From f854a3b581228e322618373bba7e7ff20f960ddb Mon Sep 17 00:00:00 2001 From: Dusk Date: Thu, 16 Apr 2026 18:02:02 +0800 Subject: [PATCH] chore: rebuild dist and sync --- .gitignore | 2 + anki-plugin-ddd-idea.md | 1287 +++++++ docs/v1-decisions.md | 111 + docs/v1-gap-report.md | 110 + docs/v1-implementation-plan.md | 46 + esbuild.config.mjs | 43 + eslint.config.mjs | 30 + main.js | 20 + main.ts | 3 + manifest.json | 10 + package-lock.json | 3428 +++++++++++++++++ package.json | 35 + scripts/stage-plugin-dist.mjs | 31 + src/application/config/PluginSettings.test.ts | 19 + src/application/config/PluginSettings.ts | 55 + src/application/dto/NoteModelDetails.ts | 4 + src/application/ports/AnkiGateway.ts | 23 + .../ports/PluginConfigRepository.ts | 6 + src/application/ports/PluginDataStore.ts | 4 + .../ports/RenderResourceResolver.ts | 5 + .../ports/SyncRegistryRepository.ts | 6 + src/application/ports/VaultGateway.ts | 7 + .../services/NoteFieldMappingService.test.ts | 111 + .../services/NoteFieldMappingService.ts | 49 + .../services/ScanScopeService.test.ts | 35 + src/application/services/ScanScopeService.ts | 33 + .../use-cases/ExecuteSyncPlanUseCase.test.ts | 151 + .../use-cases/ExecuteSyncPlanUseCase.ts | 130 + .../use-cases/ScanAndPlanSyncUseCase.ts | 72 + .../use-cases/SyncCurrentFileUseCase.test.ts | 165 + .../use-cases/SyncCurrentFileUseCase.ts | 17 + .../use-cases/SyncVaultUseCase.test.ts | 142 + src/application/use-cases/SyncVaultUseCase.ts | 17 + src/application/use-cases/types.ts | 19 + src/domain/card/entities/Card.ts | 21 + src/domain/card/entities/CardDraft.ts | 12 + src/domain/card/entities/RenderedFields.ts | 26 + src/domain/card/entities/SourceFile.ts | 5 + .../card/policies/CardIdentityPolicy.test.ts | 41 + .../card/policies/CardIdentityPolicy.ts | 20 + .../card/ports/RenderResourceResolver.ts | 19 + .../services/CardExtractionService.test.ts | 75 + .../card/services/CardExtractionService.ts | 172 + .../services/CardRenderingService.test.ts | 161 + .../card/services/CardRenderingService.ts | 219 ++ .../services/DeckResolutionService.test.ts | 17 + .../card/services/DeckResolutionService.ts | 13 + src/domain/card/value-objects/CardKey.ts | 11 + src/domain/card/value-objects/ContentHash.ts | 11 + src/domain/card/value-objects/DeckName.ts | 11 + .../card/value-objects/NoteModelName.ts | 11 + .../card/value-objects/SourceLocation.ts | 9 + src/domain/shared/hash.ts | 10 + src/domain/sync/entities/SyncRecord.ts | 11 + src/domain/sync/entities/SyncRegistry.test.ts | 50 + src/domain/sync/entities/SyncRegistry.ts | 82 + .../sync/services/SyncPlanningService.test.ts | 103 + .../sync/services/SyncPlanningService.ts | 42 + src/domain/sync/value-objects/SyncPlan.ts | 15 + src/infrastructure/anki/AnkiConnectGateway.ts | 104 + .../obsidian/ObsidianPluginDataStore.ts | 16 + .../obsidian/ObsidianVaultGateway.ts | 115 + .../DataJsonPluginConfigRepository.ts | 44 + .../DataJsonSyncRegistryRepository.test.ts | 43 + .../DataJsonSyncRegistryRepository.ts | 44 + src/presentation/AnkiHeadingSyncPlugin.ts | 104 + src/presentation/commands/registerCommands.ts | 19 + src/presentation/notices/NoticeService.ts | 19 + src/presentation/settings/PluginSettingTab.ts | 125 + tsconfig.json | 26 + versions.json | 3 + vitest.config.ts | 16 + 72 files changed, 8071 insertions(+) create mode 100644 .gitignore create mode 100644 anki-plugin-ddd-idea.md create mode 100644 docs/v1-decisions.md create mode 100644 docs/v1-gap-report.md create mode 100644 docs/v1-implementation-plan.md create mode 100644 esbuild.config.mjs create mode 100644 eslint.config.mjs create mode 100644 main.js create mode 100644 main.ts create mode 100644 manifest.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/stage-plugin-dist.mjs create mode 100644 src/application/config/PluginSettings.test.ts create mode 100644 src/application/config/PluginSettings.ts create mode 100644 src/application/dto/NoteModelDetails.ts create mode 100644 src/application/ports/AnkiGateway.ts create mode 100644 src/application/ports/PluginConfigRepository.ts create mode 100644 src/application/ports/PluginDataStore.ts create mode 100644 src/application/ports/RenderResourceResolver.ts create mode 100644 src/application/ports/SyncRegistryRepository.ts create mode 100644 src/application/ports/VaultGateway.ts create mode 100644 src/application/services/NoteFieldMappingService.test.ts create mode 100644 src/application/services/NoteFieldMappingService.ts create mode 100644 src/application/services/ScanScopeService.test.ts create mode 100644 src/application/services/ScanScopeService.ts create mode 100644 src/application/use-cases/ExecuteSyncPlanUseCase.test.ts create mode 100644 src/application/use-cases/ExecuteSyncPlanUseCase.ts create mode 100644 src/application/use-cases/ScanAndPlanSyncUseCase.ts create mode 100644 src/application/use-cases/SyncCurrentFileUseCase.test.ts create mode 100644 src/application/use-cases/SyncCurrentFileUseCase.ts create mode 100644 src/application/use-cases/SyncVaultUseCase.test.ts create mode 100644 src/application/use-cases/SyncVaultUseCase.ts create mode 100644 src/application/use-cases/types.ts create mode 100644 src/domain/card/entities/Card.ts create mode 100644 src/domain/card/entities/CardDraft.ts create mode 100644 src/domain/card/entities/RenderedFields.ts create mode 100644 src/domain/card/entities/SourceFile.ts create mode 100644 src/domain/card/policies/CardIdentityPolicy.test.ts create mode 100644 src/domain/card/policies/CardIdentityPolicy.ts create mode 100644 src/domain/card/ports/RenderResourceResolver.ts create mode 100644 src/domain/card/services/CardExtractionService.test.ts create mode 100644 src/domain/card/services/CardExtractionService.ts create mode 100644 src/domain/card/services/CardRenderingService.test.ts create mode 100644 src/domain/card/services/CardRenderingService.ts create mode 100644 src/domain/card/services/DeckResolutionService.test.ts create mode 100644 src/domain/card/services/DeckResolutionService.ts create mode 100644 src/domain/card/value-objects/CardKey.ts create mode 100644 src/domain/card/value-objects/ContentHash.ts create mode 100644 src/domain/card/value-objects/DeckName.ts create mode 100644 src/domain/card/value-objects/NoteModelName.ts create mode 100644 src/domain/card/value-objects/SourceLocation.ts create mode 100644 src/domain/shared/hash.ts create mode 100644 src/domain/sync/entities/SyncRecord.ts create mode 100644 src/domain/sync/entities/SyncRegistry.test.ts create mode 100644 src/domain/sync/entities/SyncRegistry.ts create mode 100644 src/domain/sync/services/SyncPlanningService.test.ts create mode 100644 src/domain/sync/services/SyncPlanningService.ts create mode 100644 src/domain/sync/value-objects/SyncPlan.ts create mode 100644 src/infrastructure/anki/AnkiConnectGateway.ts create mode 100644 src/infrastructure/obsidian/ObsidianPluginDataStore.ts create mode 100644 src/infrastructure/obsidian/ObsidianVaultGateway.ts create mode 100644 src/infrastructure/persistence/DataJsonPluginConfigRepository.ts create mode 100644 src/infrastructure/persistence/DataJsonSyncRegistryRepository.test.ts create mode 100644 src/infrastructure/persistence/DataJsonSyncRegistryRepository.ts create mode 100644 src/presentation/AnkiHeadingSyncPlugin.ts create mode 100644 src/presentation/commands/registerCommands.ts create mode 100644 src/presentation/notices/NoticeService.ts create mode 100644 src/presentation/settings/PluginSettingTab.ts create mode 100644 tsconfig.json create mode 100644 versions.json create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1eae0cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/anki-plugin-ddd-idea.md b/anki-plugin-ddd-idea.md new file mode 100644 index 0000000..0f5501a --- /dev/null +++ b/anki-plugin-ddd-idea.md @@ -0,0 +1,1287 @@ +# Obsidian Anki 新插件设计备忘(DDD 讨论稿) + +## 文档目的 + +这不是一个立即开工的实现文档,而是一份面向未来的设计备忘。 + +目标是把当前已经讨论清楚的方向、边界、约束、DDD 拆分方式、领域模型、同步策略、风险点与后续演进路径完整记录下来,避免之后重新开始时又回到模糊状态。 + +本文档面向的插件目标是: + +- 重新做一个新的 Obsidian -> Anki 插件 +- 吸收 `AnkiHelperForCoding` 与 `obsidian-to-anki-plugin-forCoding` 的优点 +- 保留“好用的做卡与同步能力” +- 去掉过重、过泛化、过难维护的部分 +- 明确以 Obsidian 为唯一真源 +- 明确只做单向同步:`Obsidian -> Anki` + +库一:/Users/panxiaorong/Documents/ObsidianPluginCode/AnkiHelperForCoding +库二:/Users/panxiaorong/Documents/ObsidianPluginCode/obsidian-to-anki-plugin-forCoding + +## 背景与总体判断 + +现有两个仓库各有优点,但都不适合作为新插件的直接基础。 + +### 1. `AnkiHelperForCoding` 的优点 + +- 工作流聚焦 +- 功能边界相对清楚 +- 对标题式做卡思路明确 +- 有轻量的批处理和增量处理思路 +- 贴近 Obsidian 内的实际使用方式 + +### 2. `obsidian-to-anki-plugin-forCoding` 的优点 + +- 真正具备较完整的 AnkiConnect 同步链路 +- 能创建牌组、添加卡片、更新卡片、处理媒体 +- 有较成熟的 Markdown/媒体/公式/链接转 Anki 可显示内容的能力 +- 有全库扫描、同步状态、hash、批量处理等工程经验 + +### 3. 为什么不能简单合并 + +`obsidian-to-anki-plugin-forCoding` 的问题不是“代码不好”,而是产品定位已经变成“通用同步平台”,而不是“聚焦标题式做卡的新插件”。 + +它变重主要来自以下六层: + +1. 输入语法越来越多 +2. 配置系统越来越像框架 +3. 同步系统承担了太多职责 +4. 格式转换链较长 +5. 全库扫描和过滤系统复杂化 +6. TypeScript 之外还有 Python 旁路与更重的工程负担 + +新插件的方向不是复制它,而是: + +- 保留核心能力 +- 重写核心结构 +- 主动缩掉产品边界 + +## 当前已确定的产品方向 + +下面是当前讨论中已经较明确的决定。 + +### 1. 输入语法只做“标题段落式” + +只支持标题段落式卡片,不做旧插件里的通用语法平台。 + +规则初步确定为: + +- 问答题默认使用四级标题 `H4` +- 填空题默认使用五级标题 `H5` +- 两者都允许用户修改级别 +- 一个标题对应一个卡片块 +- 卡片内容范围是: + - 从该标题下一行开始 + - 到下一个“同级或更高标题”之前结束 + +这意味着第一版明确不做: + +- 自定义正则语法 +- Inline Note +- Begin/End Note 块语法 +- 任意用户定义的卡片语法体系 + +### 2. 配置系统不做大平台 + +配置系统要保留必要能力,但不再做成旧插件那种开放式框架。 + +当前已保留的配置方向: + +- 问答题标题级别 +- 填空题标题级别 +- `TARGET DECK` +- `qa note type` +- `cloze note type` +- 默认 deck +- 扫描范围(包含/排除文件夹) +- 是否添加 Obsidian 文件回链 +- 是否为 Cloze 启用高亮转空 + +明确不做: + +- folder -> deck 映射表 +- folder -> tag 映射表 +- 通用 syntax setting 集合 +- 大量用户可编程语法选项 +- 复杂 glob 平台 + +### 3. 同步系统保留核心能力 + +同步系统虽然重,但其中很多能力是值得保留的。 + +建议保留的能力: + +- 创建 deck +- 新增 note +- 更新已有 note +- 媒体上传 +- 批量扫描 +- 同步状态持久化 +- 增量同步 +- orphan 检测 + +暂不直接做: + +- Anki -> Obsidian 反向回写 +- 双向同步 +- 高复杂度冲突解决 +- 第一版直接自动删除 orphan 对应的 Anki 卡片 + +### 4. 格式保真是关键目标 + +新插件的一个核心目标是: + +> 在 Obsidian 里看到的内容,进入 Anki 后也尽量保持可用、可读、可复习。 + +因此需要保留并重构以下能力: + +- Markdown -> HTML +- 数学公式 +- 代码块与行内代码尽量不被破坏 +- 高亮 +- Wikilink +- 图片 embed +- 音频 embed +- Obsidian 回链 + +### 5. 需要做全局扫描,但边界要收紧 + +当前讨论结果是: + +- 需要全局扫描 +- 但只支持用户指定包含文件夹和排除文件夹 +- 不做复杂 glob 规则平台 + +推荐规则: + +- `includeFolders` 为空时:扫描整个 vault +- `includeFolders` 非空时:只扫描指定文件夹 +- `excludeFolders` 始终生效 +- 仅扫描 Markdown 文件 + +### 6. 工程上不保留 Python 旁路 + +新插件只保留 TypeScript / Obsidian 插件实现。 + +明确不做: + +- Python CLI +- Python 安装与依赖管理 +- 双实现栈同步维护 + +## 核心产品原则 + +这是新插件最关键的一条原则: + +`Obsidian 是唯一真源,Anki 是同步目标。` + +这条原则一旦确定,后续很多设计就会变得稳定: + +- Obsidian 中的内容决定 Anki 中的内容 +- Anki 中手改正文不会回写 +- 下次同步时,Anki 中相关字段会被 Obsidian 内容覆盖 +- 不需要做双向冲突合并 +- 不需要把 Anki HTML 反解析成 Markdown +- 不需要做“Anki 改动写回标题块”的定位逻辑 + +## 为什么不做双向同步 + +虽然理论上可以做 Anki -> Obsidian 回写,但这不适合第一版,也可能永远不值得做。 + +原因包括: + +- 无法轻易决定谁是最终真源 +- Anki 中修改的可能是字段、标签、牌组、模型,而不只是正文 +- Obsidian 的标题段落结构并不是数据库记录 +- 回写时必须重新定位标题块 +- 标题改名、移动、层级变化都会让回写不稳定 +- HTML/媒体/链接从 Anki 回写为 Markdown 时存在信息损失 + +因此当前明确决定: + +- 只做 `Obsidian -> Anki` +- 不做双向同步 +- 不做 Anki 正文回写 + +## DDD 总体视角 + +这个插件如果用 DDD 来看,核心不是 UI,也不是 AnkiConnect API,而是: + +> 把 Obsidian 中的标题段落块建模为“可同步卡片”,并稳定地同步到 Anki。 + +因此真正的核心领域不是: + +- 设置页 +- 命令面板 +- HTTP 请求 + +而是: + +- 卡片识别 +- 卡片身份稳定性 +- 卡片渲染 +- 同步计划 +- 同步状态演进 + +## 统一语言(Ubiquitous Language) + +下面这些术语建议在整个项目里统一使用,避免后续沟通与实现混乱。 + +### Source Note + +Obsidian 中的一个 Markdown 文件。 + +### Heading Block + +一个标题以及它所管辖的正文范围。 + +### Card Draft + +从标题块中解析出来、但还没有完成渲染和同步决策的卡片草稿。 + +### Card + +领域中的标准化卡片对象,是同步的基本单位。 + +### Card Type + +卡片类型,目前只包括: + +- `basic` +- `cloze` + +### Deck Target + +卡片最终应进入的 Anki 牌组。 + +### Note Model + +Anki 中的 note type / model,例如: + +- `Basic` +- `Cloze` + +### Rendered Fields + +已经转换成可发送给 Anki 的字段内容。 + +### Sync Record + +本地保存的同步记录,用于维护 `cardKey -> noteId` 映射及同步状态。 + +### Sync Scope + +本次扫描或同步覆盖的文件范围。 + +### Orphan Card + +本地同步记录里曾存在,但本次扫描中已经找不到来源卡片的记录。 + +### Source Hash + +源卡片内容计算得到的 hash,用于判断是否变化。 + +## Bounded Context 拆分 + +建议把系统拆成四个主要上下文。 + +## 1. Card Authoring Context + +负责从 Obsidian 内容里识别卡片。 + +### 职责 + +- 判断哪些标题会被视为卡片 +- 判断这是问答题还是填空题 +- 提取标题文本 +- 提取正文块范围 +- 解析文件级 `TARGET DECK` +- 产出 `CardDraft` + +### 不负责 + +- AnkiConnect 通信 +- noteId 管理 +- orphan 检测 +- HTTP +- UI + +### 这里的核心问题 + +- 标题块切分是否准确 +- 标题级别策略是否稳定 +- 内容范围是否与用户认知一致 + +## 2. Rendering Context + +负责把卡片内容转成 Anki 可接收的字段内容。 + +### 职责 + +- Markdown -> HTML +- 数学公式处理 +- 图片与音频处理 +- Wikilink 处理 +- Obsidian 回链处理 +- 高亮与 Cloze 转换 +- 生成最终字段值 + +### 不负责 + +- 扫描哪些文件 +- 判断新增/更新/删除 +- 生成 noteId + +### 这里的核心问题 + +- 内容是否保真 +- 代码块、公式、高亮等边界是否被破坏 +- 媒体是否可上传并正确显示 + +## 3. Sync Context + +负责决定“该如何把卡片同步到 Anki”。 + +### 职责 + +- 读取历史同步记录 +- 判断新增与更新 +- 检测 orphan +- 生成同步计划 +- 执行同步动作 +- 更新本地同步记录 + +### 不负责 + +- 标题块如何识别 +- markdown 如何渲染 +- 设置 UI + +### 这里的核心问题 + +- 同一张卡如何稳定识别 +- 什么情况下是更新 +- 什么情况下视为 orphan +- 删除是否自动进行 + +## 4. Integration Context + +负责和外部系统打交道。 + +### 对 Obsidian + +- Vault 文件访问 +- MetadataCache +- 当前活动文件 +- 命令注册 +- 设置页 + +### 对 Anki + +- AnkiConnect 调用 +- 创建 deck +- 新增 note +- 更新 note +- 删除 note +- 上传媒体 + +### 角色定位 + +这是防腐层,不应该污染领域模型。 + +## Core Domain / Supporting / Generic + +为了防止后续又掉回“大而杂”,建议从战略设计上明确区分: + +### Core Domain + +- 标题块识别 +- 卡片身份生成 +- 同步计划生成 + +### Supporting Subdomains + +- Markdown/媒体/链接渲染 +- 扫描范围过滤 +- orphan 检测 +- 同步状态存储 + +### Generic Subdomains + +- 设置页 +- 命令注册 +- Notice 提示 +- JSON 持久化 +- HTTP 请求 + +## 聚合设计建议 + +不建议在这个项目里设计过多聚合。两个主要聚合已经足够。 + +## 聚合一:SourceFile Aggregate + +聚合根:`SourceFile` + +### 包含内容 + +- 文件路径 +- 文件内容 +- metadata 快照 +- 文件级 deck 信息 +- 解析出的标题块 +- 解析出的卡片草稿 + +### 职责 + +- 在单文件内完成标题块识别 +- 保证一个文件中的卡片识别逻辑一致 +- 处理文件级 deck 覆盖 + +### 不应该负责 + +- Anki noteId +- 全局同步状态 +- orphan 判断 + +## 聚合二:SyncRegistry Aggregate + +聚合根:`SyncRegistry` + +### 包含内容 + +- `cardKey -> SyncRecord` 映射 +- 历史 noteId +- source hash +- 上次同步时间 +- orphan 标记 + +### 职责 + +- 管理卡片同步状态生命周期 +- 帮助判断新增、更新、orphan +- 与数据持久化边界配合 + +### 不应该负责 + +- markdown 渲染 +- 标题块切分 + +## 实体与值对象建议 + +这里要严格区分 Entity 与 Value Object。 + +## Entity + +### SourceFile + +身份由 `filePath` 决定。 + +### Card + +身份由 `cardKey` 决定。 + +### SyncRecord + +身份由 `cardKey` 决定,也可内部持有 `noteId`。 + +## Value Object + +建议作为值对象的内容: + +- `CardKey` +- `DeckName` +- `NoteModelName` +- `HeadingLevel` +- `HeadingText` +- `HeadingBlockRange` +- `CardType` +- `RenderedFields` +- `SourceLocation` +- `ContentHash` +- `SyncScope` + +## Card 领域对象建议 + +建议把领域中的 `Card` 明确定义为“标准化同步对象”,而不是随便几段文本。 + +推荐形状如下: + +```ts +type Card = { + key: CardKey; + source: SourceLocation; + type: CardType; + heading: string; + bodyMarkdown: string; + deck: DeckName; + noteModel: NoteModelName; + tags: string[]; + fields: Record; + contentHash: ContentHash; +}; +``` + +### 字段意义 + +- `key` + - 这张卡在领域中的稳定身份 +- `source` + - 它来自哪个文件、哪个标题位置 +- `type` + - `basic` 或 `cloze` +- `heading` + - 标题文本 +- `bodyMarkdown` + - 原始正文 +- `deck` + - 最终牌组 +- `noteModel` + - 最终 note type +- `fields` + - 已经渲染好的 Anki 字段 +- `contentHash` + - 用于判断内容是否变化 + +### 一个重要原则 + +`bodyMarkdown` 与 `fields` 不应混为一谈。 + +前者是原始领域内容,后者是渲染后的派生内容。二者都应有清晰位置。 + +## Card Draft 建议 + +为了让解析与渲染解耦,建议先有 `CardDraft`。 + +```ts +type CardDraft = { + source: SourceLocation; + heading: string; + headingLevel: number; + type: "basic" | "cloze"; + bodyMarkdown: string; + deckHint?: string; +}; +``` + +然后由渲染层把 `CardDraft` 转成正式 `Card`。 + +这样做的好处是: + +- 标题识别逻辑和渲染逻辑解耦 +- deck 解析、字段映射可以单独演进 +- 未来更容易测试 + +## Card Type 语义建议 + +当前只保留两类卡。 + +### Basic + +语义建议: + +- 标题 -> Front +- 正文 -> Back + +### Cloze + +语义建议: + +- 正文 -> Cloze 主字段 +- 标题不进入主 cloze 字段 +- 标题可选进入额外字段,比如 `Extra` 或上下文字段 + +原因: + +- 如果标题也拼进 Cloze 主字段,会干扰 Cloze 语义 +- 标题更适合做上下文或辅助定位信息 + +## Note Model 与 Deck 的区别 + +必须明确区分: + +### Note Model + +决定 Anki 里字段结构,例如: + +- `Basic` +- `Basic (and reversed card)` +- `Cloze` + +### Deck + +决定卡片进入哪个牌组,例如: + +- `English` +- `English::Words` +- `History::Chapter1` + +它们是两个完全不同的维度。 + +因此配置上建议分别建模,而不是混淆。 + +## Card Identity(cardKey)策略 + +这是整个系统最敏感的问题之一。 + +### 目标 + +`cardKey` 需要尽量满足: + +- 同一文件内唯一 +- 同一轮扫描内唯一 +- 在小幅编辑下尽量稳定 +- 不依赖 Anki noteId + +### 第一版推荐策略 + +第一版可以先用较保守的组合: + +```ts +cardKey = hash( + filePath + + headingLevel + + headingText + + blockStartLine + + cardType +) +``` + +### 优点 + +- 实现简单 +- 同文件内冲突概率低 +- 足够支撑第一版 + +### 缺点 + +- 标题改名会影响 key +- 前面插入内容导致行号变化会影响 key +- 文件移动会影响 key + +### 未来可升级方向 + +未来如果需要更稳定,可以考虑: + +- 持久化块级 anchor +- 在标题块内植入不可见标识 +- 使用内部 source id 映射 + +### DDD 视角下的建议 + +不要把 key 生成逻辑散落在代码中,而应抽成一个领域策略: + +- `CardIdentityPolicy` + +它属于业务规则,不只是技术细节。 + +## Deck 解析策略 + +当前建议的 deck 决策顺序: + +1. 文件中显式配置的 `TARGET DECK` +2. 插件默认 deck + +第一版不做: + +- folder -> deck 映射 +- 基于路径的 deck 自动推导 +- 标题级单独 deck 覆盖 + +### `TARGET DECK` 的定位 + +作为保留下来的最小 syntax setting: + +- 只保留 `Target Deck Line` +- 不保留 Begin/End Note 等其它语法开关 + +## 标题级别策略 + +当前建议默认值: + +- `qaHeadingLevel = 4` +- `clozeHeadingLevel = 5` + +### 需要保证的业务规则 + +- 两个级别都允许用户修改 +- 两个级别不应相同 +- 如果相同,应在配置层校验并阻止 + +## 扫描范围模型 + +虽然保留全局扫描,但需要主动收缩边界。 + +### 推荐配置 + +```ts +type ScanScopeConfig = { + includeFolders: string[]; + excludeFolders: string[]; +}; +``` + +### 推荐行为 + +- `includeFolders` 为空:扫描全部 Markdown 文件 +- `includeFolders` 非空:只扫描这些文件夹下的 Markdown 文件 +- `excludeFolders` 永远对候选集做排除 + +### 第一版不做 + +- 任意 glob +- 单文件模式表达式 +- ignore 语法 DSL +- 复杂优先级覆盖规则 + +## Sync Record 建议 + +本地同步状态建议保存为独立模型。 + +```ts +type SyncRecord = { + cardKey: string; + noteId: number; + filePath: string; + sourceHash: string; + lastSyncedAt: number; + orphan: boolean; +}; +``` + +### 字段说明 + +- `cardKey` + - 领域卡片身份 +- `noteId` + - Anki 中对应 note 的 id +- `filePath` + - 便于追踪来源与调试 +- `sourceHash` + - 判断是否发生变化 +- `lastSyncedAt` + - 便于调试和排错 +- `orphan` + - 表示当前扫描未再找到对应来源 + +## 为什么建议先不做自动删除 + +虽然同步系统中“删除失效卡片”看起来合理,但第一版不建议直接做自动删除。 + +### 原因 + +- 标题改名会被误判为删除旧卡、创建新卡 +- 文件移动可能导致 key 变化 +- 扫描范围变窄时可能导致误判 orphan +- 用户切换设置后,候选卡集合可能临时变化 +- 删除是破坏性动作,风险高 + +### 第一版建议 + +- 支持新增 +- 支持更新 +- 支持 orphan 检测 +- 支持手动清理 orphan 的未来扩展点 +- 默认不自动删 Anki 卡 + +## 建议的领域服务 + +以下领域服务是比较关键的。 + +## 1. CardExtractionService + +### 输入 + +- `SourceFile` +- `HeadingPolicy` + +### 输出 + +- `CardDraft[]` + +### 负责 + +- 找到有效标题 +- 切分正文块 +- 判断 `basic` / `cloze` + +## 2. DeckResolutionService + +### 输入 + +- 文件级 `TARGET DECK` +- 插件默认 deck +- 卡片配置 + +### 输出 + +- `DeckName` + +### 负责 + +- 决定卡片最终牌组 + +## 3. CardIdentityService + +### 输入 + +- `SourceLocation` +- 标题信息 +- 卡片类型 + +### 输出 + +- `CardKey` + +### 负责 + +- 生成稳定 identity + +## 4. CardRenderingService + +### 输入 + +- `CardDraft` + +### 输出 + +- `Card` + +### 负责 + +- 生成最终字段 +- 处理 Markdown/媒体/链接/公式 + +## 5. SyncPlanningService + +### 输入 + +- 当前扫描得到的 `Card[]` +- 历史 `SyncRecord[]` + +### 输出 + +- `SyncPlan` + +### 负责 + +- 判断 add +- 判断 update +- 判断 orphan + +这个服务是整个同步系统的关键,必须单独存在,不应散落在命令或 UI 层中。 + +## Sync Plan 建议 + +推荐把同步规划显式建模出来。 + +```ts +type SyncPlan = { + toCreateDecks: string[]; + toAdd: Card[]; + toUpdate: Array<{ card: Card; noteId: number }>; + toMarkOrphan: SyncRecord[]; +}; +``` + +### 第一版动作集合建议 + +第一版先只做: + +- `createDeck` +- `add` +- `update` +- `markOrphan` + +不做: + +- `delete` + +## Application Service / Use Case 建议 + +DDD 中应用服务负责“编排用例”,不承担核心业务规则。 + +## 1. ScanAndPlanSyncUseCase + +### 流程 + +1. 读取扫描范围 +2. 收集候选文件 +3. 从文件解析 `CardDraft` +4. 渲染为 `Card` +5. 读取历史 `SyncRecord` +6. 生成 `SyncPlan` + +## 2. ExecuteSyncPlanUseCase + +### 流程 + +1. 创建缺失 deck +2. 上传媒体 +3. 新增 cards +4. 更新 cards +5. 更新同步状态 +6. 标记 orphan + +## 3. SyncCurrentFileUseCase + +### 流程 + +只对当前文件执行完整扫描与同步流程。 + +## 4. SyncVaultUseCase + +### 流程 + +对扫描范围内的文件执行完整扫描与同步流程。 + +## 5. PreviewSyncUseCase(未来可选) + +### 流程 + +只生成同步计划,不真正执行。 + +### 价值 + +- 调试 +- 风险确认 +- 后续做 UI 预览 + +## Repository 建议 + +Repository 主要是持久化边界,不是业务逻辑本身。 + +## 1. SyncRegistryRepository + +### 负责 + +- 读取同步记录 +- 保存同步记录 +- upsert 卡片同步状态 +- 标记 orphan + +### 可能的存储位置 + +- 插件 `data.json` + +## 2. PluginConfigRepository + +### 负责 + +- 读取插件配置 +- 输出面向领域的配置对象 + +### 注意 + +不要让领域层直接消费 Obsidian 原始 Setting UI 数据结构。 + +## 3. ScanScopeRepository(可选) + +如果未来扫描配置较复杂,可以单独抽象。 + +## Anti-Corruption Layer(防腐层)建议 + +这个项目非常需要防腐层,否则外部 API 会把领域污染掉。 + +## 对 Obsidian 的防腐层 + +不要让领域层直接依赖: + +- `App` +- `TFile` +- `CachedMetadata` + +建议由基础设施层封装为: + +- `ObsidianVaultGateway` +- `ObsidianMetadataGateway` +- `ObsidianWorkspaceGateway` + +然后向应用层提供干净 DTO。 + +## 对 AnkiConnect 的防腐层 + +不要让领域层直接拼 action JSON。 + +建议封装为: + +- `AnkiGateway` + +对外提供的方法应是业务语义,而不是裸 `invoke("addNote")`: + +- `ensureDeckExists` +- `addNote` +- `updateNote` +- `deleteNotes` +- `storeMedia` + +## 领域不变量建议 + +这些不变量建议尽早确立。 + +### 卡片识别相关 + +- 一个标题块最多生成一张卡 +- 一张卡必须属于且只属于一种 `CardType` +- 同一次扫描内 `cardKey` 不可重复 + +### 配置相关 + +- 问答题级别和填空题级别不能相同 +- 每张卡必须解析出 deck +- 每张卡必须解析出 note model + +### Basic / Cloze 相关 + +- `basic` 必须有 front/back 字段映射 +- `cloze` 必须映射到支持 cloze 的 note model + +### 同步相关 + +- 一个 `cardKey` 只能对应一个活动同步记录 +- 一个 `noteId` 不应对应多个不同 `cardKey` +- orphan 默认不能自动删除 + +## 建议的目录结构 + +如果未来真的开工,建议从一开始就按边界建目录,而不是继续堆在 `main.ts` 和工具函数里。 + +```text +src/ + domain/ + card/ + entities/ + value-objects/ + services/ + policies/ + sync/ + entities/ + value-objects/ + services/ + shared/ + value-objects/ + application/ + use-cases/ + dto/ + infrastructure/ + obsidian/ + anki/ + persistence/ + presentation/ + commands/ + settings/ + notices/ +``` + +可以进一步细化为: + +```text +src/domain/card/entities/Card.ts +src/domain/card/entities/SourceFile.ts +src/domain/card/value-objects/CardKey.ts +src/domain/card/value-objects/DeckName.ts +src/domain/card/value-objects/SourceLocation.ts +src/domain/card/services/CardExtractionService.ts +src/domain/card/services/CardRenderingService.ts +src/domain/card/policies/CardIdentityPolicy.ts + +src/domain/sync/entities/SyncRecord.ts +src/domain/sync/entities/SyncRegistry.ts +src/domain/sync/services/SyncPlanningService.ts + +src/application/use-cases/SyncCurrentFileUseCase.ts +src/application/use-cases/SyncVaultUseCase.ts +src/application/use-cases/ScanAndPlanSyncUseCase.ts +src/application/use-cases/ExecuteSyncPlanUseCase.ts + +src/infrastructure/obsidian/ObsidianVaultGateway.ts +src/infrastructure/obsidian/ObsidianMetadataGateway.ts +src/infrastructure/anki/AnkiConnectGateway.ts +src/infrastructure/persistence/DataJsonSyncRegistryRepository.ts + +src/presentation/commands/registerCommands.ts +src/presentation/settings/PluginSettingTab.ts +src/presentation/notices/NoticeService.ts +``` + +## 需要保留和重写的部分 + +这是从两个旧仓库中提炼出的结论。 + +## 可以借鉴的方向 + +### 来自 `AnkiHelperForCoding` + +- 聚焦标题级工作流 +- 批处理与增量处理思路 +- 与 Obsidian 工作流贴近的命令设计 + +### 来自 `obsidian-to-anki-plugin-forCoding` + +- AnkiConnect 通信层思路 +- Markdown/链接/媒体/公式转 Anki 的处理经验 +- 媒体上传与 deck 创建 +- 同步状态管理经验 + +## 必须重写的部分 + +- 领域模型 +- 卡片解析流程 +- 同步计划生成方式 +- 配置模型 +- 扫描范围模型 +- 旧仓库里与通用语法平台强耦合的部分 + +## 第一版建议做什么 + +虽然当前阶段不是立即开发,但为了将来回头时不迷失,仍建议记录“第一版的可交付边界”。 + +### 第一版建议包含 + +- 标题段落式卡片解析 +- `H4` 问答题、`H5` 填空题默认配置 +- 标题级别可配置 +- `Basic` / `Cloze` 两类卡片 +- `qaNoteType` / `clozeNoteType` 可配置 +- 默认 deck +- 文件级 `TARGET DECK` +- 全局扫描 +- include/exclude 文件夹 +- 增量同步 +- deck 创建 +- note 新增 +- note 更新 +- 媒体上传 +- markdown/html 转换 +- Obsidian 回链 +- orphan 检测(不自动删) + +## 第一版明确不做什么 + +- 自定义正则系统 +- inline note +- begin/end note 语法体系 +- folder -> deck 映射 +- folder -> tag 映射 +- 双向同步 +- Anki -> Obsidian 回写 +- Python CLI +- 自动删除 orphan 对应 Anki 卡片 +- 复杂 glob 系统 + +## 风险点与尚未完全拍板的问题 + +虽然方向已经清楚,但仍有一些点需要在未来真正开工前再确认。 + +## 1. `cardKey` 稳定性 + +当前推荐方案足以起步,但并不完美。 + +需要后续根据实际使用情况决定: + +- 是否接受标题改名导致新卡 +- 是否接受文件移动导致重建 +- 是否需要更稳定的内部 source id + +## 2. Cloze 标题是否写入字段 + +当前倾向是: + +- Cloze 主字段只放正文 +- 标题作为上下文信息另放 + +但这一点未来仍可在使用体验上再验证。 + +## 3. orphan 的后续处理策略 + +当前明确是: + +- 先检测 +- 先标记 +- 不自动删 + +未来是否要加“手动清理 orphan”,可以再评估。 + +## 4. 媒体处理边界 + +第一版建议保留图片和音频支持,但需要注意: + +- 不同格式是否都支持 +- 文件定位是否稳定 +- 上传后是否做重复检测 + +## 5. 渲染保真度边界 + +需要明确接受一个现实: + +- 不可能一开始就 100% 复刻 Obsidian 显示效果 + +更现实的目标是: + +- 保证常用内容结构可读、可复习 +- 保证不轻易破坏数学、代码和媒体 + +## 未来扩展方向 + +以下方向可以保留想法,但不应现在就进第一版边界。 + +### 1. 预览同步计划 + +在真正同步前,让用户看到: + +- 将新增几张 +- 将更新几张 +- 哪些是 orphan + +### 2. 当前标题同步 + +相较于当前文件同步,进一步细化到只同步当前标题块。 + +### 3. 手动 orphan 清理 + +用户明确确认后,删除失效 Anki 卡片。 + +### 4. 更稳定的 card identity + +引入块级 anchor 或内部 source id。 + +### 5. 更多上下文字段 + +例如: + +- 文件名 +- 标题路径 +- Obsidian 链接 +- 上级标题 + +## 结论 + +当前讨论已经形成一个较稳定的产品方向: + +- 这是一个新的、聚焦“标题段落式做卡”的 Obsidian -> Anki 插件 +- 它借鉴旧项目经验,但不继承旧项目的通用框架定位 +- 它明确以 Obsidian 为唯一真源 +- 它只做单向同步 +- 它保留强同步能力、保留较高内容保真度、保留全局扫描 +- 它主动砍掉通用语法平台、folder 映射、Python 旁路、双向同步与自动删除等高复杂度设计 + +从 DDD 视角看,真正应被认真设计的核心只有三件事: + +1. 如何把标题块稳定识别为领域卡片 +2. 如何生成尽量稳定的卡片身份 +3. 如何在不失控的前提下生成和执行同步计划 + +只要这三点做对,未来无论是否真的开工,这个插件的方向都不会再轻易漂移。 + +## 建议的下一步(未来恢复此 idea 时) + +当未来决定真正开始做这个插件时,建议按下面顺序继续: + +1. 先把 `Card`、`CardDraft`、`SyncRecord` 的精确定义拍板 +2. 再拍板 `cardKey` 策略 +3. 再定义 `Basic` / `Cloze` 的字段映射策略 +4. 再实现 `CardExtractionService` +5. 再实现 `CardRenderingService` +6. 再实现 `SyncPlanningService` +7. 最后接 Obsidian UI 与 AnkiConnect 集成 + +在真正动手写代码之前,都不要跳过前面三个建模步骤。 diff --git a/docs/v1-decisions.md b/docs/v1-decisions.md new file mode 100644 index 0000000..8569114 --- /dev/null +++ b/docs/v1-decisions.md @@ -0,0 +1,111 @@ +# V1 Decisions + +This file is the authoritative lock for V1 implementation behavior. +If an implementation detail conflicts with this file, this file wins. + +## Product Boundary + +- Obsidian is the only source of truth. +- Sync direction is only Obsidian -> Anki. +- Card authoring is heading-paragraph based only. +- V1 is intentionally narrow and does not become a generic syntax platform. + +## Heading Policy + +- Default QA heading level is H4. +- Default Cloze heading level is H5. +- QA and Cloze heading levels are configurable. +- QA and Cloze heading levels must not be equal. + +## Card Identity + +- `cardKey` is computed as: + + `hash(filePath + headingLevel + headingText + blockStartLine + cardType)` + +- V1 accepts instability caused by: + - heading rename + - file move + - line-number shifts + +- V1 does not introduce: + - hidden source ids + - anchor-based identity + - persistent block ids + +## Field Mapping + +### Basic + +- Front = heading +- Back = body + +### Cloze + +- Text = body +- Extra = heading + +### Model Resolution + +- QA note type is configurable and defaults to `Basic`. +- Cloze note type is configurable and defaults to `Cloze`. +- V1 uses queried Anki model field names plus small heuristics. +- V1 does not introduce a full user-facing field-mapping UI. + +## Deck Resolution + +Deck resolution order is: + +1. file-level `TARGET DECK` +2. plugin default deck + +V1 does not implement: + +- folder -> deck mapping +- path-derived deck rules +- per-heading deck overrides + +## Scan Scope + +- `includeFolders` empty => scan all markdown files in the vault +- `includeFolders` non-empty => scan only files under included folders +- `excludeFolders` always applies +- only markdown files are candidates +- no glob DSL is supported in V1 + +## Sync Behavior + +- V1 supports deck creation. +- V1 supports note add. +- V1 supports note update. +- V1 supports media upload. +- V1 supports incremental sync through local sync records. +- V1 supports orphan detection. + +### Orphan Handling + +- detect and mark only +- never auto-delete in V1 +- deletion remains out of scope unless a future explicit manual workflow is added + +## Rendering Target + +- prioritize readability and reviewability in Anki +- preserve math where practical +- preserve code blocks and inline code where practical +- preserve links and media where practical +- preserve Obsidian backlinks where enabled +- do not chase pixel-perfect Obsidian rendering fidelity + +## Explicitly Out Of Scope For V1 + +- bidirectional sync +- Anki -> Obsidian writeback +- Python tooling or sidecar CLI +- folder -> deck mapping +- folder -> tag mapping +- generic regex syntax systems +- inline note systems +- begin/end note block systems +- automatic orphan deletion +- complex glob or scan DSLs \ No newline at end of file diff --git a/docs/v1-gap-report.md b/docs/v1-gap-report.md new file mode 100644 index 0000000..ea6e12a --- /dev/null +++ b/docs/v1-gap-report.md @@ -0,0 +1,110 @@ +# V1 Gap Report + +## Audit Summary + +The current implementation is already close to the memo's intended V1 surface. +It passes its existing tests, build, and lint, and it already supports the main heading-paragraph workflow. + +The remaining problems are not a full-rewrite problem. +They are a focused repair problem: + +1. a few DDD boundaries are still leaking, +2. some V1 product decisions are implemented only implicitly, +3. the build pipeline does not yet produce a ready-to-sync plugin package by itself, +4. a few important V1 behaviors are not directly protected by tests. + +## What Already Aligns With The Memo + +- Obsidian is treated as the only source of truth. +- Sync direction is one-way only: Obsidian -> Anki. +- Card authoring is heading-paragraph based. +- Default heading levels are H4 for QA and H5 for Cloze. +- Equal QA and Cloze heading levels are rejected. +- `TARGET DECK` is supported as a file-level override. +- Default deck, QA note type, and Cloze note type are configurable. +- Include/exclude folder scanning exists and is markdown-only. +- Add, update, deck creation, media upload, incremental sync, and orphan marking are implemented. +- Auto-delete for orphan cards is not implemented. +- Rendering already covers markdown, math, code, wikilinks, image embeds, audio embeds, and Obsidian backlinks. + +## V1 Scope Drift Or Delivery Gaps + +### 1. Build output was not fully productized + +The bundle was emitted to a build folder, but the full plugin package still depended on a manual follow-up step to copy: + +- `manifest.json` +- `versions.json` +- a small distribution README + +That is a delivery gap for a plugin project because the build should produce a ready-to-sync package folder directly. + +### 2. V1 decisions were not locked in a dedicated authoritative document + +The repository had an implementation plan, but not a dedicated V1 decisions document that explicitly locks: + +- cardKey strategy +- Basic field mapping +- Cloze field mapping +- orphan behavior +- scan scope behavior +- explicit out-of-scope items + +That makes future edits more likely to drift. + +## DDD Boundary Violations + +### 1. Domain rendering depended on an application-layer port + +`CardRenderingService` imported `RenderResourceResolver` from `application/ports`. + +This is a direct layering leak because the rendering contract is part of the domain-facing rendering boundary and should be owned below, or at least alongside, the domain rather than above it. + +### 2. Anki port depended on a service-owned DTO + +`AnkiGateway` depended on `NoteModelDetails` exported from `NoteFieldMappingService`. + +That couples an application port to a concrete service file rather than to a neutral DTO/contract. + +### 3. Sync registry lifecycle was application-heavy + +The registry entity already existed, but the execution use case still assembled several lifecycle transitions directly with raw object spreads. + +That is acceptable for a quick implementation, but it under-expresses the memo's intent that sync lifecycle should be a first-class domain concern. + +## Missing Or Weakly Protected Tests + +The current suite is good, but two V1-critical areas were still under-protected: + +1. plugin-level heading validation was not directly tested through `PluginSettings` +2. execute-time orphan marking behavior was not directly tested in the execution use case itself + +## Decisions Implemented Implicitly But Not Clearly Locked + +These decisions already existed in code, but were not written down as authoritative V1 decisions: + +- card identity uses `hash(filePath + headingLevel + headingText + blockStartLine + cardType)` +- Basic cards map heading to Front and body to Back +- Cloze cards map body to Text and heading to Extra +- orphan handling marks records only and never auto-deletes +- scan scope is include-first, exclude-always, markdown-only +- rendering targets readability and reviewability rather than pixel-perfect Obsidian fidelity +- model field mapping uses minimal heuristics instead of a full field-mapping UI + +## Repair Plan + +1. Add `docs/v1-decisions.md` and lock V1 behavior explicitly. +2. Move rendering resource contracts to a domain-owned boundary. +3. Move note model details into a neutral DTO contract. +4. Tighten sync registry lifecycle so execution uses domain methods instead of ad hoc object rewrites. +5. Make the build emit a ready-to-sync plugin package in one folder. +6. Add targeted tests for plugin settings validation and execute-time orphan handling. + +## Non-Goals For This Repair Pass + +- No bidirectional sync. +- No folder-to-deck or folder-to-tag mapping. +- No generic syntax platform. +- No Python tooling. +- No automatic orphan deletion. +- No anchor-based or hidden-source identity scheme. \ No newline at end of file diff --git a/docs/v1-implementation-plan.md b/docs/v1-implementation-plan.md new file mode 100644 index 0000000..c9c9e33 --- /dev/null +++ b/docs/v1-implementation-plan.md @@ -0,0 +1,46 @@ +# V1 Implementation Plan + +This file locks the concrete V1 implementation decisions before major coding. + +## Scope + +- Direction is only Obsidian -> Anki. +- Obsidian content is the only source of truth. +- Card authoring is heading-paragraph based only. +- V1 supports only Basic and Cloze cards. +- V1 supports add, update, media upload, deck creation, incremental sync, and orphan marking. +- V1 does not support bidirectional sync, Python tooling, generic syntax platforms, or orphan auto-delete. + +## Concrete V1 Decisions + +- Project is implemented in TypeScript only. +- Architecture is split into domain, application, infrastructure, and presentation layers. +- Default heading levels are H4 for Basic and H5 for Cloze. +- Validation rejects equal QA and Cloze heading levels. +- Card identity is generated as a hash of file path, heading level, heading text, block start line, and card type. +- File-level deck override is parsed from a literal TARGET DECK line. +- Scan scope is folder include/exclude only. No glob DSL is supported. +- When includeFolders is empty, the whole vault is scanned for Markdown files. +- Exclude folders always filter candidates. +- Cloze cards render body content into the cloze field and heading into an auxiliary context field. +- Orphan handling only marks local records as orphan. It never deletes Anki notes. + +## Rendering Decisions + +- Markdown rendering uses a focused pipeline tuned for Anki output rather than trying to reproduce the entire Obsidian renderer. +- Obsidian math is preserved as MathJax-compatible inline and display markup. +- Wikilinks become obsidian:// backlinks where possible. +- Image embeds render to HTML img tags after media upload. +- Audio embeds render to Anki sound tags after media upload. +- Inline code and fenced code blocks are preserved through markdown rendering. + +## Note Model Assumptions + +- Basic note type defaults to Basic and maps to front/back style fields. +- Cloze note type defaults to Cloze and maps body into a text field and heading into an auxiliary field such as Extra. +- V1 resolves model field names by querying AnkiConnect and applying small heuristics rather than exposing a full field mapping system. + +## Validation Plan + +- Unit tests cover extraction, identity, deck resolution, rendering, sync planning, scan scope behavior, persistence, and application use cases. +- Final validation runs npm test, npm run build, and npm run lint. \ No newline at end of file diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..8a72dc7 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,43 @@ +import esbuild from "esbuild"; +import process from "node:process"; +import builtins from "builtin-modules"; + +const banner = `/*\nTHIS IS A GENERATED/BUNDLED FILE BY ESBUILD\n*/`; + +const production = process.argv[2] === "production"; + +const context = await esbuild.context({ + banner: { js: banner }, + bundle: true, + entryPoints: ["main.ts"], + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins, + ], + format: "cjs", + logLevel: "info", + minify: production, + outfile: "dist/plugin/main.js", + sourcemap: production ? false : "inline", + target: "es2020", + treeShaking: true, +}); + +if (production) { + await context.rebuild(); + process.exit(0); +} + +await context.watch(); \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..eb49150 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,30 @@ +import js from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: ["main.js", "node_modules/**", "coverage/**"], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.ts"], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + }, + parserOptions: { + project: "./tsconfig.json", + }, + }, + rules: { + "@typescript-eslint/consistent-type-imports": [ + "error", + { "prefer": "type-imports" } + ], + "@typescript-eslint/no-explicit-any": "error" + }, + }, +); \ No newline at end of file diff --git a/main.js b/main.js new file mode 100644 index 0000000..2bf4068 --- /dev/null +++ b/main.js @@ -0,0 +1,20 @@ +/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +*/ +"use strict";var te=Object.defineProperty;var At=Object.getOwnPropertyDescriptor;var Ft=Object.getOwnPropertyNames;var vt=Object.prototype.hasOwnProperty;var cu=(u,e)=>{for(var t in e)te(u,t,{get:e[t],enumerable:!0})},wt=(u,e,t,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of Ft(e))!vt.call(u,n)&&n!==t&&te(u,n,{get:()=>e[n],enumerable:!(r=At(e,n))||r.enumerable});return u};var St=u=>wt(te({},"__esModule",{value:!0}),u);var Kn={};cu(Kn,{default:()=>Zn});module.exports=St(Kn);var _t=require("obsidian");var $={qaHeadingLevel:4,clozeHeadingLevel:5,qaNoteType:"Basic",clozeNoteType:"Cloze",defaultDeck:"Obsidian",includeFolders:[],excludeFolders:[],addObsidianBacklink:!0,convertHighlightsToCloze:!0,ankiConnectUrl:"http://127.0.0.1:8765"};function J(u){let e=[u.qaHeadingLevel,u.clozeHeadingLevel];for(let t of e)if(!Number.isInteger(t)||t<1||t>6)throw new Error("Heading levels must be integers between 1 and 6.");if(u.qaHeadingLevel===u.clozeHeadingLevel)throw new Error("QA and Cloze heading levels must be different.");if(!u.qaNoteType.trim())throw new Error("QA note type is required.");if(!u.clozeNoteType.trim())throw new Error("Cloze note type is required.");if(!u.defaultDeck.trim())throw new Error("Default deck is required.");if(!u.ankiConnectUrl.trim())throw new Error("AnkiConnect URL is required.")}var hu=class{filter(e,t,r){let n=t.map(l0).filter(Boolean),o=r.map(l0).filter(Boolean);return e.filter(c=>{if(!c.path.toLowerCase().endsWith(".md"))return!1;let i=Tt(c.path),a=n.length===0||n.some(l=>d0(i,l)),s=o.some(l=>d0(i,l));return a&&!s})}};function l0(u){return u.trim().replace(/\\/g,"/").replace(/^\/+|\/+$/g,"")}function Tt(u){return u.trim().replace(/\\/g,"/").replace(/^\/+/,"")}function d0(u,e){return u===e||u.startsWith(`${e}/`)}function bu(u){let e=u.trim();if(!e)throw new Error("Deck name cannot be empty.");return e}var Rt=/^(#{1,6})\s+(.*?)\s*$/,Pt=/^\s*TARGET DECK\s*:\s*(.+?)\s*$/i,mu=class{extract(e,t){Lt(t);let r=e.content.split(/\r?\n/),n=Mt(r),o=Nt(r),c=[];for(let i=0;i6)throw new Error("QA heading level must be an integer between 1 and 6.");if(!Number.isInteger(t)||t<1||t>6)throw new Error("Cloze heading level must be an integer between 1 and 6.");if(e===t)throw new Error("QA and Cloze heading levels must not be equal.")}function Mt(u){let e=[],t=null;for(let r=0;re&&!u[t-1].trim();)t-=1;return u.slice(e,t)}var de={};cu(de,{arrayReplaceAt:()=>le,assign:()=>Y,escapeHtml:()=>q,escapeRE:()=>_r,fromCodePoint:()=>lu,has:()=>lr,isMdAsciiPunct:()=>G,isPunctChar:()=>j,isSpace:()=>y,isString:()=>wu,isValidEntityCode:()=>Su,isWhiteSpace:()=>V,lib:()=>Cr,normalizeReference:()=>Z,unescapeAll:()=>I,unescapeMd:()=>br});var _u={};cu(_u,{decode:()=>au,encode:()=>ku,format:()=>X,parse:()=>su});var f0={};function Bt(u){let e=f0[u];if(e)return e;e=f0[u]=[];for(let t=0;t<128;t++){let r=String.fromCharCode(t);e.push(r)}for(let t=0;t=55296&&l<=57343?n+="\uFFFD\uFFFD\uFFFD":n+=String.fromCharCode(l),o+=6;continue}}if((i&248)===240&&o+91114111?n+="\uFFFD\uFFFD\uFFFD\uFFFD":(f-=65536,n+=String.fromCharCode(55296+(f>>10),56320+(f&1023))),o+=9;continue}}n+="\uFFFD"}return n})}xu.defaultChars=";/?:@&=+$,#";xu.componentChars="";var au=xu;var p0={};function Ht(u){let e=p0[u];if(e)return e;e=p0[u]=[];for(let t=0;t<128;t++){let r=String.fromCharCode(t);/^[0-9a-z]$/i.test(r)?e.push(r):e.push("%"+("0"+t.toString(16).toUpperCase()).slice(-2))}for(let t=0;t"u"&&(t=!0);let r=Ht(e),n="";for(let o=0,c=u.length;o=55296&&i<=57343){if(i>=55296&&i<=56319&&o+1=56320&&a<=57343){n+=encodeURIComponent(u[o]+u[o+1]),o++;continue}}n+="%EF%BF%BD";continue}n+=encodeURIComponent(u[o])}return n}gu.defaultChars=";/?:@&=+$,-_.!~*'()#";gu.componentChars="-_.!~*'()";var ku=gu;function X(u){let e="";return e+=u.protocol||"",e+=u.slashes?"//":"",e+=u.auth?u.auth+"@":"",u.hostname&&u.hostname.indexOf(":")!==-1?e+="["+u.hostname+"]":e+=u.hostname||"",e+=u.port?":"+u.port:"",e+=u.pathname||"",e+=u.search||"",e+=u.hash||"",e}function yu(){this.protocol=null,this.slashes=null,this.auth=null,this.port=null,this.hostname=null,this.hash=null,this.search=null,this.pathname=null}var Ut=/^([a-z0-9.+-]+:)/i,Ot=/:[0-9]*$/,$t=/^(\/\/?(?!\/)[^\?\s]*)(\?[^\s]*)?$/,Vt=["<",">",'"',"`"," ","\r",` +`," "],jt=["{","}","|","\\","^","`"].concat(Vt),Gt=["'"].concat(jt),h0=["%","/","?",";","#"].concat(Gt),b0=["/","?","#"],Zt=255,m0=/^[+a-z0-9A-Z_-]{0,63}$/,Kt=/^([+a-z0-9A-Z_-]{0,63})(.*)$/,x0={javascript:!0,"javascript:":!0},g0={http:!0,https:!0,ftp:!0,gopher:!0,file:!0,"http:":!0,"https:":!0,"ftp:":!0,"gopher:":!0,"file:":!0};function Wt(u,e){if(u&&u instanceof yu)return u;let t=new yu;return t.parse(u,e),t}yu.prototype.parse=function(u,e){let t,r,n,o=u;if(o=o.trim(),!e&&u.split("#").length===1){let s=$t.exec(o);if(s)return this.pathname=s[1],s[2]&&(this.search=s[2]),this}let c=Ut.exec(o);if(c&&(c=c[0],t=c.toLowerCase(),this.protocol=c,o=o.substr(c.length)),(e||c||o.match(/^\/\/[^@\/]+@[^@\/]+/))&&(n=o.substr(0,2)==="//",n&&!(c&&x0[c])&&(o=o.substr(2),this.slashes=!0)),!x0[c]&&(n||c&&!g0[c])){let s=-1;for(let d=0;d127?m+="x":m+=D[g];if(!m.match(m0)){let g=d.slice(0,b),x=d.slice(b+1),k=D.match(Kt);k&&(g.push(k[1]),x.unshift(k[2])),x.length&&(o=x.join(".")+o),this.hostname=g.join(".");break}}}}this.hostname.length>Zt&&(this.hostname=""),p&&(this.hostname=this.hostname.substr(1,this.hostname.length-2))}let i=o.indexOf("#");i!==-1&&(this.hash=o.substr(i),o=o.slice(0,i));let a=o.indexOf("?");return a!==-1&&(this.search=o.substr(a),o=o.slice(0,a)),o&&(this.pathname=o),g0[t]&&this.hostname&&!this.pathname&&(this.pathname=""),this};yu.prototype.parseHost=function(u){let e=Ot.exec(u);e&&(e=e[0],e!==":"&&(this.port=e.substr(1)),u=u.substr(0,u.length-e.length)),u&&(this.hostname=u)};var su=Wt;var re={};cu(re,{Any:()=>Cu,Cc:()=>Du,Cf:()=>k0,P:()=>Q,S:()=>Eu,Z:()=>Au});var Cu=/[\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/;var Du=/[\0-\x1F\x7F-\x9F]/;var k0=/[\xAD\u0600-\u0605\u061C\u06DD\u070F\u0890\u0891\u08E2\u180E\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\uFEFF\uFFF9-\uFFFB]|\uD804[\uDCBD\uDCCD]|\uD80D[\uDC30-\uDC3F]|\uD82F[\uDCA0-\uDCA3]|\uD834[\uDD73-\uDD7A]|\uDB40[\uDC01\uDC20-\uDC7F]/;var Q=/[!-#%-\*,-\/:;\?@\[-\]_\{\}\xA1\xA7\xAB\xB6\xB7\xBB\xBF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061D-\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u09FD\u0A76\u0AF0\u0C77\u0C84\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166E\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1B7D\u1B7E\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2010-\u2027\u2030-\u2043\u2045-\u2051\u2053-\u205E\u207D\u207E\u208D\u208E\u2308-\u230B\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E4F\u2E52-\u2E5D\u3001-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65]|\uD800[\uDD00-\uDD02\uDF9F\uDFD0]|\uD801\uDD6F|\uD802[\uDC57\uDD1F\uDD3F\uDE50-\uDE58\uDE7F\uDEF0-\uDEF6\uDF39-\uDF3F\uDF99-\uDF9C]|\uD803[\uDEAD\uDF55-\uDF59\uDF86-\uDF89]|\uD804[\uDC47-\uDC4D\uDCBB\uDCBC\uDCBE-\uDCC1\uDD40-\uDD43\uDD74\uDD75\uDDC5-\uDDC8\uDDCD\uDDDB\uDDDD-\uDDDF\uDE38-\uDE3D\uDEA9]|\uD805[\uDC4B-\uDC4F\uDC5A\uDC5B\uDC5D\uDCC6\uDDC1-\uDDD7\uDE41-\uDE43\uDE60-\uDE6C\uDEB9\uDF3C-\uDF3E]|\uD806[\uDC3B\uDD44-\uDD46\uDDE2\uDE3F-\uDE46\uDE9A-\uDE9C\uDE9E-\uDEA2\uDF00-\uDF09]|\uD807[\uDC41-\uDC45\uDC70\uDC71\uDEF7\uDEF8\uDF43-\uDF4F\uDFFF]|\uD809[\uDC70-\uDC74]|\uD80B[\uDFF1\uDFF2]|\uD81A[\uDE6E\uDE6F\uDEF5\uDF37-\uDF3B\uDF44]|\uD81B[\uDE97-\uDE9A\uDFE2]|\uD82F\uDC9F|\uD836[\uDE87-\uDE8B]|\uD83A[\uDD5E\uDD5F]/;var Eu=/[\$\+<->\^`\|~\xA2-\xA6\xA8\xA9\xAC\xAE-\xB1\xB4\xB8\xD7\xF7\u02C2-\u02C5\u02D2-\u02DF\u02E5-\u02EB\u02ED\u02EF-\u02FF\u0375\u0384\u0385\u03F6\u0482\u058D-\u058F\u0606-\u0608\u060B\u060E\u060F\u06DE\u06E9\u06FD\u06FE\u07F6\u07FE\u07FF\u0888\u09F2\u09F3\u09FA\u09FB\u0AF1\u0B70\u0BF3-\u0BFA\u0C7F\u0D4F\u0D79\u0E3F\u0F01-\u0F03\u0F13\u0F15-\u0F17\u0F1A-\u0F1F\u0F34\u0F36\u0F38\u0FBE-\u0FC5\u0FC7-\u0FCC\u0FCE\u0FCF\u0FD5-\u0FD8\u109E\u109F\u1390-\u1399\u166D\u17DB\u1940\u19DE-\u19FF\u1B61-\u1B6A\u1B74-\u1B7C\u1FBD\u1FBF-\u1FC1\u1FCD-\u1FCF\u1FDD-\u1FDF\u1FED-\u1FEF\u1FFD\u1FFE\u2044\u2052\u207A-\u207C\u208A-\u208C\u20A0-\u20C0\u2100\u2101\u2103-\u2106\u2108\u2109\u2114\u2116-\u2118\u211E-\u2123\u2125\u2127\u2129\u212E\u213A\u213B\u2140-\u2144\u214A-\u214D\u214F\u218A\u218B\u2190-\u2307\u230C-\u2328\u232B-\u2426\u2440-\u244A\u249C-\u24E9\u2500-\u2767\u2794-\u27C4\u27C7-\u27E5\u27F0-\u2982\u2999-\u29D7\u29DC-\u29FB\u29FE-\u2B73\u2B76-\u2B95\u2B97-\u2BFF\u2CE5-\u2CEA\u2E50\u2E51\u2E80-\u2E99\u2E9B-\u2EF3\u2F00-\u2FD5\u2FF0-\u2FFF\u3004\u3012\u3013\u3020\u3036\u3037\u303E\u303F\u309B\u309C\u3190\u3191\u3196-\u319F\u31C0-\u31E3\u31EF\u3200-\u321E\u322A-\u3247\u3250\u3260-\u327F\u328A-\u32B0\u32C0-\u33FF\u4DC0-\u4DFF\uA490-\uA4C6\uA700-\uA716\uA720\uA721\uA789\uA78A\uA828-\uA82B\uA836-\uA839\uAA77-\uAA79\uAB5B\uAB6A\uAB6B\uFB29\uFBB2-\uFBC2\uFD40-\uFD4F\uFDCF\uFDFC-\uFDFF\uFE62\uFE64-\uFE66\uFE69\uFF04\uFF0B\uFF1C-\uFF1E\uFF3E\uFF40\uFF5C\uFF5E\uFFE0-\uFFE6\uFFE8-\uFFEE\uFFFC\uFFFD]|\uD800[\uDD37-\uDD3F\uDD79-\uDD89\uDD8C-\uDD8E\uDD90-\uDD9C\uDDA0\uDDD0-\uDDFC]|\uD802[\uDC77\uDC78\uDEC8]|\uD805\uDF3F|\uD807[\uDFD5-\uDFF1]|\uD81A[\uDF3C-\uDF3F\uDF45]|\uD82F\uDC9C|\uD833[\uDF50-\uDFC3]|\uD834[\uDC00-\uDCF5\uDD00-\uDD26\uDD29-\uDD64\uDD6A-\uDD6C\uDD83\uDD84\uDD8C-\uDDA9\uDDAE-\uDDEA\uDE00-\uDE41\uDE45\uDF00-\uDF56]|\uD835[\uDEC1\uDEDB\uDEFB\uDF15\uDF35\uDF4F\uDF6F\uDF89\uDFA9\uDFC3]|\uD836[\uDC00-\uDDFF\uDE37-\uDE3A\uDE6D-\uDE74\uDE76-\uDE83\uDE85\uDE86]|\uD838[\uDD4F\uDEFF]|\uD83B[\uDCAC\uDCB0\uDD2E\uDEF0\uDEF1]|\uD83C[\uDC00-\uDC2B\uDC30-\uDC93\uDCA0-\uDCAE\uDCB1-\uDCBF\uDCC1-\uDCCF\uDCD1-\uDCF5\uDD0D-\uDDAD\uDDE6-\uDE02\uDE10-\uDE3B\uDE40-\uDE48\uDE50\uDE51\uDE60-\uDE65\uDF00-\uDFFF]|\uD83D[\uDC00-\uDED7\uDEDC-\uDEEC\uDEF0-\uDEFC\uDF00-\uDF76\uDF7B-\uDFD9\uDFE0-\uDFEB\uDFF0]|\uD83E[\uDC00-\uDC0B\uDC10-\uDC47\uDC50-\uDC59\uDC60-\uDC87\uDC90-\uDCAD\uDCB0\uDCB1\uDD00-\uDE53\uDE60-\uDE6D\uDE70-\uDE7C\uDE80-\uDE88\uDE90-\uDEBD\uDEBF-\uDEC5\uDECE-\uDEDB\uDEE0-\uDEE8\uDEF0-\uDEF8\uDF00-\uDF92\uDF94-\uDFCA]/;var Au=/[ \xA0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]/;var y0=new Uint16Array('\u1D41<\xD5\u0131\u028A\u049D\u057B\u05D0\u0675\u06DE\u07A2\u07D6\u080F\u0A4A\u0A91\u0DA1\u0E6D\u0F09\u0F26\u10CA\u1228\u12E1\u1415\u149D\u14C3\u14DF\u1525\0\0\0\0\0\0\u156B\u16CD\u198D\u1C12\u1DDD\u1F7E\u2060\u21B0\u228D\u23C0\u23FB\u2442\u2824\u2912\u2D08\u2E48\u2FCE\u3016\u32BA\u3639\u37AC\u38FE\u3A28\u3A71\u3AE0\u3B2E\u0800EMabcfglmnoprstu\\bfms\x7F\x84\x8B\x90\x95\x98\xA6\xB3\xB9\xC8\xCFlig\u803B\xC6\u40C6P\u803B&\u4026cute\u803B\xC1\u40C1reve;\u4102\u0100iyx}rc\u803B\xC2\u40C2;\u4410r;\uC000\u{1D504}rave\u803B\xC0\u40C0pha;\u4391acr;\u4100d;\u6A53\u0100gp\x9D\xA1on;\u4104f;\uC000\u{1D538}plyFunction;\u6061ing\u803B\xC5\u40C5\u0100cs\xBE\xC3r;\uC000\u{1D49C}ign;\u6254ilde\u803B\xC3\u40C3ml\u803B\xC4\u40C4\u0400aceforsu\xE5\xFB\xFE\u0117\u011C\u0122\u0127\u012A\u0100cr\xEA\xF2kslash;\u6216\u0176\xF6\xF8;\u6AE7ed;\u6306y;\u4411\u0180crt\u0105\u010B\u0114ause;\u6235noullis;\u612Ca;\u4392r;\uC000\u{1D505}pf;\uC000\u{1D539}eve;\u42D8c\xF2\u0113mpeq;\u624E\u0700HOacdefhilorsu\u014D\u0151\u0156\u0180\u019E\u01A2\u01B5\u01B7\u01BA\u01DC\u0215\u0273\u0278\u027Ecy;\u4427PY\u803B\xA9\u40A9\u0180cpy\u015D\u0162\u017Aute;\u4106\u0100;i\u0167\u0168\u62D2talDifferentialD;\u6145leys;\u612D\u0200aeio\u0189\u018E\u0194\u0198ron;\u410Cdil\u803B\xC7\u40C7rc;\u4108nint;\u6230ot;\u410A\u0100dn\u01A7\u01ADilla;\u40B8terDot;\u40B7\xF2\u017Fi;\u43A7rcle\u0200DMPT\u01C7\u01CB\u01D1\u01D6ot;\u6299inus;\u6296lus;\u6295imes;\u6297o\u0100cs\u01E2\u01F8kwiseContourIntegral;\u6232eCurly\u0100DQ\u0203\u020FoubleQuote;\u601Duote;\u6019\u0200lnpu\u021E\u0228\u0247\u0255on\u0100;e\u0225\u0226\u6237;\u6A74\u0180git\u022F\u0236\u023Aruent;\u6261nt;\u622FourIntegral;\u622E\u0100fr\u024C\u024E;\u6102oduct;\u6210nterClockwiseContourIntegral;\u6233oss;\u6A2Fcr;\uC000\u{1D49E}p\u0100;C\u0284\u0285\u62D3ap;\u624D\u0580DJSZacefios\u02A0\u02AC\u02B0\u02B4\u02B8\u02CB\u02D7\u02E1\u02E6\u0333\u048D\u0100;o\u0179\u02A5trahd;\u6911cy;\u4402cy;\u4405cy;\u440F\u0180grs\u02BF\u02C4\u02C7ger;\u6021r;\u61A1hv;\u6AE4\u0100ay\u02D0\u02D5ron;\u410E;\u4414l\u0100;t\u02DD\u02DE\u6207a;\u4394r;\uC000\u{1D507}\u0100af\u02EB\u0327\u0100cm\u02F0\u0322ritical\u0200ADGT\u0300\u0306\u0316\u031Ccute;\u40B4o\u0174\u030B\u030D;\u42D9bleAcute;\u42DDrave;\u4060ilde;\u42DCond;\u62C4ferentialD;\u6146\u0470\u033D\0\0\0\u0342\u0354\0\u0405f;\uC000\u{1D53B}\u0180;DE\u0348\u0349\u034D\u40A8ot;\u60DCqual;\u6250ble\u0300CDLRUV\u0363\u0372\u0382\u03CF\u03E2\u03F8ontourIntegra\xEC\u0239o\u0274\u0379\0\0\u037B\xBB\u0349nArrow;\u61D3\u0100eo\u0387\u03A4ft\u0180ART\u0390\u0396\u03A1rrow;\u61D0ightArrow;\u61D4e\xE5\u02CAng\u0100LR\u03AB\u03C4eft\u0100AR\u03B3\u03B9rrow;\u67F8ightArrow;\u67FAightArrow;\u67F9ight\u0100AT\u03D8\u03DErrow;\u61D2ee;\u62A8p\u0241\u03E9\0\0\u03EFrrow;\u61D1ownArrow;\u61D5erticalBar;\u6225n\u0300ABLRTa\u0412\u042A\u0430\u045E\u047F\u037Crrow\u0180;BU\u041D\u041E\u0422\u6193ar;\u6913pArrow;\u61F5reve;\u4311eft\u02D2\u043A\0\u0446\0\u0450ightVector;\u6950eeVector;\u695Eector\u0100;B\u0459\u045A\u61BDar;\u6956ight\u01D4\u0467\0\u0471eeVector;\u695Fector\u0100;B\u047A\u047B\u61C1ar;\u6957ee\u0100;A\u0486\u0487\u62A4rrow;\u61A7\u0100ct\u0492\u0497r;\uC000\u{1D49F}rok;\u4110\u0800NTacdfglmopqstux\u04BD\u04C0\u04C4\u04CB\u04DE\u04E2\u04E7\u04EE\u04F5\u0521\u052F\u0536\u0552\u055D\u0560\u0565G;\u414AH\u803B\xD0\u40D0cute\u803B\xC9\u40C9\u0180aiy\u04D2\u04D7\u04DCron;\u411Arc\u803B\xCA\u40CA;\u442Dot;\u4116r;\uC000\u{1D508}rave\u803B\xC8\u40C8ement;\u6208\u0100ap\u04FA\u04FEcr;\u4112ty\u0253\u0506\0\0\u0512mallSquare;\u65FBerySmallSquare;\u65AB\u0100gp\u0526\u052Aon;\u4118f;\uC000\u{1D53C}silon;\u4395u\u0100ai\u053C\u0549l\u0100;T\u0542\u0543\u6A75ilde;\u6242librium;\u61CC\u0100ci\u0557\u055Ar;\u6130m;\u6A73a;\u4397ml\u803B\xCB\u40CB\u0100ip\u056A\u056Fsts;\u6203onentialE;\u6147\u0280cfios\u0585\u0588\u058D\u05B2\u05CCy;\u4424r;\uC000\u{1D509}lled\u0253\u0597\0\0\u05A3mallSquare;\u65FCerySmallSquare;\u65AA\u0370\u05BA\0\u05BF\0\0\u05C4f;\uC000\u{1D53D}All;\u6200riertrf;\u6131c\xF2\u05CB\u0600JTabcdfgorst\u05E8\u05EC\u05EF\u05FA\u0600\u0612\u0616\u061B\u061D\u0623\u066C\u0672cy;\u4403\u803B>\u403Emma\u0100;d\u05F7\u05F8\u4393;\u43DCreve;\u411E\u0180eiy\u0607\u060C\u0610dil;\u4122rc;\u411C;\u4413ot;\u4120r;\uC000\u{1D50A};\u62D9pf;\uC000\u{1D53E}eater\u0300EFGLST\u0635\u0644\u064E\u0656\u065B\u0666qual\u0100;L\u063E\u063F\u6265ess;\u62DBullEqual;\u6267reater;\u6AA2ess;\u6277lantEqual;\u6A7Eilde;\u6273cr;\uC000\u{1D4A2};\u626B\u0400Aacfiosu\u0685\u068B\u0696\u069B\u069E\u06AA\u06BE\u06CARDcy;\u442A\u0100ct\u0690\u0694ek;\u42C7;\u405Eirc;\u4124r;\u610ClbertSpace;\u610B\u01F0\u06AF\0\u06B2f;\u610DizontalLine;\u6500\u0100ct\u06C3\u06C5\xF2\u06A9rok;\u4126mp\u0144\u06D0\u06D8ownHum\xF0\u012Fqual;\u624F\u0700EJOacdfgmnostu\u06FA\u06FE\u0703\u0707\u070E\u071A\u071E\u0721\u0728\u0744\u0778\u078B\u078F\u0795cy;\u4415lig;\u4132cy;\u4401cute\u803B\xCD\u40CD\u0100iy\u0713\u0718rc\u803B\xCE\u40CE;\u4418ot;\u4130r;\u6111rave\u803B\xCC\u40CC\u0180;ap\u0720\u072F\u073F\u0100cg\u0734\u0737r;\u412AinaryI;\u6148lie\xF3\u03DD\u01F4\u0749\0\u0762\u0100;e\u074D\u074E\u622C\u0100gr\u0753\u0758ral;\u622Bsection;\u62C2isible\u0100CT\u076C\u0772omma;\u6063imes;\u6062\u0180gpt\u077F\u0783\u0788on;\u412Ef;\uC000\u{1D540}a;\u4399cr;\u6110ilde;\u4128\u01EB\u079A\0\u079Ecy;\u4406l\u803B\xCF\u40CF\u0280cfosu\u07AC\u07B7\u07BC\u07C2\u07D0\u0100iy\u07B1\u07B5rc;\u4134;\u4419r;\uC000\u{1D50D}pf;\uC000\u{1D541}\u01E3\u07C7\0\u07CCr;\uC000\u{1D4A5}rcy;\u4408kcy;\u4404\u0380HJacfos\u07E4\u07E8\u07EC\u07F1\u07FD\u0802\u0808cy;\u4425cy;\u440Cppa;\u439A\u0100ey\u07F6\u07FBdil;\u4136;\u441Ar;\uC000\u{1D50E}pf;\uC000\u{1D542}cr;\uC000\u{1D4A6}\u0580JTaceflmost\u0825\u0829\u082C\u0850\u0863\u09B3\u09B8\u09C7\u09CD\u0A37\u0A47cy;\u4409\u803B<\u403C\u0280cmnpr\u0837\u083C\u0841\u0844\u084Dute;\u4139bda;\u439Bg;\u67EAlacetrf;\u6112r;\u619E\u0180aey\u0857\u085C\u0861ron;\u413Ddil;\u413B;\u441B\u0100fs\u0868\u0970t\u0500ACDFRTUVar\u087E\u08A9\u08B1\u08E0\u08E6\u08FC\u092F\u095B\u0390\u096A\u0100nr\u0883\u088FgleBracket;\u67E8row\u0180;BR\u0899\u089A\u089E\u6190ar;\u61E4ightArrow;\u61C6eiling;\u6308o\u01F5\u08B7\0\u08C3bleBracket;\u67E6n\u01D4\u08C8\0\u08D2eeVector;\u6961ector\u0100;B\u08DB\u08DC\u61C3ar;\u6959loor;\u630Aight\u0100AV\u08EF\u08F5rrow;\u6194ector;\u694E\u0100er\u0901\u0917e\u0180;AV\u0909\u090A\u0910\u62A3rrow;\u61A4ector;\u695Aiangle\u0180;BE\u0924\u0925\u0929\u62B2ar;\u69CFqual;\u62B4p\u0180DTV\u0937\u0942\u094CownVector;\u6951eeVector;\u6960ector\u0100;B\u0956\u0957\u61BFar;\u6958ector\u0100;B\u0965\u0966\u61BCar;\u6952ight\xE1\u039Cs\u0300EFGLST\u097E\u098B\u0995\u099D\u09A2\u09ADqualGreater;\u62DAullEqual;\u6266reater;\u6276ess;\u6AA1lantEqual;\u6A7Dilde;\u6272r;\uC000\u{1D50F}\u0100;e\u09BD\u09BE\u62D8ftarrow;\u61DAidot;\u413F\u0180npw\u09D4\u0A16\u0A1Bg\u0200LRlr\u09DE\u09F7\u0A02\u0A10eft\u0100AR\u09E6\u09ECrrow;\u67F5ightArrow;\u67F7ightArrow;\u67F6eft\u0100ar\u03B3\u0A0Aight\xE1\u03BFight\xE1\u03CAf;\uC000\u{1D543}er\u0100LR\u0A22\u0A2CeftArrow;\u6199ightArrow;\u6198\u0180cht\u0A3E\u0A40\u0A42\xF2\u084C;\u61B0rok;\u4141;\u626A\u0400acefiosu\u0A5A\u0A5D\u0A60\u0A77\u0A7C\u0A85\u0A8B\u0A8Ep;\u6905y;\u441C\u0100dl\u0A65\u0A6FiumSpace;\u605Flintrf;\u6133r;\uC000\u{1D510}nusPlus;\u6213pf;\uC000\u{1D544}c\xF2\u0A76;\u439C\u0480Jacefostu\u0AA3\u0AA7\u0AAD\u0AC0\u0B14\u0B19\u0D91\u0D97\u0D9Ecy;\u440Acute;\u4143\u0180aey\u0AB4\u0AB9\u0ABEron;\u4147dil;\u4145;\u441D\u0180gsw\u0AC7\u0AF0\u0B0Eative\u0180MTV\u0AD3\u0ADF\u0AE8ediumSpace;\u600Bhi\u0100cn\u0AE6\u0AD8\xEB\u0AD9eryThi\xEE\u0AD9ted\u0100GL\u0AF8\u0B06reaterGreate\xF2\u0673essLes\xF3\u0A48Line;\u400Ar;\uC000\u{1D511}\u0200Bnpt\u0B22\u0B28\u0B37\u0B3Areak;\u6060BreakingSpace;\u40A0f;\u6115\u0680;CDEGHLNPRSTV\u0B55\u0B56\u0B6A\u0B7C\u0BA1\u0BEB\u0C04\u0C5E\u0C84\u0CA6\u0CD8\u0D61\u0D85\u6AEC\u0100ou\u0B5B\u0B64ngruent;\u6262pCap;\u626DoubleVerticalBar;\u6226\u0180lqx\u0B83\u0B8A\u0B9Bement;\u6209ual\u0100;T\u0B92\u0B93\u6260ilde;\uC000\u2242\u0338ists;\u6204reater\u0380;EFGLST\u0BB6\u0BB7\u0BBD\u0BC9\u0BD3\u0BD8\u0BE5\u626Fqual;\u6271ullEqual;\uC000\u2267\u0338reater;\uC000\u226B\u0338ess;\u6279lantEqual;\uC000\u2A7E\u0338ilde;\u6275ump\u0144\u0BF2\u0BFDownHump;\uC000\u224E\u0338qual;\uC000\u224F\u0338e\u0100fs\u0C0A\u0C27tTriangle\u0180;BE\u0C1A\u0C1B\u0C21\u62EAar;\uC000\u29CF\u0338qual;\u62ECs\u0300;EGLST\u0C35\u0C36\u0C3C\u0C44\u0C4B\u0C58\u626Equal;\u6270reater;\u6278ess;\uC000\u226A\u0338lantEqual;\uC000\u2A7D\u0338ilde;\u6274ested\u0100GL\u0C68\u0C79reaterGreater;\uC000\u2AA2\u0338essLess;\uC000\u2AA1\u0338recedes\u0180;ES\u0C92\u0C93\u0C9B\u6280qual;\uC000\u2AAF\u0338lantEqual;\u62E0\u0100ei\u0CAB\u0CB9verseElement;\u620CghtTriangle\u0180;BE\u0CCB\u0CCC\u0CD2\u62EBar;\uC000\u29D0\u0338qual;\u62ED\u0100qu\u0CDD\u0D0CuareSu\u0100bp\u0CE8\u0CF9set\u0100;E\u0CF0\u0CF3\uC000\u228F\u0338qual;\u62E2erset\u0100;E\u0D03\u0D06\uC000\u2290\u0338qual;\u62E3\u0180bcp\u0D13\u0D24\u0D4Eset\u0100;E\u0D1B\u0D1E\uC000\u2282\u20D2qual;\u6288ceeds\u0200;EST\u0D32\u0D33\u0D3B\u0D46\u6281qual;\uC000\u2AB0\u0338lantEqual;\u62E1ilde;\uC000\u227F\u0338erset\u0100;E\u0D58\u0D5B\uC000\u2283\u20D2qual;\u6289ilde\u0200;EFT\u0D6E\u0D6F\u0D75\u0D7F\u6241qual;\u6244ullEqual;\u6247ilde;\u6249erticalBar;\u6224cr;\uC000\u{1D4A9}ilde\u803B\xD1\u40D1;\u439D\u0700Eacdfgmoprstuv\u0DBD\u0DC2\u0DC9\u0DD5\u0DDB\u0DE0\u0DE7\u0DFC\u0E02\u0E20\u0E22\u0E32\u0E3F\u0E44lig;\u4152cute\u803B\xD3\u40D3\u0100iy\u0DCE\u0DD3rc\u803B\xD4\u40D4;\u441Eblac;\u4150r;\uC000\u{1D512}rave\u803B\xD2\u40D2\u0180aei\u0DEE\u0DF2\u0DF6cr;\u414Cga;\u43A9cron;\u439Fpf;\uC000\u{1D546}enCurly\u0100DQ\u0E0E\u0E1AoubleQuote;\u601Cuote;\u6018;\u6A54\u0100cl\u0E27\u0E2Cr;\uC000\u{1D4AA}ash\u803B\xD8\u40D8i\u016C\u0E37\u0E3Cde\u803B\xD5\u40D5es;\u6A37ml\u803B\xD6\u40D6er\u0100BP\u0E4B\u0E60\u0100ar\u0E50\u0E53r;\u603Eac\u0100ek\u0E5A\u0E5C;\u63DEet;\u63B4arenthesis;\u63DC\u0480acfhilors\u0E7F\u0E87\u0E8A\u0E8F\u0E92\u0E94\u0E9D\u0EB0\u0EFCrtialD;\u6202y;\u441Fr;\uC000\u{1D513}i;\u43A6;\u43A0usMinus;\u40B1\u0100ip\u0EA2\u0EADncareplan\xE5\u069Df;\u6119\u0200;eio\u0EB9\u0EBA\u0EE0\u0EE4\u6ABBcedes\u0200;EST\u0EC8\u0EC9\u0ECF\u0EDA\u627Aqual;\u6AAFlantEqual;\u627Cilde;\u627Eme;\u6033\u0100dp\u0EE9\u0EEEuct;\u620Fortion\u0100;a\u0225\u0EF9l;\u621D\u0100ci\u0F01\u0F06r;\uC000\u{1D4AB};\u43A8\u0200Ufos\u0F11\u0F16\u0F1B\u0F1FOT\u803B"\u4022r;\uC000\u{1D514}pf;\u611Acr;\uC000\u{1D4AC}\u0600BEacefhiorsu\u0F3E\u0F43\u0F47\u0F60\u0F73\u0FA7\u0FAA\u0FAD\u1096\u10A9\u10B4\u10BEarr;\u6910G\u803B\xAE\u40AE\u0180cnr\u0F4E\u0F53\u0F56ute;\u4154g;\u67EBr\u0100;t\u0F5C\u0F5D\u61A0l;\u6916\u0180aey\u0F67\u0F6C\u0F71ron;\u4158dil;\u4156;\u4420\u0100;v\u0F78\u0F79\u611Cerse\u0100EU\u0F82\u0F99\u0100lq\u0F87\u0F8Eement;\u620Builibrium;\u61CBpEquilibrium;\u696Fr\xBB\u0F79o;\u43A1ght\u0400ACDFTUVa\u0FC1\u0FEB\u0FF3\u1022\u1028\u105B\u1087\u03D8\u0100nr\u0FC6\u0FD2gleBracket;\u67E9row\u0180;BL\u0FDC\u0FDD\u0FE1\u6192ar;\u61E5eftArrow;\u61C4eiling;\u6309o\u01F5\u0FF9\0\u1005bleBracket;\u67E7n\u01D4\u100A\0\u1014eeVector;\u695Dector\u0100;B\u101D\u101E\u61C2ar;\u6955loor;\u630B\u0100er\u102D\u1043e\u0180;AV\u1035\u1036\u103C\u62A2rrow;\u61A6ector;\u695Biangle\u0180;BE\u1050\u1051\u1055\u62B3ar;\u69D0qual;\u62B5p\u0180DTV\u1063\u106E\u1078ownVector;\u694FeeVector;\u695Cector\u0100;B\u1082\u1083\u61BEar;\u6954ector\u0100;B\u1091\u1092\u61C0ar;\u6953\u0100pu\u109B\u109Ef;\u611DndImplies;\u6970ightarrow;\u61DB\u0100ch\u10B9\u10BCr;\u611B;\u61B1leDelayed;\u69F4\u0680HOacfhimoqstu\u10E4\u10F1\u10F7\u10FD\u1119\u111E\u1151\u1156\u1161\u1167\u11B5\u11BB\u11BF\u0100Cc\u10E9\u10EEHcy;\u4429y;\u4428FTcy;\u442Ccute;\u415A\u0280;aeiy\u1108\u1109\u110E\u1113\u1117\u6ABCron;\u4160dil;\u415Erc;\u415C;\u4421r;\uC000\u{1D516}ort\u0200DLRU\u112A\u1134\u113E\u1149ownArrow\xBB\u041EeftArrow\xBB\u089AightArrow\xBB\u0FDDpArrow;\u6191gma;\u43A3allCircle;\u6218pf;\uC000\u{1D54A}\u0272\u116D\0\0\u1170t;\u621Aare\u0200;ISU\u117B\u117C\u1189\u11AF\u65A1ntersection;\u6293u\u0100bp\u118F\u119Eset\u0100;E\u1197\u1198\u628Fqual;\u6291erset\u0100;E\u11A8\u11A9\u6290qual;\u6292nion;\u6294cr;\uC000\u{1D4AE}ar;\u62C6\u0200bcmp\u11C8\u11DB\u1209\u120B\u0100;s\u11CD\u11CE\u62D0et\u0100;E\u11CD\u11D5qual;\u6286\u0100ch\u11E0\u1205eeds\u0200;EST\u11ED\u11EE\u11F4\u11FF\u627Bqual;\u6AB0lantEqual;\u627Dilde;\u627FTh\xE1\u0F8C;\u6211\u0180;es\u1212\u1213\u1223\u62D1rset\u0100;E\u121C\u121D\u6283qual;\u6287et\xBB\u1213\u0580HRSacfhiors\u123E\u1244\u1249\u1255\u125E\u1271\u1276\u129F\u12C2\u12C8\u12D1ORN\u803B\xDE\u40DEADE;\u6122\u0100Hc\u124E\u1252cy;\u440By;\u4426\u0100bu\u125A\u125C;\u4009;\u43A4\u0180aey\u1265\u126A\u126Fron;\u4164dil;\u4162;\u4422r;\uC000\u{1D517}\u0100ei\u127B\u1289\u01F2\u1280\0\u1287efore;\u6234a;\u4398\u0100cn\u128E\u1298kSpace;\uC000\u205F\u200ASpace;\u6009lde\u0200;EFT\u12AB\u12AC\u12B2\u12BC\u623Cqual;\u6243ullEqual;\u6245ilde;\u6248pf;\uC000\u{1D54B}ipleDot;\u60DB\u0100ct\u12D6\u12DBr;\uC000\u{1D4AF}rok;\u4166\u0AE1\u12F7\u130E\u131A\u1326\0\u132C\u1331\0\0\0\0\0\u1338\u133D\u1377\u1385\0\u13FF\u1404\u140A\u1410\u0100cr\u12FB\u1301ute\u803B\xDA\u40DAr\u0100;o\u1307\u1308\u619Fcir;\u6949r\u01E3\u1313\0\u1316y;\u440Eve;\u416C\u0100iy\u131E\u1323rc\u803B\xDB\u40DB;\u4423blac;\u4170r;\uC000\u{1D518}rave\u803B\xD9\u40D9acr;\u416A\u0100di\u1341\u1369er\u0100BP\u1348\u135D\u0100ar\u134D\u1350r;\u405Fac\u0100ek\u1357\u1359;\u63DFet;\u63B5arenthesis;\u63DDon\u0100;P\u1370\u1371\u62C3lus;\u628E\u0100gp\u137B\u137Fon;\u4172f;\uC000\u{1D54C}\u0400ADETadps\u1395\u13AE\u13B8\u13C4\u03E8\u13D2\u13D7\u13F3rrow\u0180;BD\u1150\u13A0\u13A4ar;\u6912ownArrow;\u61C5ownArrow;\u6195quilibrium;\u696Eee\u0100;A\u13CB\u13CC\u62A5rrow;\u61A5own\xE1\u03F3er\u0100LR\u13DE\u13E8eftArrow;\u6196ightArrow;\u6197i\u0100;l\u13F9\u13FA\u43D2on;\u43A5ing;\u416Ecr;\uC000\u{1D4B0}ilde;\u4168ml\u803B\xDC\u40DC\u0480Dbcdefosv\u1427\u142C\u1430\u1433\u143E\u1485\u148A\u1490\u1496ash;\u62ABar;\u6AEBy;\u4412ash\u0100;l\u143B\u143C\u62A9;\u6AE6\u0100er\u1443\u1445;\u62C1\u0180bty\u144C\u1450\u147Aar;\u6016\u0100;i\u144F\u1455cal\u0200BLST\u1461\u1465\u146A\u1474ar;\u6223ine;\u407Ceparator;\u6758ilde;\u6240ThinSpace;\u600Ar;\uC000\u{1D519}pf;\uC000\u{1D54D}cr;\uC000\u{1D4B1}dash;\u62AA\u0280cefos\u14A7\u14AC\u14B1\u14B6\u14BCirc;\u4174dge;\u62C0r;\uC000\u{1D51A}pf;\uC000\u{1D54E}cr;\uC000\u{1D4B2}\u0200fios\u14CB\u14D0\u14D2\u14D8r;\uC000\u{1D51B};\u439Epf;\uC000\u{1D54F}cr;\uC000\u{1D4B3}\u0480AIUacfosu\u14F1\u14F5\u14F9\u14FD\u1504\u150F\u1514\u151A\u1520cy;\u442Fcy;\u4407cy;\u442Ecute\u803B\xDD\u40DD\u0100iy\u1509\u150Drc;\u4176;\u442Br;\uC000\u{1D51C}pf;\uC000\u{1D550}cr;\uC000\u{1D4B4}ml;\u4178\u0400Hacdefos\u1535\u1539\u153F\u154B\u154F\u155D\u1560\u1564cy;\u4416cute;\u4179\u0100ay\u1544\u1549ron;\u417D;\u4417ot;\u417B\u01F2\u1554\0\u155BoWidt\xE8\u0AD9a;\u4396r;\u6128pf;\u6124cr;\uC000\u{1D4B5}\u0BE1\u1583\u158A\u1590\0\u15B0\u15B6\u15BF\0\0\0\0\u15C6\u15DB\u15EB\u165F\u166D\0\u1695\u169B\u16B2\u16B9\0\u16BEcute\u803B\xE1\u40E1reve;\u4103\u0300;Ediuy\u159C\u159D\u15A1\u15A3\u15A8\u15AD\u623E;\uC000\u223E\u0333;\u623Frc\u803B\xE2\u40E2te\u80BB\xB4\u0306;\u4430lig\u803B\xE6\u40E6\u0100;r\xB2\u15BA;\uC000\u{1D51E}rave\u803B\xE0\u40E0\u0100ep\u15CA\u15D6\u0100fp\u15CF\u15D4sym;\u6135\xE8\u15D3ha;\u43B1\u0100ap\u15DFc\u0100cl\u15E4\u15E7r;\u4101g;\u6A3F\u0264\u15F0\0\0\u160A\u0280;adsv\u15FA\u15FB\u15FF\u1601\u1607\u6227nd;\u6A55;\u6A5Clope;\u6A58;\u6A5A\u0380;elmrsz\u1618\u1619\u161B\u161E\u163F\u164F\u1659\u6220;\u69A4e\xBB\u1619sd\u0100;a\u1625\u1626\u6221\u0461\u1630\u1632\u1634\u1636\u1638\u163A\u163C\u163E;\u69A8;\u69A9;\u69AA;\u69AB;\u69AC;\u69AD;\u69AE;\u69AFt\u0100;v\u1645\u1646\u621Fb\u0100;d\u164C\u164D\u62BE;\u699D\u0100pt\u1654\u1657h;\u6222\xBB\xB9arr;\u637C\u0100gp\u1663\u1667on;\u4105f;\uC000\u{1D552}\u0380;Eaeiop\u12C1\u167B\u167D\u1682\u1684\u1687\u168A;\u6A70cir;\u6A6F;\u624Ad;\u624Bs;\u4027rox\u0100;e\u12C1\u1692\xF1\u1683ing\u803B\xE5\u40E5\u0180cty\u16A1\u16A6\u16A8r;\uC000\u{1D4B6};\u402Amp\u0100;e\u12C1\u16AF\xF1\u0288ilde\u803B\xE3\u40E3ml\u803B\xE4\u40E4\u0100ci\u16C2\u16C8onin\xF4\u0272nt;\u6A11\u0800Nabcdefiklnoprsu\u16ED\u16F1\u1730\u173C\u1743\u1748\u1778\u177D\u17E0\u17E6\u1839\u1850\u170D\u193D\u1948\u1970ot;\u6AED\u0100cr\u16F6\u171Ek\u0200ceps\u1700\u1705\u170D\u1713ong;\u624Cpsilon;\u43F6rime;\u6035im\u0100;e\u171A\u171B\u623Dq;\u62CD\u0176\u1722\u1726ee;\u62BDed\u0100;g\u172C\u172D\u6305e\xBB\u172Drk\u0100;t\u135C\u1737brk;\u63B6\u0100oy\u1701\u1741;\u4431quo;\u601E\u0280cmprt\u1753\u175B\u1761\u1764\u1768aus\u0100;e\u010A\u0109ptyv;\u69B0s\xE9\u170Cno\xF5\u0113\u0180ahw\u176F\u1771\u1773;\u43B2;\u6136een;\u626Cr;\uC000\u{1D51F}g\u0380costuvw\u178D\u179D\u17B3\u17C1\u17D5\u17DB\u17DE\u0180aiu\u1794\u1796\u179A\xF0\u0760rc;\u65EFp\xBB\u1371\u0180dpt\u17A4\u17A8\u17ADot;\u6A00lus;\u6A01imes;\u6A02\u0271\u17B9\0\0\u17BEcup;\u6A06ar;\u6605riangle\u0100du\u17CD\u17D2own;\u65BDp;\u65B3plus;\u6A04e\xE5\u1444\xE5\u14ADarow;\u690D\u0180ako\u17ED\u1826\u1835\u0100cn\u17F2\u1823k\u0180lst\u17FA\u05AB\u1802ozenge;\u69EBriangle\u0200;dlr\u1812\u1813\u1818\u181D\u65B4own;\u65BEeft;\u65C2ight;\u65B8k;\u6423\u01B1\u182B\0\u1833\u01B2\u182F\0\u1831;\u6592;\u65914;\u6593ck;\u6588\u0100eo\u183E\u184D\u0100;q\u1843\u1846\uC000=\u20E5uiv;\uC000\u2261\u20E5t;\u6310\u0200ptwx\u1859\u185E\u1867\u186Cf;\uC000\u{1D553}\u0100;t\u13CB\u1863om\xBB\u13CCtie;\u62C8\u0600DHUVbdhmptuv\u1885\u1896\u18AA\u18BB\u18D7\u18DB\u18EC\u18FF\u1905\u190A\u1910\u1921\u0200LRlr\u188E\u1890\u1892\u1894;\u6557;\u6554;\u6556;\u6553\u0280;DUdu\u18A1\u18A2\u18A4\u18A6\u18A8\u6550;\u6566;\u6569;\u6564;\u6567\u0200LRlr\u18B3\u18B5\u18B7\u18B9;\u655D;\u655A;\u655C;\u6559\u0380;HLRhlr\u18CA\u18CB\u18CD\u18CF\u18D1\u18D3\u18D5\u6551;\u656C;\u6563;\u6560;\u656B;\u6562;\u655Fox;\u69C9\u0200LRlr\u18E4\u18E6\u18E8\u18EA;\u6555;\u6552;\u6510;\u650C\u0280;DUdu\u06BD\u18F7\u18F9\u18FB\u18FD;\u6565;\u6568;\u652C;\u6534inus;\u629Flus;\u629Eimes;\u62A0\u0200LRlr\u1919\u191B\u191D\u191F;\u655B;\u6558;\u6518;\u6514\u0380;HLRhlr\u1930\u1931\u1933\u1935\u1937\u1939\u193B\u6502;\u656A;\u6561;\u655E;\u653C;\u6524;\u651C\u0100ev\u0123\u1942bar\u803B\xA6\u40A6\u0200ceio\u1951\u1956\u195A\u1960r;\uC000\u{1D4B7}mi;\u604Fm\u0100;e\u171A\u171Cl\u0180;bh\u1968\u1969\u196B\u405C;\u69C5sub;\u67C8\u016C\u1974\u197El\u0100;e\u1979\u197A\u6022t\xBB\u197Ap\u0180;Ee\u012F\u1985\u1987;\u6AAE\u0100;q\u06DC\u06DB\u0CE1\u19A7\0\u19E8\u1A11\u1A15\u1A32\0\u1A37\u1A50\0\0\u1AB4\0\0\u1AC1\0\0\u1B21\u1B2E\u1B4D\u1B52\0\u1BFD\0\u1C0C\u0180cpr\u19AD\u19B2\u19DDute;\u4107\u0300;abcds\u19BF\u19C0\u19C4\u19CA\u19D5\u19D9\u6229nd;\u6A44rcup;\u6A49\u0100au\u19CF\u19D2p;\u6A4Bp;\u6A47ot;\u6A40;\uC000\u2229\uFE00\u0100eo\u19E2\u19E5t;\u6041\xEE\u0693\u0200aeiu\u19F0\u19FB\u1A01\u1A05\u01F0\u19F5\0\u19F8s;\u6A4Don;\u410Ddil\u803B\xE7\u40E7rc;\u4109ps\u0100;s\u1A0C\u1A0D\u6A4Cm;\u6A50ot;\u410B\u0180dmn\u1A1B\u1A20\u1A26il\u80BB\xB8\u01ADptyv;\u69B2t\u8100\xA2;e\u1A2D\u1A2E\u40A2r\xE4\u01B2r;\uC000\u{1D520}\u0180cei\u1A3D\u1A40\u1A4Dy;\u4447ck\u0100;m\u1A47\u1A48\u6713ark\xBB\u1A48;\u43C7r\u0380;Ecefms\u1A5F\u1A60\u1A62\u1A6B\u1AA4\u1AAA\u1AAE\u65CB;\u69C3\u0180;el\u1A69\u1A6A\u1A6D\u42C6q;\u6257e\u0261\u1A74\0\0\u1A88rrow\u0100lr\u1A7C\u1A81eft;\u61BAight;\u61BB\u0280RSacd\u1A92\u1A94\u1A96\u1A9A\u1A9F\xBB\u0F47;\u64C8st;\u629Birc;\u629Aash;\u629Dnint;\u6A10id;\u6AEFcir;\u69C2ubs\u0100;u\u1ABB\u1ABC\u6663it\xBB\u1ABC\u02EC\u1AC7\u1AD4\u1AFA\0\u1B0Aon\u0100;e\u1ACD\u1ACE\u403A\u0100;q\xC7\xC6\u026D\u1AD9\0\0\u1AE2a\u0100;t\u1ADE\u1ADF\u402C;\u4040\u0180;fl\u1AE8\u1AE9\u1AEB\u6201\xEE\u1160e\u0100mx\u1AF1\u1AF6ent\xBB\u1AE9e\xF3\u024D\u01E7\u1AFE\0\u1B07\u0100;d\u12BB\u1B02ot;\u6A6Dn\xF4\u0246\u0180fry\u1B10\u1B14\u1B17;\uC000\u{1D554}o\xE4\u0254\u8100\xA9;s\u0155\u1B1Dr;\u6117\u0100ao\u1B25\u1B29rr;\u61B5ss;\u6717\u0100cu\u1B32\u1B37r;\uC000\u{1D4B8}\u0100bp\u1B3C\u1B44\u0100;e\u1B41\u1B42\u6ACF;\u6AD1\u0100;e\u1B49\u1B4A\u6AD0;\u6AD2dot;\u62EF\u0380delprvw\u1B60\u1B6C\u1B77\u1B82\u1BAC\u1BD4\u1BF9arr\u0100lr\u1B68\u1B6A;\u6938;\u6935\u0270\u1B72\0\0\u1B75r;\u62DEc;\u62DFarr\u0100;p\u1B7F\u1B80\u61B6;\u693D\u0300;bcdos\u1B8F\u1B90\u1B96\u1BA1\u1BA5\u1BA8\u622Arcap;\u6A48\u0100au\u1B9B\u1B9Ep;\u6A46p;\u6A4Aot;\u628Dr;\u6A45;\uC000\u222A\uFE00\u0200alrv\u1BB5\u1BBF\u1BDE\u1BE3rr\u0100;m\u1BBC\u1BBD\u61B7;\u693Cy\u0180evw\u1BC7\u1BD4\u1BD8q\u0270\u1BCE\0\0\u1BD2re\xE3\u1B73u\xE3\u1B75ee;\u62CEedge;\u62CFen\u803B\xA4\u40A4earrow\u0100lr\u1BEE\u1BF3eft\xBB\u1B80ight\xBB\u1BBDe\xE4\u1BDD\u0100ci\u1C01\u1C07onin\xF4\u01F7nt;\u6231lcty;\u632D\u0980AHabcdefhijlorstuwz\u1C38\u1C3B\u1C3F\u1C5D\u1C69\u1C75\u1C8A\u1C9E\u1CAC\u1CB7\u1CFB\u1CFF\u1D0D\u1D7B\u1D91\u1DAB\u1DBB\u1DC6\u1DCDr\xF2\u0381ar;\u6965\u0200glrs\u1C48\u1C4D\u1C52\u1C54ger;\u6020eth;\u6138\xF2\u1133h\u0100;v\u1C5A\u1C5B\u6010\xBB\u090A\u016B\u1C61\u1C67arow;\u690Fa\xE3\u0315\u0100ay\u1C6E\u1C73ron;\u410F;\u4434\u0180;ao\u0332\u1C7C\u1C84\u0100gr\u02BF\u1C81r;\u61CAtseq;\u6A77\u0180glm\u1C91\u1C94\u1C98\u803B\xB0\u40B0ta;\u43B4ptyv;\u69B1\u0100ir\u1CA3\u1CA8sht;\u697F;\uC000\u{1D521}ar\u0100lr\u1CB3\u1CB5\xBB\u08DC\xBB\u101E\u0280aegsv\u1CC2\u0378\u1CD6\u1CDC\u1CE0m\u0180;os\u0326\u1CCA\u1CD4nd\u0100;s\u0326\u1CD1uit;\u6666amma;\u43DDin;\u62F2\u0180;io\u1CE7\u1CE8\u1CF8\u40F7de\u8100\xF7;o\u1CE7\u1CF0ntimes;\u62C7n\xF8\u1CF7cy;\u4452c\u026F\u1D06\0\0\u1D0Arn;\u631Eop;\u630D\u0280lptuw\u1D18\u1D1D\u1D22\u1D49\u1D55lar;\u4024f;\uC000\u{1D555}\u0280;emps\u030B\u1D2D\u1D37\u1D3D\u1D42q\u0100;d\u0352\u1D33ot;\u6251inus;\u6238lus;\u6214quare;\u62A1blebarwedg\xE5\xFAn\u0180adh\u112E\u1D5D\u1D67ownarrow\xF3\u1C83arpoon\u0100lr\u1D72\u1D76ef\xF4\u1CB4igh\xF4\u1CB6\u0162\u1D7F\u1D85karo\xF7\u0F42\u026F\u1D8A\0\0\u1D8Ern;\u631Fop;\u630C\u0180cot\u1D98\u1DA3\u1DA6\u0100ry\u1D9D\u1DA1;\uC000\u{1D4B9};\u4455l;\u69F6rok;\u4111\u0100dr\u1DB0\u1DB4ot;\u62F1i\u0100;f\u1DBA\u1816\u65BF\u0100ah\u1DC0\u1DC3r\xF2\u0429a\xF2\u0FA6angle;\u69A6\u0100ci\u1DD2\u1DD5y;\u445Fgrarr;\u67FF\u0900Dacdefglmnopqrstux\u1E01\u1E09\u1E19\u1E38\u0578\u1E3C\u1E49\u1E61\u1E7E\u1EA5\u1EAF\u1EBD\u1EE1\u1F2A\u1F37\u1F44\u1F4E\u1F5A\u0100Do\u1E06\u1D34o\xF4\u1C89\u0100cs\u1E0E\u1E14ute\u803B\xE9\u40E9ter;\u6A6E\u0200aioy\u1E22\u1E27\u1E31\u1E36ron;\u411Br\u0100;c\u1E2D\u1E2E\u6256\u803B\xEA\u40EAlon;\u6255;\u444Dot;\u4117\u0100Dr\u1E41\u1E45ot;\u6252;\uC000\u{1D522}\u0180;rs\u1E50\u1E51\u1E57\u6A9Aave\u803B\xE8\u40E8\u0100;d\u1E5C\u1E5D\u6A96ot;\u6A98\u0200;ils\u1E6A\u1E6B\u1E72\u1E74\u6A99nters;\u63E7;\u6113\u0100;d\u1E79\u1E7A\u6A95ot;\u6A97\u0180aps\u1E85\u1E89\u1E97cr;\u4113ty\u0180;sv\u1E92\u1E93\u1E95\u6205et\xBB\u1E93p\u01001;\u1E9D\u1EA4\u0133\u1EA1\u1EA3;\u6004;\u6005\u6003\u0100gs\u1EAA\u1EAC;\u414Bp;\u6002\u0100gp\u1EB4\u1EB8on;\u4119f;\uC000\u{1D556}\u0180als\u1EC4\u1ECE\u1ED2r\u0100;s\u1ECA\u1ECB\u62D5l;\u69E3us;\u6A71i\u0180;lv\u1EDA\u1EDB\u1EDF\u43B5on\xBB\u1EDB;\u43F5\u0200csuv\u1EEA\u1EF3\u1F0B\u1F23\u0100io\u1EEF\u1E31rc\xBB\u1E2E\u0269\u1EF9\0\0\u1EFB\xED\u0548ant\u0100gl\u1F02\u1F06tr\xBB\u1E5Dess\xBB\u1E7A\u0180aei\u1F12\u1F16\u1F1Als;\u403Dst;\u625Fv\u0100;D\u0235\u1F20D;\u6A78parsl;\u69E5\u0100Da\u1F2F\u1F33ot;\u6253rr;\u6971\u0180cdi\u1F3E\u1F41\u1EF8r;\u612Fo\xF4\u0352\u0100ah\u1F49\u1F4B;\u43B7\u803B\xF0\u40F0\u0100mr\u1F53\u1F57l\u803B\xEB\u40EBo;\u60AC\u0180cip\u1F61\u1F64\u1F67l;\u4021s\xF4\u056E\u0100eo\u1F6C\u1F74ctatio\xEE\u0559nential\xE5\u0579\u09E1\u1F92\0\u1F9E\0\u1FA1\u1FA7\0\0\u1FC6\u1FCC\0\u1FD3\0\u1FE6\u1FEA\u2000\0\u2008\u205Allingdotse\xF1\u1E44y;\u4444male;\u6640\u0180ilr\u1FAD\u1FB3\u1FC1lig;\u8000\uFB03\u0269\u1FB9\0\0\u1FBDg;\u8000\uFB00ig;\u8000\uFB04;\uC000\u{1D523}lig;\u8000\uFB01lig;\uC000fj\u0180alt\u1FD9\u1FDC\u1FE1t;\u666Dig;\u8000\uFB02ns;\u65B1of;\u4192\u01F0\u1FEE\0\u1FF3f;\uC000\u{1D557}\u0100ak\u05BF\u1FF7\u0100;v\u1FFC\u1FFD\u62D4;\u6AD9artint;\u6A0D\u0100ao\u200C\u2055\u0100cs\u2011\u2052\u03B1\u201A\u2030\u2038\u2045\u2048\0\u2050\u03B2\u2022\u2025\u2027\u202A\u202C\0\u202E\u803B\xBD\u40BD;\u6153\u803B\xBC\u40BC;\u6155;\u6159;\u615B\u01B3\u2034\0\u2036;\u6154;\u6156\u02B4\u203E\u2041\0\0\u2043\u803B\xBE\u40BE;\u6157;\u615C5;\u6158\u01B6\u204C\0\u204E;\u615A;\u615D8;\u615El;\u6044wn;\u6322cr;\uC000\u{1D4BB}\u0880Eabcdefgijlnorstv\u2082\u2089\u209F\u20A5\u20B0\u20B4\u20F0\u20F5\u20FA\u20FF\u2103\u2112\u2138\u0317\u213E\u2152\u219E\u0100;l\u064D\u2087;\u6A8C\u0180cmp\u2090\u2095\u209Dute;\u41F5ma\u0100;d\u209C\u1CDA\u43B3;\u6A86reve;\u411F\u0100iy\u20AA\u20AErc;\u411D;\u4433ot;\u4121\u0200;lqs\u063E\u0642\u20BD\u20C9\u0180;qs\u063E\u064C\u20C4lan\xF4\u0665\u0200;cdl\u0665\u20D2\u20D5\u20E5c;\u6AA9ot\u0100;o\u20DC\u20DD\u6A80\u0100;l\u20E2\u20E3\u6A82;\u6A84\u0100;e\u20EA\u20ED\uC000\u22DB\uFE00s;\u6A94r;\uC000\u{1D524}\u0100;g\u0673\u061Bmel;\u6137cy;\u4453\u0200;Eaj\u065A\u210C\u210E\u2110;\u6A92;\u6AA5;\u6AA4\u0200Eaes\u211B\u211D\u2129\u2134;\u6269p\u0100;p\u2123\u2124\u6A8Arox\xBB\u2124\u0100;q\u212E\u212F\u6A88\u0100;q\u212E\u211Bim;\u62E7pf;\uC000\u{1D558}\u0100ci\u2143\u2146r;\u610Am\u0180;el\u066B\u214E\u2150;\u6A8E;\u6A90\u8300>;cdlqr\u05EE\u2160\u216A\u216E\u2173\u2179\u0100ci\u2165\u2167;\u6AA7r;\u6A7Aot;\u62D7Par;\u6995uest;\u6A7C\u0280adels\u2184\u216A\u2190\u0656\u219B\u01F0\u2189\0\u218Epro\xF8\u209Er;\u6978q\u0100lq\u063F\u2196les\xF3\u2088i\xED\u066B\u0100en\u21A3\u21ADrtneqq;\uC000\u2269\uFE00\xC5\u21AA\u0500Aabcefkosy\u21C4\u21C7\u21F1\u21F5\u21FA\u2218\u221D\u222F\u2268\u227Dr\xF2\u03A0\u0200ilmr\u21D0\u21D4\u21D7\u21DBrs\xF0\u1484f\xBB\u2024il\xF4\u06A9\u0100dr\u21E0\u21E4cy;\u444A\u0180;cw\u08F4\u21EB\u21EFir;\u6948;\u61ADar;\u610Firc;\u4125\u0180alr\u2201\u220E\u2213rts\u0100;u\u2209\u220A\u6665it\xBB\u220Alip;\u6026con;\u62B9r;\uC000\u{1D525}s\u0100ew\u2223\u2229arow;\u6925arow;\u6926\u0280amopr\u223A\u223E\u2243\u225E\u2263rr;\u61FFtht;\u623Bk\u0100lr\u2249\u2253eftarrow;\u61A9ightarrow;\u61AAf;\uC000\u{1D559}bar;\u6015\u0180clt\u226F\u2274\u2278r;\uC000\u{1D4BD}as\xE8\u21F4rok;\u4127\u0100bp\u2282\u2287ull;\u6043hen\xBB\u1C5B\u0AE1\u22A3\0\u22AA\0\u22B8\u22C5\u22CE\0\u22D5\u22F3\0\0\u22F8\u2322\u2367\u2362\u237F\0\u2386\u23AA\u23B4cute\u803B\xED\u40ED\u0180;iy\u0771\u22B0\u22B5rc\u803B\xEE\u40EE;\u4438\u0100cx\u22BC\u22BFy;\u4435cl\u803B\xA1\u40A1\u0100fr\u039F\u22C9;\uC000\u{1D526}rave\u803B\xEC\u40EC\u0200;ino\u073E\u22DD\u22E9\u22EE\u0100in\u22E2\u22E6nt;\u6A0Ct;\u622Dfin;\u69DCta;\u6129lig;\u4133\u0180aop\u22FE\u231A\u231D\u0180cgt\u2305\u2308\u2317r;\u412B\u0180elp\u071F\u230F\u2313in\xE5\u078Ear\xF4\u0720h;\u4131f;\u62B7ed;\u41B5\u0280;cfot\u04F4\u232C\u2331\u233D\u2341are;\u6105in\u0100;t\u2338\u2339\u621Eie;\u69DDdo\xF4\u2319\u0280;celp\u0757\u234C\u2350\u235B\u2361al;\u62BA\u0100gr\u2355\u2359er\xF3\u1563\xE3\u234Darhk;\u6A17rod;\u6A3C\u0200cgpt\u236F\u2372\u2376\u237By;\u4451on;\u412Ff;\uC000\u{1D55A}a;\u43B9uest\u803B\xBF\u40BF\u0100ci\u238A\u238Fr;\uC000\u{1D4BE}n\u0280;Edsv\u04F4\u239B\u239D\u23A1\u04F3;\u62F9ot;\u62F5\u0100;v\u23A6\u23A7\u62F4;\u62F3\u0100;i\u0777\u23AElde;\u4129\u01EB\u23B8\0\u23BCcy;\u4456l\u803B\xEF\u40EF\u0300cfmosu\u23CC\u23D7\u23DC\u23E1\u23E7\u23F5\u0100iy\u23D1\u23D5rc;\u4135;\u4439r;\uC000\u{1D527}ath;\u4237pf;\uC000\u{1D55B}\u01E3\u23EC\0\u23F1r;\uC000\u{1D4BF}rcy;\u4458kcy;\u4454\u0400acfghjos\u240B\u2416\u2422\u2427\u242D\u2431\u2435\u243Bppa\u0100;v\u2413\u2414\u43BA;\u43F0\u0100ey\u241B\u2420dil;\u4137;\u443Ar;\uC000\u{1D528}reen;\u4138cy;\u4445cy;\u445Cpf;\uC000\u{1D55C}cr;\uC000\u{1D4C0}\u0B80ABEHabcdefghjlmnoprstuv\u2470\u2481\u2486\u248D\u2491\u250E\u253D\u255A\u2580\u264E\u265E\u2665\u2679\u267D\u269A\u26B2\u26D8\u275D\u2768\u278B\u27C0\u2801\u2812\u0180art\u2477\u247A\u247Cr\xF2\u09C6\xF2\u0395ail;\u691Barr;\u690E\u0100;g\u0994\u248B;\u6A8Bar;\u6962\u0963\u24A5\0\u24AA\0\u24B1\0\0\0\0\0\u24B5\u24BA\0\u24C6\u24C8\u24CD\0\u24F9ute;\u413Amptyv;\u69B4ra\xEE\u084Cbda;\u43BBg\u0180;dl\u088E\u24C1\u24C3;\u6991\xE5\u088E;\u6A85uo\u803B\xAB\u40ABr\u0400;bfhlpst\u0899\u24DE\u24E6\u24E9\u24EB\u24EE\u24F1\u24F5\u0100;f\u089D\u24E3s;\u691Fs;\u691D\xEB\u2252p;\u61ABl;\u6939im;\u6973l;\u61A2\u0180;ae\u24FF\u2500\u2504\u6AABil;\u6919\u0100;s\u2509\u250A\u6AAD;\uC000\u2AAD\uFE00\u0180abr\u2515\u2519\u251Drr;\u690Crk;\u6772\u0100ak\u2522\u252Cc\u0100ek\u2528\u252A;\u407B;\u405B\u0100es\u2531\u2533;\u698Bl\u0100du\u2539\u253B;\u698F;\u698D\u0200aeuy\u2546\u254B\u2556\u2558ron;\u413E\u0100di\u2550\u2554il;\u413C\xEC\u08B0\xE2\u2529;\u443B\u0200cqrs\u2563\u2566\u256D\u257Da;\u6936uo\u0100;r\u0E19\u1746\u0100du\u2572\u2577har;\u6967shar;\u694Bh;\u61B2\u0280;fgqs\u258B\u258C\u0989\u25F3\u25FF\u6264t\u0280ahlrt\u2598\u25A4\u25B7\u25C2\u25E8rrow\u0100;t\u0899\u25A1a\xE9\u24F6arpoon\u0100du\u25AF\u25B4own\xBB\u045Ap\xBB\u0966eftarrows;\u61C7ight\u0180ahs\u25CD\u25D6\u25DErrow\u0100;s\u08F4\u08A7arpoon\xF3\u0F98quigarro\xF7\u21F0hreetimes;\u62CB\u0180;qs\u258B\u0993\u25FAlan\xF4\u09AC\u0280;cdgs\u09AC\u260A\u260D\u261D\u2628c;\u6AA8ot\u0100;o\u2614\u2615\u6A7F\u0100;r\u261A\u261B\u6A81;\u6A83\u0100;e\u2622\u2625\uC000\u22DA\uFE00s;\u6A93\u0280adegs\u2633\u2639\u263D\u2649\u264Bppro\xF8\u24C6ot;\u62D6q\u0100gq\u2643\u2645\xF4\u0989gt\xF2\u248C\xF4\u099Bi\xED\u09B2\u0180ilr\u2655\u08E1\u265Asht;\u697C;\uC000\u{1D529}\u0100;E\u099C\u2663;\u6A91\u0161\u2669\u2676r\u0100du\u25B2\u266E\u0100;l\u0965\u2673;\u696Alk;\u6584cy;\u4459\u0280;acht\u0A48\u2688\u268B\u2691\u2696r\xF2\u25C1orne\xF2\u1D08ard;\u696Bri;\u65FA\u0100io\u269F\u26A4dot;\u4140ust\u0100;a\u26AC\u26AD\u63B0che\xBB\u26AD\u0200Eaes\u26BB\u26BD\u26C9\u26D4;\u6268p\u0100;p\u26C3\u26C4\u6A89rox\xBB\u26C4\u0100;q\u26CE\u26CF\u6A87\u0100;q\u26CE\u26BBim;\u62E6\u0400abnoptwz\u26E9\u26F4\u26F7\u271A\u272F\u2741\u2747\u2750\u0100nr\u26EE\u26F1g;\u67ECr;\u61FDr\xEB\u08C1g\u0180lmr\u26FF\u270D\u2714eft\u0100ar\u09E6\u2707ight\xE1\u09F2apsto;\u67FCight\xE1\u09FDparrow\u0100lr\u2725\u2729ef\xF4\u24EDight;\u61AC\u0180afl\u2736\u2739\u273Dr;\u6985;\uC000\u{1D55D}us;\u6A2Dimes;\u6A34\u0161\u274B\u274Fst;\u6217\xE1\u134E\u0180;ef\u2757\u2758\u1800\u65CAnge\xBB\u2758ar\u0100;l\u2764\u2765\u4028t;\u6993\u0280achmt\u2773\u2776\u277C\u2785\u2787r\xF2\u08A8orne\xF2\u1D8Car\u0100;d\u0F98\u2783;\u696D;\u600Eri;\u62BF\u0300achiqt\u2798\u279D\u0A40\u27A2\u27AE\u27BBquo;\u6039r;\uC000\u{1D4C1}m\u0180;eg\u09B2\u27AA\u27AC;\u6A8D;\u6A8F\u0100bu\u252A\u27B3o\u0100;r\u0E1F\u27B9;\u601Arok;\u4142\u8400<;cdhilqr\u082B\u27D2\u2639\u27DC\u27E0\u27E5\u27EA\u27F0\u0100ci\u27D7\u27D9;\u6AA6r;\u6A79re\xE5\u25F2mes;\u62C9arr;\u6976uest;\u6A7B\u0100Pi\u27F5\u27F9ar;\u6996\u0180;ef\u2800\u092D\u181B\u65C3r\u0100du\u2807\u280Dshar;\u694Ahar;\u6966\u0100en\u2817\u2821rtneqq;\uC000\u2268\uFE00\xC5\u281E\u0700Dacdefhilnopsu\u2840\u2845\u2882\u288E\u2893\u28A0\u28A5\u28A8\u28DA\u28E2\u28E4\u0A83\u28F3\u2902Dot;\u623A\u0200clpr\u284E\u2852\u2863\u287Dr\u803B\xAF\u40AF\u0100et\u2857\u2859;\u6642\u0100;e\u285E\u285F\u6720se\xBB\u285F\u0100;s\u103B\u2868to\u0200;dlu\u103B\u2873\u2877\u287Bow\xEE\u048Cef\xF4\u090F\xF0\u13D1ker;\u65AE\u0100oy\u2887\u288Cmma;\u6A29;\u443Cash;\u6014asuredangle\xBB\u1626r;\uC000\u{1D52A}o;\u6127\u0180cdn\u28AF\u28B4\u28C9ro\u803B\xB5\u40B5\u0200;acd\u1464\u28BD\u28C0\u28C4s\xF4\u16A7ir;\u6AF0ot\u80BB\xB7\u01B5us\u0180;bd\u28D2\u1903\u28D3\u6212\u0100;u\u1D3C\u28D8;\u6A2A\u0163\u28DE\u28E1p;\u6ADB\xF2\u2212\xF0\u0A81\u0100dp\u28E9\u28EEels;\u62A7f;\uC000\u{1D55E}\u0100ct\u28F8\u28FDr;\uC000\u{1D4C2}pos\xBB\u159D\u0180;lm\u2909\u290A\u290D\u43BCtimap;\u62B8\u0C00GLRVabcdefghijlmoprstuvw\u2942\u2953\u297E\u2989\u2998\u29DA\u29E9\u2A15\u2A1A\u2A58\u2A5D\u2A83\u2A95\u2AA4\u2AA8\u2B04\u2B07\u2B44\u2B7F\u2BAE\u2C34\u2C67\u2C7C\u2CE9\u0100gt\u2947\u294B;\uC000\u22D9\u0338\u0100;v\u2950\u0BCF\uC000\u226B\u20D2\u0180elt\u295A\u2972\u2976ft\u0100ar\u2961\u2967rrow;\u61CDightarrow;\u61CE;\uC000\u22D8\u0338\u0100;v\u297B\u0C47\uC000\u226A\u20D2ightarrow;\u61CF\u0100Dd\u298E\u2993ash;\u62AFash;\u62AE\u0280bcnpt\u29A3\u29A7\u29AC\u29B1\u29CCla\xBB\u02DEute;\u4144g;\uC000\u2220\u20D2\u0280;Eiop\u0D84\u29BC\u29C0\u29C5\u29C8;\uC000\u2A70\u0338d;\uC000\u224B\u0338s;\u4149ro\xF8\u0D84ur\u0100;a\u29D3\u29D4\u666El\u0100;s\u29D3\u0B38\u01F3\u29DF\0\u29E3p\u80BB\xA0\u0B37mp\u0100;e\u0BF9\u0C00\u0280aeouy\u29F4\u29FE\u2A03\u2A10\u2A13\u01F0\u29F9\0\u29FB;\u6A43on;\u4148dil;\u4146ng\u0100;d\u0D7E\u2A0Aot;\uC000\u2A6D\u0338p;\u6A42;\u443Dash;\u6013\u0380;Aadqsx\u0B92\u2A29\u2A2D\u2A3B\u2A41\u2A45\u2A50rr;\u61D7r\u0100hr\u2A33\u2A36k;\u6924\u0100;o\u13F2\u13F0ot;\uC000\u2250\u0338ui\xF6\u0B63\u0100ei\u2A4A\u2A4Ear;\u6928\xED\u0B98ist\u0100;s\u0BA0\u0B9Fr;\uC000\u{1D52B}\u0200Eest\u0BC5\u2A66\u2A79\u2A7C\u0180;qs\u0BBC\u2A6D\u0BE1\u0180;qs\u0BBC\u0BC5\u2A74lan\xF4\u0BE2i\xED\u0BEA\u0100;r\u0BB6\u2A81\xBB\u0BB7\u0180Aap\u2A8A\u2A8D\u2A91r\xF2\u2971rr;\u61AEar;\u6AF2\u0180;sv\u0F8D\u2A9C\u0F8C\u0100;d\u2AA1\u2AA2\u62FC;\u62FAcy;\u445A\u0380AEadest\u2AB7\u2ABA\u2ABE\u2AC2\u2AC5\u2AF6\u2AF9r\xF2\u2966;\uC000\u2266\u0338rr;\u619Ar;\u6025\u0200;fqs\u0C3B\u2ACE\u2AE3\u2AEFt\u0100ar\u2AD4\u2AD9rro\xF7\u2AC1ightarro\xF7\u2A90\u0180;qs\u0C3B\u2ABA\u2AEAlan\xF4\u0C55\u0100;s\u0C55\u2AF4\xBB\u0C36i\xED\u0C5D\u0100;r\u0C35\u2AFEi\u0100;e\u0C1A\u0C25i\xE4\u0D90\u0100pt\u2B0C\u2B11f;\uC000\u{1D55F}\u8180\xAC;in\u2B19\u2B1A\u2B36\u40ACn\u0200;Edv\u0B89\u2B24\u2B28\u2B2E;\uC000\u22F9\u0338ot;\uC000\u22F5\u0338\u01E1\u0B89\u2B33\u2B35;\u62F7;\u62F6i\u0100;v\u0CB8\u2B3C\u01E1\u0CB8\u2B41\u2B43;\u62FE;\u62FD\u0180aor\u2B4B\u2B63\u2B69r\u0200;ast\u0B7B\u2B55\u2B5A\u2B5Flle\xEC\u0B7Bl;\uC000\u2AFD\u20E5;\uC000\u2202\u0338lint;\u6A14\u0180;ce\u0C92\u2B70\u2B73u\xE5\u0CA5\u0100;c\u0C98\u2B78\u0100;e\u0C92\u2B7D\xF1\u0C98\u0200Aait\u2B88\u2B8B\u2B9D\u2BA7r\xF2\u2988rr\u0180;cw\u2B94\u2B95\u2B99\u619B;\uC000\u2933\u0338;\uC000\u219D\u0338ghtarrow\xBB\u2B95ri\u0100;e\u0CCB\u0CD6\u0380chimpqu\u2BBD\u2BCD\u2BD9\u2B04\u0B78\u2BE4\u2BEF\u0200;cer\u0D32\u2BC6\u0D37\u2BC9u\xE5\u0D45;\uC000\u{1D4C3}ort\u026D\u2B05\0\0\u2BD6ar\xE1\u2B56m\u0100;e\u0D6E\u2BDF\u0100;q\u0D74\u0D73su\u0100bp\u2BEB\u2BED\xE5\u0CF8\xE5\u0D0B\u0180bcp\u2BF6\u2C11\u2C19\u0200;Ees\u2BFF\u2C00\u0D22\u2C04\u6284;\uC000\u2AC5\u0338et\u0100;e\u0D1B\u2C0Bq\u0100;q\u0D23\u2C00c\u0100;e\u0D32\u2C17\xF1\u0D38\u0200;Ees\u2C22\u2C23\u0D5F\u2C27\u6285;\uC000\u2AC6\u0338et\u0100;e\u0D58\u2C2Eq\u0100;q\u0D60\u2C23\u0200gilr\u2C3D\u2C3F\u2C45\u2C47\xEC\u0BD7lde\u803B\xF1\u40F1\xE7\u0C43iangle\u0100lr\u2C52\u2C5Ceft\u0100;e\u0C1A\u2C5A\xF1\u0C26ight\u0100;e\u0CCB\u2C65\xF1\u0CD7\u0100;m\u2C6C\u2C6D\u43BD\u0180;es\u2C74\u2C75\u2C79\u4023ro;\u6116p;\u6007\u0480DHadgilrs\u2C8F\u2C94\u2C99\u2C9E\u2CA3\u2CB0\u2CB6\u2CD3\u2CE3ash;\u62ADarr;\u6904p;\uC000\u224D\u20D2ash;\u62AC\u0100et\u2CA8\u2CAC;\uC000\u2265\u20D2;\uC000>\u20D2nfin;\u69DE\u0180Aet\u2CBD\u2CC1\u2CC5rr;\u6902;\uC000\u2264\u20D2\u0100;r\u2CCA\u2CCD\uC000<\u20D2ie;\uC000\u22B4\u20D2\u0100At\u2CD8\u2CDCrr;\u6903rie;\uC000\u22B5\u20D2im;\uC000\u223C\u20D2\u0180Aan\u2CF0\u2CF4\u2D02rr;\u61D6r\u0100hr\u2CFA\u2CFDk;\u6923\u0100;o\u13E7\u13E5ear;\u6927\u1253\u1A95\0\0\0\0\0\0\0\0\0\0\0\0\0\u2D2D\0\u2D38\u2D48\u2D60\u2D65\u2D72\u2D84\u1B07\0\0\u2D8D\u2DAB\0\u2DC8\u2DCE\0\u2DDC\u2E19\u2E2B\u2E3E\u2E43\u0100cs\u2D31\u1A97ute\u803B\xF3\u40F3\u0100iy\u2D3C\u2D45r\u0100;c\u1A9E\u2D42\u803B\xF4\u40F4;\u443E\u0280abios\u1AA0\u2D52\u2D57\u01C8\u2D5Alac;\u4151v;\u6A38old;\u69BClig;\u4153\u0100cr\u2D69\u2D6Dir;\u69BF;\uC000\u{1D52C}\u036F\u2D79\0\0\u2D7C\0\u2D82n;\u42DBave\u803B\xF2\u40F2;\u69C1\u0100bm\u2D88\u0DF4ar;\u69B5\u0200acit\u2D95\u2D98\u2DA5\u2DA8r\xF2\u1A80\u0100ir\u2D9D\u2DA0r;\u69BEoss;\u69BBn\xE5\u0E52;\u69C0\u0180aei\u2DB1\u2DB5\u2DB9cr;\u414Dga;\u43C9\u0180cdn\u2DC0\u2DC5\u01CDron;\u43BF;\u69B6pf;\uC000\u{1D560}\u0180ael\u2DD4\u2DD7\u01D2r;\u69B7rp;\u69B9\u0380;adiosv\u2DEA\u2DEB\u2DEE\u2E08\u2E0D\u2E10\u2E16\u6228r\xF2\u1A86\u0200;efm\u2DF7\u2DF8\u2E02\u2E05\u6A5Dr\u0100;o\u2DFE\u2DFF\u6134f\xBB\u2DFF\u803B\xAA\u40AA\u803B\xBA\u40BAgof;\u62B6r;\u6A56lope;\u6A57;\u6A5B\u0180clo\u2E1F\u2E21\u2E27\xF2\u2E01ash\u803B\xF8\u40F8l;\u6298i\u016C\u2E2F\u2E34de\u803B\xF5\u40F5es\u0100;a\u01DB\u2E3As;\u6A36ml\u803B\xF6\u40F6bar;\u633D\u0AE1\u2E5E\0\u2E7D\0\u2E80\u2E9D\0\u2EA2\u2EB9\0\0\u2ECB\u0E9C\0\u2F13\0\0\u2F2B\u2FBC\0\u2FC8r\u0200;ast\u0403\u2E67\u2E72\u0E85\u8100\xB6;l\u2E6D\u2E6E\u40B6le\xEC\u0403\u0269\u2E78\0\0\u2E7Bm;\u6AF3;\u6AFDy;\u443Fr\u0280cimpt\u2E8B\u2E8F\u2E93\u1865\u2E97nt;\u4025od;\u402Eil;\u6030enk;\u6031r;\uC000\u{1D52D}\u0180imo\u2EA8\u2EB0\u2EB4\u0100;v\u2EAD\u2EAE\u43C6;\u43D5ma\xF4\u0A76ne;\u660E\u0180;tv\u2EBF\u2EC0\u2EC8\u43C0chfork\xBB\u1FFD;\u43D6\u0100au\u2ECF\u2EDFn\u0100ck\u2ED5\u2EDDk\u0100;h\u21F4\u2EDB;\u610E\xF6\u21F4s\u0480;abcdemst\u2EF3\u2EF4\u1908\u2EF9\u2EFD\u2F04\u2F06\u2F0A\u2F0E\u402Bcir;\u6A23ir;\u6A22\u0100ou\u1D40\u2F02;\u6A25;\u6A72n\u80BB\xB1\u0E9Dim;\u6A26wo;\u6A27\u0180ipu\u2F19\u2F20\u2F25ntint;\u6A15f;\uC000\u{1D561}nd\u803B\xA3\u40A3\u0500;Eaceinosu\u0EC8\u2F3F\u2F41\u2F44\u2F47\u2F81\u2F89\u2F92\u2F7E\u2FB6;\u6AB3p;\u6AB7u\xE5\u0ED9\u0100;c\u0ECE\u2F4C\u0300;acens\u0EC8\u2F59\u2F5F\u2F66\u2F68\u2F7Eppro\xF8\u2F43urlye\xF1\u0ED9\xF1\u0ECE\u0180aes\u2F6F\u2F76\u2F7Approx;\u6AB9qq;\u6AB5im;\u62E8i\xED\u0EDFme\u0100;s\u2F88\u0EAE\u6032\u0180Eas\u2F78\u2F90\u2F7A\xF0\u2F75\u0180dfp\u0EEC\u2F99\u2FAF\u0180als\u2FA0\u2FA5\u2FAAlar;\u632Eine;\u6312urf;\u6313\u0100;t\u0EFB\u2FB4\xEF\u0EFBrel;\u62B0\u0100ci\u2FC0\u2FC5r;\uC000\u{1D4C5};\u43C8ncsp;\u6008\u0300fiopsu\u2FDA\u22E2\u2FDF\u2FE5\u2FEB\u2FF1r;\uC000\u{1D52E}pf;\uC000\u{1D562}rime;\u6057cr;\uC000\u{1D4C6}\u0180aeo\u2FF8\u3009\u3013t\u0100ei\u2FFE\u3005rnion\xF3\u06B0nt;\u6A16st\u0100;e\u3010\u3011\u403F\xF1\u1F19\xF4\u0F14\u0A80ABHabcdefhilmnoprstux\u3040\u3051\u3055\u3059\u30E0\u310E\u312B\u3147\u3162\u3172\u318E\u3206\u3215\u3224\u3229\u3258\u326E\u3272\u3290\u32B0\u32B7\u0180art\u3047\u304A\u304Cr\xF2\u10B3\xF2\u03DDail;\u691Car\xF2\u1C65ar;\u6964\u0380cdenqrt\u3068\u3075\u3078\u307F\u308F\u3094\u30CC\u0100eu\u306D\u3071;\uC000\u223D\u0331te;\u4155i\xE3\u116Emptyv;\u69B3g\u0200;del\u0FD1\u3089\u308B\u308D;\u6992;\u69A5\xE5\u0FD1uo\u803B\xBB\u40BBr\u0580;abcfhlpstw\u0FDC\u30AC\u30AF\u30B7\u30B9\u30BC\u30BE\u30C0\u30C3\u30C7\u30CAp;\u6975\u0100;f\u0FE0\u30B4s;\u6920;\u6933s;\u691E\xEB\u225D\xF0\u272El;\u6945im;\u6974l;\u61A3;\u619D\u0100ai\u30D1\u30D5il;\u691Ao\u0100;n\u30DB\u30DC\u6236al\xF3\u0F1E\u0180abr\u30E7\u30EA\u30EEr\xF2\u17E5rk;\u6773\u0100ak\u30F3\u30FDc\u0100ek\u30F9\u30FB;\u407D;\u405D\u0100es\u3102\u3104;\u698Cl\u0100du\u310A\u310C;\u698E;\u6990\u0200aeuy\u3117\u311C\u3127\u3129ron;\u4159\u0100di\u3121\u3125il;\u4157\xEC\u0FF2\xE2\u30FA;\u4440\u0200clqs\u3134\u3137\u313D\u3144a;\u6937dhar;\u6969uo\u0100;r\u020E\u020Dh;\u61B3\u0180acg\u314E\u315F\u0F44l\u0200;ips\u0F78\u3158\u315B\u109Cn\xE5\u10BBar\xF4\u0FA9t;\u65AD\u0180ilr\u3169\u1023\u316Esht;\u697D;\uC000\u{1D52F}\u0100ao\u3177\u3186r\u0100du\u317D\u317F\xBB\u047B\u0100;l\u1091\u3184;\u696C\u0100;v\u318B\u318C\u43C1;\u43F1\u0180gns\u3195\u31F9\u31FCht\u0300ahlrst\u31A4\u31B0\u31C2\u31D8\u31E4\u31EErrow\u0100;t\u0FDC\u31ADa\xE9\u30C8arpoon\u0100du\u31BB\u31BFow\xEE\u317Ep\xBB\u1092eft\u0100ah\u31CA\u31D0rrow\xF3\u0FEAarpoon\xF3\u0551ightarrows;\u61C9quigarro\xF7\u30CBhreetimes;\u62CCg;\u42DAingdotse\xF1\u1F32\u0180ahm\u320D\u3210\u3213r\xF2\u0FEAa\xF2\u0551;\u600Foust\u0100;a\u321E\u321F\u63B1che\xBB\u321Fmid;\u6AEE\u0200abpt\u3232\u323D\u3240\u3252\u0100nr\u3237\u323Ag;\u67EDr;\u61FEr\xEB\u1003\u0180afl\u3247\u324A\u324Er;\u6986;\uC000\u{1D563}us;\u6A2Eimes;\u6A35\u0100ap\u325D\u3267r\u0100;g\u3263\u3264\u4029t;\u6994olint;\u6A12ar\xF2\u31E3\u0200achq\u327B\u3280\u10BC\u3285quo;\u603Ar;\uC000\u{1D4C7}\u0100bu\u30FB\u328Ao\u0100;r\u0214\u0213\u0180hir\u3297\u329B\u32A0re\xE5\u31F8mes;\u62CAi\u0200;efl\u32AA\u1059\u1821\u32AB\u65B9tri;\u69CEluhar;\u6968;\u611E\u0D61\u32D5\u32DB\u32DF\u332C\u3338\u3371\0\u337A\u33A4\0\0\u33EC\u33F0\0\u3428\u3448\u345A\u34AD\u34B1\u34CA\u34F1\0\u3616\0\0\u3633cute;\u415Bqu\xEF\u27BA\u0500;Eaceinpsy\u11ED\u32F3\u32F5\u32FF\u3302\u330B\u330F\u331F\u3326\u3329;\u6AB4\u01F0\u32FA\0\u32FC;\u6AB8on;\u4161u\xE5\u11FE\u0100;d\u11F3\u3307il;\u415Frc;\u415D\u0180Eas\u3316\u3318\u331B;\u6AB6p;\u6ABAim;\u62E9olint;\u6A13i\xED\u1204;\u4441ot\u0180;be\u3334\u1D47\u3335\u62C5;\u6A66\u0380Aacmstx\u3346\u334A\u3357\u335B\u335E\u3363\u336Drr;\u61D8r\u0100hr\u3350\u3352\xEB\u2228\u0100;o\u0A36\u0A34t\u803B\xA7\u40A7i;\u403Bwar;\u6929m\u0100in\u3369\xF0nu\xF3\xF1t;\u6736r\u0100;o\u3376\u2055\uC000\u{1D530}\u0200acoy\u3382\u3386\u3391\u33A0rp;\u666F\u0100hy\u338B\u338Fcy;\u4449;\u4448rt\u026D\u3399\0\0\u339Ci\xE4\u1464ara\xEC\u2E6F\u803B\xAD\u40AD\u0100gm\u33A8\u33B4ma\u0180;fv\u33B1\u33B2\u33B2\u43C3;\u43C2\u0400;deglnpr\u12AB\u33C5\u33C9\u33CE\u33D6\u33DE\u33E1\u33E6ot;\u6A6A\u0100;q\u12B1\u12B0\u0100;E\u33D3\u33D4\u6A9E;\u6AA0\u0100;E\u33DB\u33DC\u6A9D;\u6A9Fe;\u6246lus;\u6A24arr;\u6972ar\xF2\u113D\u0200aeit\u33F8\u3408\u340F\u3417\u0100ls\u33FD\u3404lsetm\xE9\u336Ahp;\u6A33parsl;\u69E4\u0100dl\u1463\u3414e;\u6323\u0100;e\u341C\u341D\u6AAA\u0100;s\u3422\u3423\u6AAC;\uC000\u2AAC\uFE00\u0180flp\u342E\u3433\u3442tcy;\u444C\u0100;b\u3438\u3439\u402F\u0100;a\u343E\u343F\u69C4r;\u633Ff;\uC000\u{1D564}a\u0100dr\u344D\u0402es\u0100;u\u3454\u3455\u6660it\xBB\u3455\u0180csu\u3460\u3479\u349F\u0100au\u3465\u346Fp\u0100;s\u1188\u346B;\uC000\u2293\uFE00p\u0100;s\u11B4\u3475;\uC000\u2294\uFE00u\u0100bp\u347F\u348F\u0180;es\u1197\u119C\u3486et\u0100;e\u1197\u348D\xF1\u119D\u0180;es\u11A8\u11AD\u3496et\u0100;e\u11A8\u349D\xF1\u11AE\u0180;af\u117B\u34A6\u05B0r\u0165\u34AB\u05B1\xBB\u117Car\xF2\u1148\u0200cemt\u34B9\u34BE\u34C2\u34C5r;\uC000\u{1D4C8}tm\xEE\xF1i\xEC\u3415ar\xE6\u11BE\u0100ar\u34CE\u34D5r\u0100;f\u34D4\u17BF\u6606\u0100an\u34DA\u34EDight\u0100ep\u34E3\u34EApsilo\xEE\u1EE0h\xE9\u2EAFs\xBB\u2852\u0280bcmnp\u34FB\u355E\u1209\u358B\u358E\u0480;Edemnprs\u350E\u350F\u3511\u3515\u351E\u3523\u352C\u3531\u3536\u6282;\u6AC5ot;\u6ABD\u0100;d\u11DA\u351Aot;\u6AC3ult;\u6AC1\u0100Ee\u3528\u352A;\u6ACB;\u628Alus;\u6ABFarr;\u6979\u0180eiu\u353D\u3552\u3555t\u0180;en\u350E\u3545\u354Bq\u0100;q\u11DA\u350Feq\u0100;q\u352B\u3528m;\u6AC7\u0100bp\u355A\u355C;\u6AD5;\u6AD3c\u0300;acens\u11ED\u356C\u3572\u3579\u357B\u3326ppro\xF8\u32FAurlye\xF1\u11FE\xF1\u11F3\u0180aes\u3582\u3588\u331Bppro\xF8\u331Aq\xF1\u3317g;\u666A\u0680123;Edehlmnps\u35A9\u35AC\u35AF\u121C\u35B2\u35B4\u35C0\u35C9\u35D5\u35DA\u35DF\u35E8\u35ED\u803B\xB9\u40B9\u803B\xB2\u40B2\u803B\xB3\u40B3;\u6AC6\u0100os\u35B9\u35BCt;\u6ABEub;\u6AD8\u0100;d\u1222\u35C5ot;\u6AC4s\u0100ou\u35CF\u35D2l;\u67C9b;\u6AD7arr;\u697Bult;\u6AC2\u0100Ee\u35E4\u35E6;\u6ACC;\u628Blus;\u6AC0\u0180eiu\u35F4\u3609\u360Ct\u0180;en\u121C\u35FC\u3602q\u0100;q\u1222\u35B2eq\u0100;q\u35E7\u35E4m;\u6AC8\u0100bp\u3611\u3613;\u6AD4;\u6AD6\u0180Aan\u361C\u3620\u362Drr;\u61D9r\u0100hr\u3626\u3628\xEB\u222E\u0100;o\u0A2B\u0A29war;\u692Alig\u803B\xDF\u40DF\u0BE1\u3651\u365D\u3660\u12CE\u3673\u3679\0\u367E\u36C2\0\0\0\0\0\u36DB\u3703\0\u3709\u376C\0\0\0\u3787\u0272\u3656\0\0\u365Bget;\u6316;\u43C4r\xEB\u0E5F\u0180aey\u3666\u366B\u3670ron;\u4165dil;\u4163;\u4442lrec;\u6315r;\uC000\u{1D531}\u0200eiko\u3686\u369D\u36B5\u36BC\u01F2\u368B\0\u3691e\u01004f\u1284\u1281a\u0180;sv\u3698\u3699\u369B\u43B8ym;\u43D1\u0100cn\u36A2\u36B2k\u0100as\u36A8\u36AEppro\xF8\u12C1im\xBB\u12ACs\xF0\u129E\u0100as\u36BA\u36AE\xF0\u12C1rn\u803B\xFE\u40FE\u01EC\u031F\u36C6\u22E7es\u8180\xD7;bd\u36CF\u36D0\u36D8\u40D7\u0100;a\u190F\u36D5r;\u6A31;\u6A30\u0180eps\u36E1\u36E3\u3700\xE1\u2A4D\u0200;bcf\u0486\u36EC\u36F0\u36F4ot;\u6336ir;\u6AF1\u0100;o\u36F9\u36FC\uC000\u{1D565}rk;\u6ADA\xE1\u3362rime;\u6034\u0180aip\u370F\u3712\u3764d\xE5\u1248\u0380adempst\u3721\u374D\u3740\u3751\u3757\u375C\u375Fngle\u0280;dlqr\u3730\u3731\u3736\u3740\u3742\u65B5own\xBB\u1DBBeft\u0100;e\u2800\u373E\xF1\u092E;\u625Cight\u0100;e\u32AA\u374B\xF1\u105Aot;\u65ECinus;\u6A3Alus;\u6A39b;\u69CDime;\u6A3Bezium;\u63E2\u0180cht\u3772\u377D\u3781\u0100ry\u3777\u377B;\uC000\u{1D4C9};\u4446cy;\u445Brok;\u4167\u0100io\u378B\u378Ex\xF4\u1777head\u0100lr\u3797\u37A0eftarro\xF7\u084Fightarrow\xBB\u0F5D\u0900AHabcdfghlmoprstuw\u37D0\u37D3\u37D7\u37E4\u37F0\u37FC\u380E\u381C\u3823\u3834\u3851\u385D\u386B\u38A9\u38CC\u38D2\u38EA\u38F6r\xF2\u03EDar;\u6963\u0100cr\u37DC\u37E2ute\u803B\xFA\u40FA\xF2\u1150r\u01E3\u37EA\0\u37EDy;\u445Eve;\u416D\u0100iy\u37F5\u37FArc\u803B\xFB\u40FB;\u4443\u0180abh\u3803\u3806\u380Br\xF2\u13ADlac;\u4171a\xF2\u13C3\u0100ir\u3813\u3818sht;\u697E;\uC000\u{1D532}rave\u803B\xF9\u40F9\u0161\u3827\u3831r\u0100lr\u382C\u382E\xBB\u0957\xBB\u1083lk;\u6580\u0100ct\u3839\u384D\u026F\u383F\0\0\u384Arn\u0100;e\u3845\u3846\u631Cr\xBB\u3846op;\u630Fri;\u65F8\u0100al\u3856\u385Acr;\u416B\u80BB\xA8\u0349\u0100gp\u3862\u3866on;\u4173f;\uC000\u{1D566}\u0300adhlsu\u114B\u3878\u387D\u1372\u3891\u38A0own\xE1\u13B3arpoon\u0100lr\u3888\u388Cef\xF4\u382Digh\xF4\u382Fi\u0180;hl\u3899\u389A\u389C\u43C5\xBB\u13FAon\xBB\u389Aparrows;\u61C8\u0180cit\u38B0\u38C4\u38C8\u026F\u38B6\0\0\u38C1rn\u0100;e\u38BC\u38BD\u631Dr\xBB\u38BDop;\u630Eng;\u416Fri;\u65F9cr;\uC000\u{1D4CA}\u0180dir\u38D9\u38DD\u38E2ot;\u62F0lde;\u4169i\u0100;f\u3730\u38E8\xBB\u1813\u0100am\u38EF\u38F2r\xF2\u38A8l\u803B\xFC\u40FCangle;\u69A7\u0780ABDacdeflnoprsz\u391C\u391F\u3929\u392D\u39B5\u39B8\u39BD\u39DF\u39E4\u39E8\u39F3\u39F9\u39FD\u3A01\u3A20r\xF2\u03F7ar\u0100;v\u3926\u3927\u6AE8;\u6AE9as\xE8\u03E1\u0100nr\u3932\u3937grt;\u699C\u0380eknprst\u34E3\u3946\u394B\u3952\u395D\u3964\u3996app\xE1\u2415othin\xE7\u1E96\u0180hir\u34EB\u2EC8\u3959op\xF4\u2FB5\u0100;h\u13B7\u3962\xEF\u318D\u0100iu\u3969\u396Dgm\xE1\u33B3\u0100bp\u3972\u3984setneq\u0100;q\u397D\u3980\uC000\u228A\uFE00;\uC000\u2ACB\uFE00setneq\u0100;q\u398F\u3992\uC000\u228B\uFE00;\uC000\u2ACC\uFE00\u0100hr\u399B\u399Fet\xE1\u369Ciangle\u0100lr\u39AA\u39AFeft\xBB\u0925ight\xBB\u1051y;\u4432ash\xBB\u1036\u0180elr\u39C4\u39D2\u39D7\u0180;be\u2DEA\u39CB\u39CFar;\u62BBq;\u625Alip;\u62EE\u0100bt\u39DC\u1468a\xF2\u1469r;\uC000\u{1D533}tr\xE9\u39AEsu\u0100bp\u39EF\u39F1\xBB\u0D1C\xBB\u0D59pf;\uC000\u{1D567}ro\xF0\u0EFBtr\xE9\u39B4\u0100cu\u3A06\u3A0Br;\uC000\u{1D4CB}\u0100bp\u3A10\u3A18n\u0100Ee\u3980\u3A16\xBB\u397En\u0100Ee\u3992\u3A1E\xBB\u3990igzag;\u699A\u0380cefoprs\u3A36\u3A3B\u3A56\u3A5B\u3A54\u3A61\u3A6Airc;\u4175\u0100di\u3A40\u3A51\u0100bg\u3A45\u3A49ar;\u6A5Fe\u0100;q\u15FA\u3A4F;\u6259erp;\u6118r;\uC000\u{1D534}pf;\uC000\u{1D568}\u0100;e\u1479\u3A66at\xE8\u1479cr;\uC000\u{1D4CC}\u0AE3\u178E\u3A87\0\u3A8B\0\u3A90\u3A9B\0\0\u3A9D\u3AA8\u3AAB\u3AAF\0\0\u3AC3\u3ACE\0\u3AD8\u17DC\u17DFtr\xE9\u17D1r;\uC000\u{1D535}\u0100Aa\u3A94\u3A97r\xF2\u03C3r\xF2\u09F6;\u43BE\u0100Aa\u3AA1\u3AA4r\xF2\u03B8r\xF2\u09EBa\xF0\u2713is;\u62FB\u0180dpt\u17A4\u3AB5\u3ABE\u0100fl\u3ABA\u17A9;\uC000\u{1D569}im\xE5\u17B2\u0100Aa\u3AC7\u3ACAr\xF2\u03CEr\xF2\u0A01\u0100cq\u3AD2\u17B8r;\uC000\u{1D4CD}\u0100pt\u17D6\u3ADCr\xE9\u17D4\u0400acefiosu\u3AF0\u3AFD\u3B08\u3B0C\u3B11\u3B15\u3B1B\u3B21c\u0100uy\u3AF6\u3AFBte\u803B\xFD\u40FD;\u444F\u0100iy\u3B02\u3B06rc;\u4177;\u444Bn\u803B\xA5\u40A5r;\uC000\u{1D536}cy;\u4457pf;\uC000\u{1D56A}cr;\uC000\u{1D4CE}\u0100cm\u3B26\u3B29y;\u444El\u803B\xFF\u40FF\u0500acdefhiosw\u3B42\u3B48\u3B54\u3B58\u3B64\u3B69\u3B6D\u3B74\u3B7A\u3B80cute;\u417A\u0100ay\u3B4D\u3B52ron;\u417E;\u4437ot;\u417C\u0100et\u3B5D\u3B61tr\xE6\u155Fa;\u43B6r;\uC000\u{1D537}cy;\u4436grarr;\u61DDpf;\uC000\u{1D56B}cr;\uC000\u{1D4CF}\u0100jn\u3B85\u3B87;\u600Dj;\u600C'.split("").map(u=>u.charCodeAt(0)));var _0=new Uint16Array("\u0200aglq \x1B\u026D\0\0p;\u4026os;\u4027t;\u403Et;\u403Cuot;\u4022".split("").map(u=>u.charCodeAt(0)));var ne,Jt=new Map([[0,65533],[128,8364],[130,8218],[131,402],[132,8222],[133,8230],[134,8224],[135,8225],[136,710],[137,8240],[138,352],[139,8249],[140,338],[142,381],[145,8216],[146,8217],[147,8220],[148,8221],[149,8226],[150,8211],[151,8212],[152,732],[153,8482],[154,353],[155,8250],[156,339],[158,382],[159,376]]),oe=(ne=String.fromCodePoint)!==null&&ne!==void 0?ne:function(u){let e="";return u>65535&&(u-=65536,e+=String.fromCharCode(u>>>10&1023|55296),u=56320|u&1023),e+=String.fromCharCode(u),e};function ie(u){var e;return u>=55296&&u<=57343||u>1114111?65533:(e=Jt.get(u))!==null&&e!==void 0?e:u}var A;(function(u){u[u.NUM=35]="NUM",u[u.SEMI=59]="SEMI",u[u.EQUALS=61]="EQUALS",u[u.ZERO=48]="ZERO",u[u.NINE=57]="NINE",u[u.LOWER_A=97]="LOWER_A",u[u.LOWER_F=102]="LOWER_F",u[u.LOWER_X=120]="LOWER_X",u[u.LOWER_Z=122]="LOWER_Z",u[u.UPPER_A=65]="UPPER_A",u[u.UPPER_F=70]="UPPER_F",u[u.UPPER_Z=90]="UPPER_Z"})(A||(A={}));var Xt=32,H;(function(u){u[u.VALUE_LENGTH=49152]="VALUE_LENGTH",u[u.BRANCH_LENGTH=16256]="BRANCH_LENGTH",u[u.JUMP_TABLE=127]="JUMP_TABLE"})(H||(H={}));function ce(u){return u>=A.ZERO&&u<=A.NINE}function Qt(u){return u>=A.UPPER_A&&u<=A.UPPER_F||u>=A.LOWER_A&&u<=A.LOWER_F}function Yt(u){return u>=A.UPPER_A&&u<=A.UPPER_Z||u>=A.LOWER_A&&u<=A.LOWER_Z||ce(u)}function ur(u){return u===A.EQUALS||Yt(u)}var E;(function(u){u[u.EntityStart=0]="EntityStart",u[u.NumericStart=1]="NumericStart",u[u.NumericDecimal=2]="NumericDecimal",u[u.NumericHex=3]="NumericHex",u[u.NamedEntity=4]="NamedEntity"})(E||(E={}));var P;(function(u){u[u.Legacy=0]="Legacy",u[u.Strict=1]="Strict",u[u.Attribute=2]="Attribute"})(P||(P={}));var Fu=class{constructor(e,t,r){this.decodeTree=e,this.emitCodePoint=t,this.errors=r,this.state=E.EntityStart,this.consumed=1,this.result=0,this.treeIndex=0,this.excess=1,this.decodeMode=P.Strict}startEntity(e){this.decodeMode=e,this.state=E.EntityStart,this.result=0,this.treeIndex=0,this.excess=1,this.consumed=1}write(e,t){switch(this.state){case E.EntityStart:return e.charCodeAt(t)===A.NUM?(this.state=E.NumericStart,this.consumed+=1,this.stateNumericStart(e,t+1)):(this.state=E.NamedEntity,this.stateNamedEntity(e,t));case E.NumericStart:return this.stateNumericStart(e,t);case E.NumericDecimal:return this.stateNumericDecimal(e,t);case E.NumericHex:return this.stateNumericHex(e,t);case E.NamedEntity:return this.stateNamedEntity(e,t)}}stateNumericStart(e,t){return t>=e.length?-1:(e.charCodeAt(t)|Xt)===A.LOWER_X?(this.state=E.NumericHex,this.consumed+=1,this.stateNumericHex(e,t+1)):(this.state=E.NumericDecimal,this.stateNumericDecimal(e,t))}addToNumericResult(e,t,r,n){if(t!==r){let o=r-t;this.result=this.result*Math.pow(n,o)+parseInt(e.substr(t,o),n),this.consumed+=o}}stateNumericHex(e,t){let r=t;for(;t>14;for(;t>14,o!==0){if(c===A.SEMI)return this.emitNamedEntityData(this.treeIndex,o,this.consumed+this.excess);this.decodeMode!==P.Strict&&(this.result=this.treeIndex,this.consumed+=this.excess,this.excess=0)}}return-1}emitNotTerminatedNamedEntity(){var e;let{result:t,decodeTree:r}=this,n=(r[t]&H.VALUE_LENGTH)>>14;return this.emitNamedEntityData(t,n,this.consumed),(e=this.errors)===null||e===void 0||e.missingSemicolonAfterCharacterReference(),this.consumed}emitNamedEntityData(e,t,r){let{decodeTree:n}=this;return this.emitCodePoint(t===1?n[e]&~H.VALUE_LENGTH:n[e+1],r),t===3&&this.emitCodePoint(n[e+2],r),r}end(){var e;switch(this.state){case E.NamedEntity:return this.result!==0&&(this.decodeMode!==P.Attribute||this.result===this.treeIndex)?this.emitNotTerminatedNamedEntity():0;case E.NumericDecimal:return this.emitNumericEntity(0,2);case E.NumericHex:return this.emitNumericEntity(0,3);case E.NumericStart:return(e=this.errors)===null||e===void 0||e.absenceOfDigitsInNumericCharacterReference(this.consumed),0;case E.EntityStart:return 0}}};function C0(u){let e="",t=new Fu(u,r=>e+=oe(r));return function(n,o){let c=0,i=0;for(;(i=n.indexOf("&",i))>=0;){e+=n.slice(c,i),t.startEntity(o);let s=t.write(n,i+1);if(s<0){c=i+t.end();break}c=i+s,i=s===0?c+1:c}let a=e+n.slice(c);return e="",a}}function er(u,e,t,r){let n=(e&H.BRANCH_LENGTH)>>7,o=e&H.JUMP_TABLE;if(n===0)return o!==0&&r===o?t:-1;if(o){let a=r-o;return a<0||a>=n?-1:u[t+a]-1}let c=t,i=c+n-1;for(;c<=i;){let a=c+i>>>1,s=u[a];if(sr)i=a-1;else return u[a+n]}return-1}var tr=C0(y0),So=C0(_0);function U(u,e=P.Legacy){return tr(u,e)}function vu(u){for(let e=1;eu.codePointAt(e):(u,e)=>(u.charCodeAt(e)&64512)===55296?(u.charCodeAt(e)-55296)*1024+u.charCodeAt(e+1)-56320+65536:u.charCodeAt(e);function ae(u,e){return function(r){let n,o=0,c="";for(;n=u.exec(r);)o!==n.index&&(c+=r.substring(o,n.index)),c+=e.get(n[0].charCodeAt(0)),o=n.index+1;return c+r.substring(o)}}var D0=ae(/[&<>'"]/g,nr),E0=ae(/["&\u00A0]/g,new Map([[34,"""],[38,"&"],[160," "]])),A0=ae(/[&<>\u00A0]/g,new Map([[38,"&"],[60,"<"],[62,">"],[160," "]]));var F0;(function(u){u[u.XML=0]="XML",u[u.HTML=1]="HTML"})(F0||(F0={}));var v0;(function(u){u[u.UTF8=0]="UTF8",u[u.ASCII=1]="ASCII",u[u.Extensive=2]="Extensive",u[u.Attribute=3]="Attribute",u[u.Text=4]="Text"})(v0||(v0={}));function ar(u){return Object.prototype.toString.call(u)}function wu(u){return ar(u)==="[object String]"}var sr=Object.prototype.hasOwnProperty;function lr(u,e){return sr.call(u,e)}function Y(u){return Array.prototype.slice.call(arguments,1).forEach(function(t){if(t){if(typeof t!="object")throw new TypeError(t+"must be object");Object.keys(t).forEach(function(r){u[r]=t[r]})}}),u}function le(u,e,t){return[].concat(u.slice(0,e),t,u.slice(e+1))}function Su(u){return!(u>=55296&&u<=57343||u>=64976&&u<=65007||(u&65535)===65535||(u&65535)===65534||u>=0&&u<=8||u===11||u>=14&&u<=31||u>=127&&u<=159||u>1114111)}function lu(u){if(u>65535){u-=65536;let e=55296+(u>>10),t=56320+(u&1023);return String.fromCharCode(e,t)}return String.fromCharCode(u)}var T0=/\\([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g,dr=/&([a-z#][a-z0-9]{1,31});/gi,fr=new RegExp(T0.source+"|"+dr.source,"gi"),pr=/^#((?:x[a-f0-9]{1,8}|[0-9]{1,8}))$/i;function hr(u,e){if(e.charCodeAt(0)===35&&pr.test(e)){let r=e[1].toLowerCase()==="x"?parseInt(e.slice(2),16):parseInt(e.slice(1),10);return Su(r)?lu(r):u}let t=U(u);return t!==u?t:u}function br(u){return u.indexOf("\\")<0?u:u.replace(T0,"$1")}function I(u){return u.indexOf("\\")<0&&u.indexOf("&")<0?u:u.replace(fr,function(e,t,r){return t||hr(e,r)})}var mr=/[&<>"]/,xr=/[&<>"]/g,gr={"&":"&","<":"<",">":">",'"':"""};function kr(u){return gr[u]}function q(u){return mr.test(u)?u.replace(xr,kr):u}var yr=/[.?*+^$[\]\\(){}|-]/g;function _r(u){return u.replace(yr,"\\$&")}function y(u){switch(u){case 9:case 32:return!0}return!1}function V(u){if(u>=8192&&u<=8202)return!0;switch(u){case 9:case 10:case 11:case 12:case 13:case 32:case 160:case 5760:case 8239:case 8287:case 12288:return!0}return!1}function j(u){return Q.test(u)||Eu.test(u)}function G(u){switch(u){case 33:case 34:case 35:case 36:case 37:case 38:case 39:case 40:case 41:case 42:case 43:case 44:case 45:case 46:case 47:case 58:case 59:case 60:case 61:case 62:case 63:case 64:case 91:case 92:case 93:case 94:case 95:case 96:case 123:case 124:case 125:case 126:return!0;default:return!1}}function Z(u){return u=u.trim().replace(/\s+/g," "),"\u1E9E".toLowerCase()==="\u1E7E"&&(u=u.replace(/ẞ/g,"\xDF")),u.toLowerCase().toUpperCase()}var Cr={mdurl:_u,ucmicro:re};var be={};cu(be,{parseLinkDestination:()=>pe,parseLinkLabel:()=>fe,parseLinkTitle:()=>he});function fe(u,e,t){let r,n,o,c,i=u.posMax,a=u.pos;for(u.pos=e+1,r=1;u.pos32))return o;if(r===41){if(c===0)break;c--}n++}return e===n||c!==0||(o.str=I(u.slice(e,n)),o.pos=n,o.ok=!0),o}function he(u,e,t,r){let n,o=e,c={ok:!1,can_continue:!1,pos:0,str:"",marker:0};if(r)c.str=r.str,c.marker=r.marker;else{if(o>=t)return c;let i=u.charCodeAt(o);if(i!==34&&i!==39&&i!==40)return c;e++,o++,i===40&&(i=41),c.marker=i}for(;o"+q(o.content)+""};L.code_block=function(u,e,t,r,n){let o=u[e];return""+q(u[e].content)+` +`};L.fence=function(u,e,t,r,n){let o=u[e],c=o.info?I(o.info).trim():"",i="",a="";if(c){let l=c.split(/(\s+)/g);i=l[0],a=l.slice(2).join("")}let s;if(t.highlight?s=t.highlight(o.content,i,a)||q(o.content):s=q(o.content),s.indexOf("${s} +`}return`
${s}
+`};L.image=function(u,e,t,r,n){let o=u[e];return o.attrs[o.attrIndex("alt")][1]=n.renderInlineAsText(o.children,t,r),n.renderToken(u,e,t)};L.hardbreak=function(u,e,t){return t.xhtmlOut?`
+`:`
+`};L.softbreak=function(u,e,t){return t.breaks?t.xhtmlOut?`
+`:`
+`:` +`};L.text=function(u,e){return q(u[e].content)};L.html_block=function(u,e){return u[e].content};L.html_inline=function(u,e){return u[e].content};function uu(){this.rules=Y({},L)}uu.prototype.renderAttrs=function(e){let t,r,n;if(!e.attrs)return"";for(n="",t=0,r=e.attrs.length;t +`:">",o};uu.prototype.renderInline=function(u,e,t){let r="",n=this.rules;for(let o=0,c=u.length;o=0&&(r=this.attrs[t][1]),r};eu.prototype.attrJoin=function(e,t){let r=this.attrIndex(e);r<0?this.attrPush([e,t]):this.attrs[r][1]=this.attrs[r][1]+" "+t};var z=eu;function P0(u,e,t){this.src=u,this.env=t,this.tokens=[],this.inlineMode=!1,this.md=e}P0.prototype.Token=z;var L0=P0;var Dr=/\r\n?|\n/g,Er=/\0/g;function me(u){let e;e=u.src.replace(Dr,` +`),e=e.replace(Er,"\uFFFD"),u.src=e}function xe(u){let e;u.inlineMode?(e=new u.Token("inline","",0),e.content=u.src,e.map=[0,1],e.children=[],u.tokens.push(e)):u.md.block.parse(u.src,u.md,u.env,u.tokens)}function ge(u){let e=u.tokens;for(let t=0,r=e.length;t\s]/i.test(u)}function Fr(u){return/^<\/a\s*>/i.test(u)}function ke(u){let e=u.tokens;if(u.md.options.linkify)for(let t=0,r=e.length;t=0;c--){let i=n[c];if(i.type==="link_close"){for(c--;n[c].level!==i.level&&n[c].type!=="link_open";)c--;continue}if(i.type==="html_inline"&&(Ar(i.content)&&o>0&&o--,Fr(i.content)&&o++),!(o>0)&&i.type==="text"&&u.md.linkify.test(i.content)){let a=i.content,s=u.md.linkify.match(a),l=[],f=i.level,h=0;s.length>0&&s[0].index===0&&c>0&&n[c-1].type==="text_special"&&(s=s.slice(1));for(let p=0;ph){let k=new u.Token("text","",0);k.content=a.slice(h,D),k.level=f,l.push(k)}let m=new u.Token("link_open","a",1);m.attrs=[["href",b]],m.level=f++,m.markup="linkify",m.info="auto",l.push(m);let g=new u.Token("text","",0);g.content=_,g.level=f,l.push(g);let x=new u.Token("link_close","a",-1);x.level=--f,x.markup="linkify",x.info="auto",l.push(x),h=s[p].lastIndex}if(h=0;t--){let r=u[t];r.type==="text"&&!e&&(r.content=r.content.replace(wr,Tr)),r.type==="link_open"&&r.info==="auto"&&e--,r.type==="link_close"&&r.info==="auto"&&e++}}function Pr(u){let e=0;for(let t=u.length-1;t>=0;t--){let r=u[t];r.type==="text"&&!e&&M0.test(r.content)&&(r.content=r.content.replace(/\+-/g,"\xB1").replace(/\.{2,}/g,"\u2026").replace(/([?!])…/g,"$1..").replace(/([?!]){4,}/g,"$1$1$1").replace(/,{2,}/g,",").replace(/(^|[^-])---(?=[^-]|$)/mg,"$1\u2014").replace(/(^|\s)--(?=\s|$)/mg,"$1\u2013").replace(/(^|[^-\s])--(?=[^-\s]|$)/mg,"$1\u2013")),r.type==="link_open"&&r.info==="auto"&&e--,r.type==="link_close"&&r.info==="auto"&&e++}}function ye(u){let e;if(u.md.options.typographer)for(e=u.tokens.length-1;e>=0;e--)u.tokens[e].type==="inline"&&(vr.test(u.tokens[e].content)&&Rr(u.tokens[e].children),M0.test(u.tokens[e].content)&&Pr(u.tokens[e].children))}var Lr=/['"]/,N0=/['"]/g,I0="\u2019";function Tu(u,e,t){return u.slice(0,e)+t+u.slice(e+1)}function Mr(u,e){let t,r=[];for(let n=0;n=0&&!(r[t].level<=c);t--);if(r.length=t+1,o.type!=="text")continue;let i=o.content,a=0,s=i.length;u:for(;a=0)d=i.charCodeAt(l.index-1);else for(t=n-1;t>=0&&!(u[t].type==="softbreak"||u[t].type==="hardbreak");t--)if(u[t].content){d=u[t].content.charCodeAt(u[t].content.length-1);break}let b=32;if(a=48&&d<=57&&(h=f=!1),f&&h&&(f=_,h=D),!f&&!h){p&&(o.content=Tu(o.content,l.index,I0));continue}if(h)for(t=r.length-1;t>=0;t--){let x=r[t];if(r[t].level=0;e--)u.tokens[e].type!=="inline"||!Lr.test(u.tokens[e].content)||Mr(u.tokens[e].children,u)}function Ce(u){let e,t,r=u.tokens,n=r.length;for(let o=0;o0&&this.level++,this.tokens.push(r),r};M.prototype.isEmpty=function(e){return this.bMarks[e]+this.tShift[e]>=this.eMarks[e]};M.prototype.skipEmptyLines=function(e){for(let t=this.lineMax;et;)if(!y(this.src.charCodeAt(--e)))return e+1;return e};M.prototype.skipChars=function(e,t){for(let r=this.src.length;er;)if(t!==this.src.charCodeAt(--e))return e+1;return e};M.prototype.getLines=function(e,t,r,n){if(e>=t)return"";let o=new Array(t-e);for(let c=0,i=e;ir?o[c]=new Array(a-r+1).join(" ")+this.src.slice(l,f):o[c]=this.src.slice(l,f)}return o.join("")};M.prototype.Token=z;var z0=M;var Nr=65536;function Ae(u,e){let t=u.bMarks[e]+u.tShift[e],r=u.eMarks[e];return u.src.slice(t,r)}function B0(u){let e=[],t=u.length,r=0,n=u.charCodeAt(r),o=!1,c=0,i="";for(;rt)return!1;let n=e+1;if(u.sCount[n]=4)return!1;let o=u.bMarks[n]+u.tShift[n];if(o>=u.eMarks[n])return!1;let c=u.src.charCodeAt(o++);if(c!==124&&c!==45&&c!==58||o>=u.eMarks[n])return!1;let i=u.src.charCodeAt(o++);if(i!==124&&i!==45&&i!==58&&!y(i)||c===45&&y(i))return!1;for(;o=4)return!1;s=B0(a),s.length&&s[0]===""&&s.shift(),s.length&&s[s.length-1]===""&&s.pop();let f=s.length;if(f===0||f!==l.length)return!1;if(r)return!0;let h=u.parentType;u.parentType="table";let p=u.md.block.ruler.getRules("blockquote"),d=u.push("table_open","table",1),b=[e,0];d.map=b;let _=u.push("thead_open","thead",1);_.map=[e,e+1];let D=u.push("tr_open","tr",1);D.map=[e,e+1];for(let x=0;x=4||(s=B0(a),s.length&&s[0]===""&&s.shift(),s.length&&s[s.length-1]===""&&s.pop(),g+=f-s.length,g>Nr))break;if(n===e+2){let C=u.push("tbody_open","tbody",1);C.map=m=[e+2,0]}let k=u.push("tr_open","tr",1);k.map=[n,n+1];for(let C=0;C=4){r++,n=r;continue}break}u.line=n;let o=u.push("code_block","code",0);return o.content=u.getLines(e,n,4+u.blkIndent,!1)+` +`,o.map=[e,u.line],!0}function we(u,e,t,r){let n=u.bMarks[e]+u.tShift[e],o=u.eMarks[e];if(u.sCount[e]-u.blkIndent>=4||n+3>o)return!1;let c=u.src.charCodeAt(n);if(c!==126&&c!==96)return!1;let i=n;n=u.skipChars(n,c);let a=n-i;if(a<3)return!1;let s=u.src.slice(i,n),l=u.src.slice(n,o);if(c===96&&l.indexOf(String.fromCharCode(c))>=0)return!1;if(r)return!0;let f=e,h=!1;for(;f++,!(f>=t||(n=i=u.bMarks[f]+u.tShift[f],o=u.eMarks[f],n=4)&&(n=u.skipChars(n,c),!(n-i=4||u.src.charCodeAt(n)!==62)return!1;if(r)return!0;let i=[],a=[],s=[],l=[],f=u.md.block.ruler.getRules("blockquote"),h=u.parentType;u.parentType="blockquote";let p=!1,d;for(d=e;d=o)break;if(u.src.charCodeAt(n++)===62&&!g){let k=u.sCount[d]+1,C,F;u.src.charCodeAt(n)===32?(n++,k++,F=!1,C=!0):u.src.charCodeAt(n)===9?(C=!0,(u.bsCount[d]+k)%4===3?(n++,k++,F=!1):F=!0):C=!1;let T=k;for(i.push(u.bMarks[d]),u.bMarks[d]=n;n=o,a.push(u.bsCount[d]),u.bsCount[d]=u.sCount[d]+1+(C?1:0),s.push(u.sCount[d]),u.sCount[d]=T-k,l.push(u.tShift[d]),u.tShift[d]=n-u.bMarks[d];continue}if(p)break;let x=!1;for(let k=0,C=f.length;k";let D=[e,0];_.map=D,u.md.block.tokenize(u,e,d);let m=u.push("blockquote_close","blockquote",-1);m.markup=">",u.lineMax=c,u.parentType=h,D[1]=u.line;for(let g=0;g=4)return!1;let o=u.bMarks[e]+u.tShift[e],c=u.src.charCodeAt(o++);if(c!==42&&c!==45&&c!==95)return!1;let i=1;for(;o=r)return-1;let o=u.src.charCodeAt(n++);if(o<48||o>57)return-1;for(;;){if(n>=r)return-1;if(o=u.src.charCodeAt(n++),o>=48&&o<=57){if(n-t>=10)return-1;continue}if(o===41||o===46)break;return-1}return n=4||u.listIndent>=0&&u.sCount[a]-u.listIndent>=4&&u.sCount[a]=u.blkIndent&&(l=!0);let f,h,p;if((p=U0(u,a))>=0){if(f=!0,c=u.bMarks[a]+u.tShift[a],h=Number(u.src.slice(c,p-1)),l&&h!==1)return!1}else if((p=H0(u,a))>=0)f=!1;else return!1;if(l&&u.skipSpaces(p)>=u.eMarks[a])return!1;if(r)return!0;let d=u.src.charCodeAt(p-1),b=u.tokens.length;f?(i=u.push("ordered_list_open","ol",1),h!==1&&(i.attrs=[["start",h]])):i=u.push("bullet_list_open","ul",1);let _=[a,0];i.map=_,i.markup=String.fromCharCode(d);let D=!1,m=u.md.block.ruler.getRules("list"),g=u.parentType;for(u.parentType="list";a=n?F=1:F=k-x,F>4&&(F=1);let T=x+F;i=u.push("list_item_open","li",1),i.markup=String.fromCharCode(d);let B=[a,0];i.map=B,f&&(i.info=u.src.slice(c,p-1));let iu=u.tight,ee=u.tShift[a],Ct=u.sCount[a],Dt=u.listIndent;if(u.listIndent=u.blkIndent,u.blkIndent=T,u.tight=!0,u.tShift[a]=C-u.bMarks[a],u.sCount[a]=k,C>=n&&u.isEmpty(a+1)?u.line=Math.min(u.line+2,t):u.md.block.tokenize(u,a,t,!0),(!u.tight||D)&&(s=!1),D=u.line-a>1&&u.isEmpty(u.line-1),u.blkIndent=u.listIndent,u.listIndent=Dt,u.tShift[a]=ee,u.sCount[a]=Ct,u.tight=iu,i=u.push("list_item_close","li",-1),i.markup=String.fromCharCode(d),a=u.line,B[1]=a,a>=t||u.sCount[a]=4)break;let s0=!1;for(let W=0,Et=m.length;W=4||u.src.charCodeAt(n)!==91)return!1;function i(m){let g=u.lineMax;if(m>=g||u.isEmpty(m))return null;let x=!1;if(u.sCount[m]-u.blkIndent>3&&(x=!0),u.sCount[m]<0&&(x=!0),!x){let F=u.md.block.ruler.getRules("reference"),T=u.parentType;u.parentType="reference";let B=!1;for(let iu=0,ee=F.length;iu"u"&&(u.env.references={}),typeof u.env.references[D]>"u"&&(u.env.references[D]={title:_,href:f}),u.line=c),!0):!1}var O0=["address","article","aside","base","basefont","blockquote","body","caption","center","col","colgroup","dd","details","dialog","dir","div","dl","dt","fieldset","figcaption","figure","footer","form","frame","frameset","h1","h2","h3","h4","h5","h6","head","header","hr","html","iframe","legend","li","link","main","menu","menuitem","nav","noframes","ol","optgroup","option","p","param","search","section","summary","table","tbody","td","tfoot","th","thead","title","tr","track","ul"];var qr="[a-zA-Z_:][a-zA-Z0-9:._-]*",zr="[^\"'=<>`\\x00-\\x20]+",Br="'[^']*'",Hr='"[^"]*"',Ur="(?:"+zr+"|"+Br+"|"+Hr+")",Or="(?:\\s+"+qr+"(?:\\s*=\\s*"+Ur+")?)",$0="<[A-Za-z][A-Za-z0-9\\-]*"+Or+"*\\s*\\/?>",V0="<\\/[A-Za-z][A-Za-z0-9\\-]*\\s*>",$r="",Vr="<[?][\\s\\S]*?[?]>",jr="]*>",Gr="",j0=new RegExp("^(?:"+$0+"|"+V0+"|"+$r+"|"+Vr+"|"+jr+"|"+Gr+")"),G0=new RegExp("^(?:"+$0+"|"+V0+")");var tu=[[/^<(script|pre|style|textarea)(?=(\s|>|$))/i,/<\/(script|pre|style|textarea)>/i,!0],[/^/,!0],[/^<\?/,/\?>/,!0],[/^/,!0],[/^/,!0],[new RegExp("^|$))","i"),/^$/,!0],[new RegExp(G0.source+"\\s*$"),/^$/,!1]];function Le(u,e,t,r){let n=u.bMarks[e]+u.tShift[e],o=u.eMarks[e];if(u.sCount[e]-u.blkIndent>=4||!u.md.options.html||u.src.charCodeAt(n)!==60)return!1;let c=u.src.slice(n,o),i=0;for(;i=4)return!1;let c=u.src.charCodeAt(n);if(c!==35||n>=o)return!1;let i=1;for(c=u.src.charCodeAt(++n);c===35&&n6||nn&&y(u.src.charCodeAt(a-1))&&(o=a),u.line=e+1;let s=u.push("heading_open","h"+String(i),1);s.markup="########".slice(0,i),s.map=[e,u.line];let l=u.push("inline","",0);l.content=u.src.slice(n,o).trim(),l.map=[e,u.line],l.children=[];let f=u.push("heading_close","h"+String(i),-1);return f.markup="########".slice(0,i),!0}function Ne(u,e,t){let r=u.md.block.ruler.getRules("paragraph");if(u.sCount[e]-u.blkIndent>=4)return!1;let n=u.parentType;u.parentType="paragraph";let o=0,c,i=e+1;for(;i3)continue;if(u.sCount[i]>=u.blkIndent){let p=u.bMarks[i]+u.tShift[i],d=u.eMarks[i];if(p=d))){o=c===61?1:2;break}}if(u.sCount[i]<0)continue;let h=!1;for(let p=0,d=r.length;p3||u.sCount[o]<0)continue;let s=!1;for(let l=0,f=r.length;l=t||u.sCount[c]=o){u.line=t;break}let a=u.line,s=!1;for(let l=0;l=u.line)throw new Error("block rule didn't increment state.line");break}if(!s)throw new Error("none of the block rules matched");u.tight=!i,u.isEmpty(u.line-1)&&(i=!0),c=u.line,c0&&(this.level++,this._prev_delimiters.push(this.delimiters),this.delimiters=[],n={delimiters:this.delimiters}),this.pendingLevel=this.level,this.tokens.push(r),this.tokens_meta.push(n),r};du.prototype.scanDelims=function(u,e){let t=this.posMax,r=this.src.charCodeAt(u),n=u>0?this.src.charCodeAt(u-1):32,o=u;for(;o0)return!1;let t=u.pos,r=u.posMax;if(t+3>r||u.src.charCodeAt(t)!==58||u.src.charCodeAt(t+1)!==47||u.src.charCodeAt(t+2)!==47)return!1;let n=u.pending.match(Kr);if(!n)return!1;let o=n[1],c=u.md.linkify.matchAtStart(u.src.slice(t-o.length));if(!c)return!1;let i=c.url;if(i.length<=o.length)return!1;let a=i.length;for(;a>0&&i.charCodeAt(a-1)===42;)a--;a!==i.length&&(i=i.slice(0,a));let s=u.md.normalizeLink(i);if(!u.md.validateLink(s))return!1;if(!e){u.pending=u.pending.slice(0,-o.length);let l=u.push("link_open","a",1);l.attrs=[["href",s]],l.markup="linkify",l.info="auto";let f=u.push("text","",0);f.content=u.md.normalizeLinkText(i);let h=u.push("link_close","a",-1);h.markup="linkify",h.info="auto"}return u.pos+=i.length-o.length,!0}function Be(u,e){let t=u.pos;if(u.src.charCodeAt(t)!==10)return!1;let r=u.pending.length-1,n=u.posMax;if(!e)if(r>=0&&u.pending.charCodeAt(r)===32)if(r>=1&&u.pending.charCodeAt(r-1)===32){let o=r-1;for(;o>=1&&u.pending.charCodeAt(o-1)===32;)o--;u.pending=u.pending.slice(0,o),u.push("hardbreak","br",0)}else u.pending=u.pending.slice(0,-1),u.push("softbreak","br",0);else u.push("softbreak","br",0);for(t++;t?@[]^_`{|}~-".split("").forEach(function(u){He[u.charCodeAt(0)]=1});function Ue(u,e){let t=u.pos,r=u.posMax;if(u.src.charCodeAt(t)!==92||(t++,t>=r))return!1;let n=u.src.charCodeAt(t);if(n===10){for(e||u.push("hardbreak","br",0),t++;t=55296&&n<=56319&&t+1=56320&&i<=57343&&(o+=u.src[t+1],t++)}let c="\\"+o;if(!e){let i=u.push("text_special","",0);n<256&&He[n]!==0?i.content=o:i.content=c,i.markup=c,i.info="escape"}return u.pos=t+1,!0}function Oe(u,e){let t=u.pos;if(u.src.charCodeAt(t)!==96)return!1;let n=t;t++;let o=u.posMax;for(;t=0;r--){let n=e[r];if(n.marker!==95&&n.marker!==42||n.end===-1)continue;let o=e[n.end],c=r>0&&e[r-1].end===n.end+1&&e[r-1].marker===n.marker&&e[r-1].token===n.token-1&&e[n.end+1].token===o.token+1,i=String.fromCharCode(n.marker),a=u.tokens[n.token];a.type=c?"strong_open":"em_open",a.tag=c?"strong":"em",a.nesting=1,a.markup=c?i+i:i,a.content="";let s=u.tokens[o.token];s.type=c?"strong_close":"em_close",s.tag=c?"strong":"em",s.nesting=-1,s.markup=c?i+i:i,s.content="",c&&(u.tokens[e[r-1].token].content="",u.tokens[e[n.end+1].token].content="",r--)}}function Qr(u){let e=u.tokens_meta,t=u.tokens_meta.length;J0(u,u.delimiters);for(let r=0;r=f)return!1;if(a=d,n=u.md.helpers.parseLinkDestination(u.src,d,u.posMax),n.ok){for(c=u.md.normalizeLink(n.str),u.md.validateLink(c)?d=n.pos:c="",a=d;d=f||u.src.charCodeAt(d)!==41)&&(s=!0),d++}if(s){if(typeof u.env.references>"u")return!1;if(d=0?r=u.src.slice(a,d++):d=p+1):d=p+1,r||(r=u.src.slice(h,p)),o=u.env.references[Z(r)],!o)return u.pos=l,!1;c=o.href,i=o.title}if(!e){u.pos=h,u.posMax=p;let b=u.push("link_open","a",1),_=[["href",c]];b.attrs=_,i&&_.push(["title",i]),u.linkLevel++,u.md.inline.tokenize(u),u.linkLevel--,u.push("link_close","a",-1)}return u.pos=d,u.posMax=f,!0}function Ge(u,e){let t,r,n,o,c,i,a,s,l="",f=u.pos,h=u.posMax;if(u.src.charCodeAt(u.pos)!==33||u.src.charCodeAt(u.pos+1)!==91)return!1;let p=u.pos+2,d=u.md.helpers.parseLinkLabel(u,u.pos+1,!1);if(d<0)return!1;if(o=d+1,o=h)return!1;for(s=o,i=u.md.helpers.parseLinkDestination(u.src,o,u.posMax),i.ok&&(l=u.md.normalizeLink(i.str),u.md.validateLink(l)?o=i.pos:l=""),s=o;o=h||u.src.charCodeAt(o)!==41)return u.pos=f,!1;o++}else{if(typeof u.env.references>"u")return!1;if(o=0?n=u.src.slice(s,o++):o=d+1):o=d+1,n||(n=u.src.slice(p,d)),c=u.env.references[Z(n)],!c)return u.pos=f,!1;l=c.href,a=c.title}if(!e){r=u.src.slice(p,d);let b=[];u.md.inline.parse(r,u.md,u.env,b);let _=u.push("image","img",0),D=[["src",l],["alt",""]];_.attrs=D,_.children=b,_.content=r,a&&D.push(["title",a])}return u.pos=o,u.posMax=h,!0}var Yr=/^([a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)$/,un=/^([a-zA-Z][a-zA-Z0-9+.-]{1,31}):([^<>\x00-\x20]*)$/;function Ze(u,e){let t=u.pos;if(u.src.charCodeAt(t)!==60)return!1;let r=u.pos,n=u.posMax;for(;;){if(++t>=n)return!1;let c=u.src.charCodeAt(t);if(c===60)return!1;if(c===62)break}let o=u.src.slice(r+1,t);if(un.test(o)){let c=u.md.normalizeLink(o);if(!u.md.validateLink(c))return!1;if(!e){let i=u.push("link_open","a",1);i.attrs=[["href",c]],i.markup="autolink",i.info="auto";let a=u.push("text","",0);a.content=u.md.normalizeLinkText(o);let s=u.push("link_close","a",-1);s.markup="autolink",s.info="auto"}return u.pos+=o.length+2,!0}if(Yr.test(o)){let c=u.md.normalizeLink("mailto:"+o);if(!u.md.validateLink(c))return!1;if(!e){let i=u.push("link_open","a",1);i.attrs=[["href",c]],i.markup="autolink",i.info="auto";let a=u.push("text","",0);a.content=u.md.normalizeLinkText(o);let s=u.push("link_close","a",-1);s.markup="autolink",s.info="auto"}return u.pos+=o.length+2,!0}return!1}function en(u){return/^\s]/i.test(u)}function tn(u){return/^<\/a\s*>/i.test(u)}function rn(u){let e=u|32;return e>=97&&e<=122}function Ke(u,e){if(!u.md.options.html)return!1;let t=u.posMax,r=u.pos;if(u.src.charCodeAt(r)!==60||r+2>=t)return!1;let n=u.src.charCodeAt(r+1);if(n!==33&&n!==63&&n!==47&&!rn(n))return!1;let o=u.src.slice(r).match(j0);if(!o)return!1;if(!e){let c=u.push("html_inline","",0);c.content=o[0],en(c.content)&&u.linkLevel++,tn(c.content)&&u.linkLevel--}return u.pos+=o[0].length,!0}var nn=/^&#((?:x[a-f0-9]{1,6}|[0-9]{1,7}));/i,on=/^&([a-z][a-z0-9]{1,31});/i;function We(u,e){let t=u.pos,r=u.posMax;if(u.src.charCodeAt(t)!==38||t+1>=r)return!1;if(u.src.charCodeAt(t+1)===35){let o=u.src.slice(t).match(nn);if(o){if(!e){let c=o[1][0].toLowerCase()==="x"?parseInt(o[1].slice(1),16):parseInt(o[1],10),i=u.push("text_special","",0);i.content=Su(c)?lu(c):lu(65533),i.markup=o[0],i.info="entity"}return u.pos+=o[0].length,!0}}else{let o=u.src.slice(t).match(on);if(o){let c=U(o[0]);if(c!==o[0]){if(!e){let i=u.push("text_special","",0);i.content=c,i.markup=o[0],i.info="entity"}return u.pos+=o[0].length,!0}}}return!1}function X0(u){let e={},t=u.length;if(!t)return;let r=0,n=-2,o=[];for(let c=0;ca;s-=o[s]+1){let f=u[s];if(f.marker===i.marker&&f.open&&f.end<0){let h=!1;if((f.close||i.open)&&(f.length+i.length)%3===0&&(f.length%3!==0||i.length%3!==0)&&(h=!0),!h){let p=s>0&&!u[s-1].open?o[s-1]+1:0;o[c]=c-s+p,o[s]=p,i.open=!1,f.end=c,f.close=!1,l=-1,n=-2;break}}}l!==-1&&(e[i.marker][(i.open?3:0)+(i.length||0)%3]=l)}}function Je(u){let e=u.tokens_meta,t=u.tokens_meta.length;X0(u.delimiters);for(let r=0;r0&&r++,n[e].type==="text"&&e+1=u.pos)throw new Error("inline rule didn't increment state.pos");break}}else u.pos=u.posMax;c||u.pos++,o[e]=u.pos};fu.prototype.tokenize=function(u){let e=this.ruler.getRules(""),t=e.length,r=u.posMax,n=u.md.options.maxNesting;for(;u.pos=u.pos)throw new Error("inline rule didn't increment state.pos");break}}if(c){if(u.pos>=r)break;continue}u.pending+=u.src[u.pos++]}u.pending&&u.pushPending()};fu.prototype.parse=function(u,e,t,r){let n=new this.State(u,e,t,r);this.tokenize(n);let o=this.ruler2.getRules(""),c=o.length;for(let i=0;i|$))",e.tpl_email_fuzzy="(^|"+t+'|"|\\(|'+e.src_ZCc+")("+e.src_email_name+"@"+e.tpl_host_fuzzy_strict+")",e.tpl_link_fuzzy="(^|(?![.:/\\-_@])(?:[$+<=>^`|\uFF5C]|"+e.src_ZPCc+"))((?![$+<=>^`|\uFF5C])"+e.tpl_host_port_fuzzy_strict+e.src_path+")",e.tpl_link_no_ip_fuzzy="(^|(?![.:/\\-_@])(?:[$+<=>^`|\uFF5C]|"+e.src_ZPCc+"))((?![$+<=>^`|\uFF5C])"+e.tpl_host_port_no_ip_fuzzy_strict+e.src_path+")",e}function u0(u){return Array.prototype.slice.call(arguments,1).forEach(function(t){t&&Object.keys(t).forEach(function(r){u[r]=t[r]})}),u}function Mu(u){return Object.prototype.toString.call(u)}function cn(u){return Mu(u)==="[object String]"}function an(u){return Mu(u)==="[object Object]"}function sn(u){return Mu(u)==="[object RegExp]"}function ut(u){return Mu(u)==="[object Function]"}function ln(u){return u.replace(/[.?*+^$[\]\\(){}|-]/g,"\\$&")}var tt={fuzzyLink:!0,fuzzyEmail:!0,fuzzyIP:!1};function dn(u){return Object.keys(u||{}).reduce(function(e,t){return e||tt.hasOwnProperty(t)},!1)}var fn={"http:":{validate:function(u,e,t){let r=u.slice(e);return t.re.http||(t.re.http=new RegExp("^\\/\\/"+t.re.src_auth+t.re.src_host_port_strict+t.re.src_path,"i")),t.re.http.test(r)?r.match(t.re.http)[0].length:0}},"https:":"http:","ftp:":"http:","//":{validate:function(u,e,t){let r=u.slice(e);return t.re.no_http||(t.re.no_http=new RegExp("^"+t.re.src_auth+"(?:localhost|(?:(?:"+t.re.src_domain+")\\.)+"+t.re.src_domain_root+")"+t.re.src_port+t.re.src_host_terminator+t.re.src_path,"i")),t.re.no_http.test(r)?e>=3&&u[e-3]===":"||e>=3&&u[e-3]==="/"?0:r.match(t.re.no_http)[0].length:0}},"mailto:":{validate:function(u,e,t){let r=u.slice(e);return t.re.mailto||(t.re.mailto=new RegExp("^"+t.re.src_email_name+"@"+t.re.src_host_strict,"i")),t.re.mailto.test(r)?r.match(t.re.mailto)[0].length:0}}},pn="a[cdefgilmnoqrstuwxz]|b[abdefghijmnorstvwyz]|c[acdfghiklmnoruvwxyz]|d[ejkmoz]|e[cegrstu]|f[ijkmor]|g[abdefghilmnpqrstuwy]|h[kmnrtu]|i[delmnoqrst]|j[emop]|k[eghimnprwyz]|l[abcikrstuvy]|m[acdeghklmnopqrstuvwxyz]|n[acefgilopruz]|om|p[aefghklmnrstwy]|qa|r[eosuw]|s[abcdeghijklmnortuvxyz]|t[cdfghjklmnortvwz]|u[agksyz]|v[aceginu]|w[fs]|y[et]|z[amw]",hn="biz|com|edu|gov|net|org|pro|web|xxx|aero|asia|coop|info|museum|name|shop|\u0440\u0444".split("|");function bn(u){u.__index__=-1,u.__text_cache__=""}function mn(u){return function(e,t){let r=e.slice(t);return u.test(r)?r.match(u)[0].length:0}}function et(){return function(u,e){e.normalize(u)}}function Lu(u){let e=u.re=Y0(u.__opts__),t=u.__tlds__.slice();u.onCompile(),u.__tlds_replaced__||t.push(pn),t.push(e.src_xn),e.src_tlds=t.join("|");function r(i){return i.replace("%TLDS%",e.src_tlds)}e.email_fuzzy=RegExp(r(e.tpl_email_fuzzy),"i"),e.link_fuzzy=RegExp(r(e.tpl_link_fuzzy),"i"),e.link_no_ip_fuzzy=RegExp(r(e.tpl_link_no_ip_fuzzy),"i"),e.host_fuzzy_test=RegExp(r(e.tpl_host_fuzzy_test),"i");let n=[];u.__compiled__={};function o(i,a){throw new Error('(LinkifyIt) Invalid schema "'+i+'": '+a)}Object.keys(u.__schemas__).forEach(function(i){let a=u.__schemas__[i];if(a===null)return;let s={validate:null,link:null};if(u.__compiled__[i]=s,an(a)){sn(a.validate)?s.validate=mn(a.validate):ut(a.validate)?s.validate=a.validate:o(i,a),ut(a.normalize)?s.normalize=a.normalize:a.normalize?o(i,a):s.normalize=et();return}if(cn(a)){n.push(i);return}o(i,a)}),n.forEach(function(i){u.__compiled__[u.__schemas__[i]]&&(u.__compiled__[i].validate=u.__compiled__[u.__schemas__[i]].validate,u.__compiled__[i].normalize=u.__compiled__[u.__schemas__[i]].normalize)}),u.__compiled__[""]={validate:null,normalize:et()};let c=Object.keys(u.__compiled__).filter(function(i){return i.length>0&&u.__compiled__[i]}).map(ln).join("|");u.re.schema_test=RegExp("(^|(?!_)(?:[><\uFF5C]|"+e.src_ZPCc+"))("+c+")","i"),u.re.schema_search=RegExp("(^|(?!_)(?:[><\uFF5C]|"+e.src_ZPCc+"))("+c+")","ig"),u.re.schema_at_start=RegExp("^"+u.re.schema_search.source,"i"),u.re.pretest=RegExp("("+u.re.schema_test.source+")|("+u.re.host_fuzzy_test.source+")|@","i"),bn(u)}function xn(u,e){let t=u.__index__,r=u.__last_index__,n=u.__text_cache__.slice(t,r);this.schema=u.__schema__.toLowerCase(),this.index=t+e,this.lastIndex=r+e,this.raw=n,this.text=n,this.url=n}function e0(u,e){let t=new xn(u,e);return u.__compiled__[t.schema].normalize(t,u),t}function v(u,e){if(!(this instanceof v))return new v(u,e);e||dn(u)&&(e=u,u={}),this.__opts__=u0({},tt,e),this.__index__=-1,this.__last_index__=-1,this.__schema__="",this.__text_cache__="",this.__schemas__=u0({},fn,u),this.__compiled__={},this.__tlds__=hn,this.__tlds_replaced__=!1,this.re={},Lu(this)}v.prototype.add=function(e,t){return this.__schemas__[e]=t,Lu(this),this};v.prototype.set=function(e){return this.__opts__=u0(this.__opts__,e),this};v.prototype.test=function(e){if(this.__text_cache__=e,this.__index__=-1,!e.length)return!1;let t,r,n,o,c,i,a,s,l;if(this.re.schema_test.test(e)){for(a=this.re.schema_search,a.lastIndex=0;(t=a.exec(e))!==null;)if(o=this.testSchemaAt(e,t[2],a.lastIndex),o){this.__schema__=t[2],this.__index__=t.index+t[1].length,this.__last_index__=t.index+t[0].length+o;break}}return this.__opts__.fuzzyLink&&this.__compiled__["http:"]&&(s=e.search(this.re.host_fuzzy_test),s>=0&&(this.__index__<0||s=0&&(n=e.match(this.re.email_fuzzy))!==null&&(c=n.index+n[1].length,i=n.index+n[0].length,(this.__index__<0||cthis.__last_index__)&&(this.__schema__="mailto:",this.__index__=c,this.__last_index__=i))),this.__index__>=0};v.prototype.pretest=function(e){return this.re.pretest.test(e)};v.prototype.testSchemaAt=function(e,t,r){return this.__compiled__[t.toLowerCase()]?this.__compiled__[t.toLowerCase()].validate(e,r,this):0};v.prototype.match=function(e){let t=[],r=0;this.__index__>=0&&this.__text_cache__===e&&(t.push(e0(this,r)),r=this.__last_index__);let n=r?e.slice(r):e;for(;this.test(n);)t.push(e0(this,r)),n=n.slice(this.__last_index__),r+=this.__last_index__;return t.length?t:null};v.prototype.matchAtStart=function(e){if(this.__text_cache__=e,this.__index__=-1,!e.length)return null;let t=this.re.schema_at_start.exec(e);if(!t)return null;let r=this.testSchemaAt(e,t[2],t[0].length);return r?(this.__schema__=t[2],this.__index__=t.index+t[1].length,this.__last_index__=t.index+t[0].length+r,e0(this,0)):null};v.prototype.tlds=function(e,t){return e=Array.isArray(e)?e:[e],t?(this.__tlds__=this.__tlds__.concat(e).sort().filter(function(r,n,o){return r!==o[n-1]}).reverse(),Lu(this),this):(this.__tlds__=e.slice(),this.__tlds_replaced__=!0,Lu(this),this)};v.prototype.normalize=function(e){e.schema||(e.url="http://"+e.url),e.schema==="mailto:"&&!/^mailto:/i.test(e.url)&&(e.url="mailto:"+e.url)};v.prototype.onCompile=function(){};var rt=v;var gn=/^xn--/,kn=/[^\0-\x7F]/,yn=/[\x2E\u3002\uFF0E\uFF61]/g,_n={overflow:"Overflow: input needs wider integers to process","not-basic":"Illegal input >= 0x80 (not a basic code point)","invalid-input":"Invalid input"},t0=35,N=Math.floor,r0=String.fromCharCode;function O(u){throw new RangeError(_n[u])}function Cn(u,e){let t=[],r=u.length;for(;r--;)t[r]=e(u[r]);return t}function ot(u,e){let t=u.split("@"),r="";t.length>1&&(r=t[0]+"@",u=t[1]),u=u.replace(yn,".");let n=u.split("."),o=Cn(n,e).join(".");return r+o}function it(u){let e=[],t=0,r=u.length;for(;t=55296&&n<=56319&&tString.fromCodePoint(...u),En=function(u){return u>=48&&u<58?26+(u-48):u>=65&&u<91?u-65:u>=97&&u<123?u-97:36},nt=function(u,e){return u+22+75*(u<26)-((e!=0)<<5)},ct=function(u,e,t){let r=0;for(u=t?N(u/700):u>>1,u+=N(u/e);u>t0*26>>1;r+=36)u=N(u/t0);return N(r+(t0+1)*u/(u+38))},at=function(u){let e=[],t=u.length,r=0,n=128,o=72,c=u.lastIndexOf("-");c<0&&(c=0);for(let i=0;i=128&&O("not-basic"),e.push(u.charCodeAt(i));for(let i=c>0?c+1:0;i=t&&O("invalid-input");let h=En(u.charCodeAt(i++));h>=36&&O("invalid-input"),h>N((2147483647-r)/l)&&O("overflow"),r+=h*l;let p=f<=o?1:f>=o+26?26:f-o;if(hN(2147483647/d)&&O("overflow"),l*=d}let s=e.length+1;o=ct(r-a,s,a==0),N(r/s)>2147483647-n&&O("overflow"),n+=N(r/s),r%=s,e.splice(r++,0,n)}return String.fromCodePoint(...e)},st=function(u){let e=[];u=it(u);let t=u.length,r=128,n=0,o=72;for(let a of u)a<128&&e.push(r0(a));let c=e.length,i=c;for(c&&e.push("-");i=r&&lN((2147483647-n)/s)&&O("overflow"),n+=(a-r)*s,r=a;for(let l of u)if(l2147483647&&O("overflow"),l===r){let f=n;for(let h=36;;h+=36){let p=h<=o?1:h>=o+26?26:h-o;if(f=0))try{e.hostname=n0.toASCII(e.hostname)}catch{}return ku(X(e))}function Ln(u){let e=su(u,!0);if(e.hostname&&(!e.protocol||pt.indexOf(e.protocol)>=0))try{e.hostname=n0.toUnicode(e.hostname)}catch{}return au(X(e),au.defaultChars+"%")}function S(u,e){if(!(this instanceof S))return new S(u,e);e||wu(u)||(e=u||{},u="default"),this.inline=new Q0,this.block=new Z0,this.core=new q0,this.renderer=new R0,this.linkify=new rt,this.validateLink=Rn,this.normalizeLink=Pn,this.normalizeLinkText=Ln,this.utils=de,this.helpers=Y({},be),this.options={},this.configure(u),e&&this.set(e)}S.prototype.set=function(u){return Y(this.options,u),this};S.prototype.configure=function(u){let e=this;if(wu(u)){let t=u;if(u=wn[t],!u)throw new Error('Wrong `markdown-it` preset "'+t+'", check name')}if(!u)throw new Error("Wrong `markdown-it` preset, can't be empty");return u.options&&e.set(u.options),u.components&&Object.keys(u.components).forEach(function(t){u.components[t].rules&&e[t].ruler.enableOnly(u.components[t].rules),u.components[t].rules2&&e[t].ruler2.enableOnly(u.components[t].rules2)}),this};S.prototype.enable=function(u,e){let t=[];Array.isArray(u)||(u=[u]),["core","block","inline"].forEach(function(n){t=t.concat(this[n].ruler.enable(u,!0))},this),t=t.concat(this.inline.ruler2.enable(u,!0));let r=u.filter(function(n){return t.indexOf(n)<0});if(r.length&&!e)throw new Error("MarkdownIt. Failed to enable unknown rule(s): "+r);return this};S.prototype.disable=function(u,e){let t=[];Array.isArray(u)||(u=[u]),["core","block","inline"].forEach(function(n){t=t.concat(this[n].ruler.disable(u,!0))},this),t=t.concat(this.inline.ruler2.disable(u,!0));let r=u.filter(function(n){return t.indexOf(n)<0});if(r.length&&!e)throw new Error("MarkdownIt. Failed to disable unknown rule(s): "+r);return this};S.prototype.use=function(u){let e=[this].concat(Array.prototype.slice.call(arguments,1));return u.apply(u,e),this};S.prototype.parse=function(u,e){if(typeof u!="string")throw new Error("Input data should be a String");let t=new this.core.State(u,this,e);return this.core.process(t),t.tokens};S.prototype.render=function(u,e){return e=e||{},this.renderer.render(this.parse(u,e),this.options,e)};S.prototype.parseInline=function(u,e){let t=new this.core.State(u,this,e);return t.inlineMode=!0,this.core.process(t),t.tokens};S.prototype.renderInline=function(u,e){return e=e||{},this.renderer.render(this.parseInline(u,e),this.options,e)};var o0=S;function ru(u){let e=2166136261;for(let t=0;t>>0).toString(16).padStart(8,"0")}function Nu(u){let e=u.trim();if(!e)throw new Error("Card key cannot be empty.");return e}var Iu=class{create(e,t){return Nu(ru([e.filePath,String(e.headingLevel),e.headingText,String(e.blockStartLine),t].join("|")))}};function qu(u){let e=u.trim();if(!e)throw new Error("Content hash cannot be empty.");return e}function ht(u){let e=u.trim();if(!e)throw new Error("Note model name cannot be empty.");return e}var zu=class{resolve(e,t){let r=e?.trim()||t.trim();if(!r)throw new Error("A card deck could not be resolved.");return bu(r)}};var bt=new o0({breaks:!0,html:!0,linkify:!0}),Mn=/`[^`\n]+`/g,Nn=/```[\s\S]*?```|~~~[\s\S]*?~~~/g,In=/(?Open in Obsidian

`:"",a=e.type==="basic"?{kind:"basic",values:{front:o.html,back:c.html+i}}:{kind:"cloze",values:{text:c.html,extra:o.html+i}},s={...a.values},l=qu(ru(JSON.stringify({deck:r,fields:s,heading:e.heading,noteModel:n,sourcePath:e.source.filePath,type:e.type})));return{key:this.identityPolicy.create(e.source,e.type),source:e.source,type:e.type,heading:e.heading,bodyMarkdown:e.bodyMarkdown,deck:r,noteModel:n,tags:[],renderedFields:a,fields:s,contentHash:l,media:On([...o.media,...c.media])}}renderMarkdown(e,t,r,n,o){let c=i0(e,Nn,"FENCED_CODE"),i=i0(c.text,Mn,"INLINE_CODE"),a=i.text,s=[],l=1;a=a.replace(In,(p,d)=>`\\[${d.trim()}\\]`),a=a.replace(qn,(p,d)=>`\\(${d.trim()}\\)`),n&&r.convertHighlightsToCloze&&(a=a.replace(mt,"{$1}")),n&&(a=a.replace(Bn,(p,d,b)=>`{{c${d?Number(d):l++}::${b}}}`)),a=a.replace(Hn,(p,d)=>{let b=r.resourceResolver.resolveEmbed(d,t.source.filePath);return b?(s.push({kind:b.kind,fileName:b.fileName,absolutePath:b.absolutePath,altText:b.altText}),b.kind==="audio"?`[sound:${b.fileName}]`:`${nu(b.altText??b.fileName)}`):p}),a=a.replace(Un,(p,d)=>{let b=r.resourceResolver.resolveWikiLink(d,t.source.filePath);return b?`${nu(b.displayText)}`:p}),a=a.replace(mt,"$1");let f=i0(a,zn,"MATH");a=f.text,a=i.restore(a),a=c.restore(a);let h=(o?bt.renderInline(a):bt.render(a)).trim();return{html:f.restore(h,nu),media:s}}};function i0(u,e,t){let r=[];return{text:u.replace(e,o=>{let c=`@@${t}_${r.length}@@`;return r.push(o),c}),restore:(o,c=i=>i)=>r.reduce((i,a,s)=>i.split(`@@${t}_${s}@@`).join(c(a)),o)}}function On(u){let e=new Set;return u.filter(t=>{let r=`${t.kind}:${t.absolutePath}:${t.fileName}`;return e.has(r)?!1:(e.add(r),!0)})}function nu(u){return u.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}var Hu=class{plan(e,t,r){let n=new Set,o=new Map,c=[],i=[];for(let l of e){if(n.has(l.key))throw new Error(`Duplicate card key detected in current scan: ${l.key}`);n.add(l.key);let f=t.get(l.key);if(!f){c.push(l),o.set(l.deck,l.deck);continue}(f.sourceHash!==l.contentHash||f.orphan)&&(i.push({card:l,noteId:f.noteId}),o.set(l.deck,l.deck))}let a=new Set(r),s=t.list().filter(l=>a.has(l.filePath)&&!l.orphan&&!n.has(l.cardKey));return{toCreateDecks:Array.from(o.values()),toAdd:c,toUpdate:i,toMarkOrphan:s}}};var Uu=class{constructor(e,t,r=new hu,n=new mu,o=new Bu,c=new Hu){this.vaultGateway=e;this.syncRegistryRepository=t;this.scanScopeService=r;this.cardExtractionService=n;this.cardRenderingService=o;this.syncPlanningService=c}async executeForVault(e){J(e);let t=await this.vaultGateway.listMarkdownFiles(),r=this.scanScopeService.filter(t,e.includeFolders,e.excludeFolders);return this.scanFiles(r,e)}async executeForFile(e,t){J(t);let r=await this.vaultGateway.getMarkdownFile(e);if(!r)throw new Error(`Markdown file not found: ${e}`);return this.scanFiles([r],t)}async scanFiles(e,t){let r=e.flatMap(i=>this.cardExtractionService.extract(i,{qaHeadingLevel:t.qaHeadingLevel,clozeHeadingLevel:t.clozeHeadingLevel}).map(a=>this.cardRenderingService.render(a,{defaultDeck:t.defaultDeck,qaNoteType:t.qaNoteType,clozeNoteType:t.clozeNoteType,addObsidianBacklink:t.addObsidianBacklink,convertHighlightsToCloze:t.convertHighlightsToCloze,resourceResolver:this.vaultGateway}))),n=await this.syncRegistryRepository.load(),o=e.map(i=>i.path),c=this.syncPlanningService.plan(r,n,o);return{cards:r,registry:n,plan:c,scopedFilePaths:o}}};var $u=class{map(e,t){return e.type==="basic"?this.mapBasic(e,t.fieldNames):this.mapCloze(e,t)}mapBasic(e,t){let r=Ou(t,["Front"])??t[0],n=Ou(t,["Back"])??t[1];if(!r||!n)throw new Error(`Basic note model ${e.noteModel} must expose at least two fields.`);return{[r]:e.fields.front,[n]:e.fields.back}}mapCloze(e,t){if(!t.isCloze)throw new Error(`Cloze card ${e.key} must target a cloze-compatible note model.`);let r=Ou(t.fieldNames,["Text"])??t.fieldNames[0],n=Ou(t.fieldNames,["Extra","Context"])??t.fieldNames[1];if(!r||!n)throw new Error(`Cloze note model ${e.noteModel} must expose text and auxiliary fields.`);return{[r]:e.fields.text,[n]:e.fields.extra}}};function Ou(u,e){return u.find(t=>e.some(r=>r.toLowerCase()===t.toLowerCase()))}var ou=class{constructor(e=[]){this.recordsByCardKey=new Map;this.cardKeysByNoteId=new Map;for(let t of e)this.upsert(t)}get(e){return this.recordsByCardKey.get(e)}findByNoteId(e){let t=this.cardKeysByNoteId.get(e);return t?this.recordsByCardKey.get(t):void 0}list(){return Array.from(this.recordsByCardKey.values())}upsert(e){let t=this.cardKeysByNoteId.get(e.noteId);if(t&&t!==e.cardKey)throw new Error(`Note ${e.noteId} is already assigned to another card key.`);let r=this.recordsByCardKey.get(e.cardKey);r&&r.noteId!==e.noteId&&this.cardKeysByNoteId.delete(r.noteId),this.recordsByCardKey.set(e.cardKey,e),this.cardKeysByNoteId.set(e.noteId,e.cardKey)}};var Vu=class{constructor(e,t,r=new $u,n=()=>Date.now()){this.ankiGateway=e;this.syncRegistryRepository=t;this.noteFieldMappingService=r;this.now=n}async execute(e){let t=new ou(e.registry.list()),r=new Map,n=this.now();for(let a of e.plan.toCreateDecks)await this.ankiGateway.ensureDeckExists(a);let o=[...e.plan.toAdd,...e.plan.toUpdate.map(a=>a.card)],c=await this.uploadMedia(o);for(let a of e.plan.toAdd){let s=await this.getModelDetails(r,a.noteModel),l=await this.ankiGateway.addNote({deckName:a.deck,modelName:a.noteModel,fields:this.noteFieldMappingService.map(a,s),tags:a.tags});t.upsert({cardKey:a.key,noteId:l,filePath:a.source.filePath,sourceHash:a.contentHash,lastSyncedAt:n,orphan:!1})}for(let a of e.plan.toUpdate){let s=await this.getModelDetails(r,a.card.noteModel);await this.ankiGateway.updateNote({noteId:a.noteId,deckName:a.card.deck,fields:this.noteFieldMappingService.map(a.card,s)}),t.upsert({cardKey:a.card.key,noteId:a.noteId,filePath:a.card.source.filePath,sourceHash:a.card.contentHash,lastSyncedAt:n,orphan:!1})}let i=new Set(o.map(a=>a.key));for(let a of e.cards){if(i.has(a.key))continue;let s=t.get(a.key);s&&t.upsert({...s,filePath:a.source.filePath,sourceHash:a.contentHash,lastSyncedAt:n,orphan:!1})}for(let a of e.plan.toMarkOrphan)t.upsert({...a,lastSyncedAt:n,orphan:!0});return await this.syncRegistryRepository.save(t),{created:e.plan.toAdd.length,updated:e.plan.toUpdate.length,markedOrphan:e.plan.toMarkOrphan.length,uploadedMedia:c,scanned:e.cards.length,unchanged:e.cards.length-e.plan.toAdd.length-e.plan.toUpdate.length}}async getModelDetails(e,t){let r=e.get(t);if(r)return r;let n=await this.ankiGateway.getModelDetails(t);return e.set(t,n),n}async uploadMedia(e){let t=new Map;for(let r of e)for(let n of r.media)t.set(`${n.kind}:${n.absolutePath}:${n.fileName}`,n);for(let r of t.values())await this.ankiGateway.storeMedia(r);return t.size}};var ju=class{constructor(e,t){this.scanAndPlanSyncUseCase=e;this.executeSyncPlanUseCase=t}async execute(e,t){let r=await this.scanAndPlanSyncUseCase.executeForFile(e,t);return this.executeSyncPlanUseCase.execute(r)}};var Gu=class{constructor(e,t){this.scanAndPlanSyncUseCase=e;this.executeSyncPlanUseCase=t}async execute(e){let t=await this.scanAndPlanSyncUseCase.executeForVault(e);return this.executeSyncPlanUseCase.execute(t)}};var xt=require("obsidian"),Zu=class{constructor(e){this.getBaseUrl=e}async ensureDeckExists(e){await this.invoke("createDeck",{deck:e})}async getModelDetails(e){let t=await this.invoke("modelFieldNames",{modelName:e}),r=e.toLowerCase().includes("cloze");try{let n=await this.invoke("modelTemplates",{modelName:e});r=r||Object.keys(n).some(o=>o.toLowerCase().includes("cloze"))}catch{r=r||t.some(n=>n.toLowerCase()==="text")}return{fieldNames:t,isCloze:r}}async addNote(e){return this.invoke("addNote",{note:{deckName:e.deckName,modelName:e.modelName,fields:e.fields,options:{allowDuplicate:!1,duplicateScope:"deck"},tags:e.tags}})}async updateNote(e){await this.invoke("updateNoteFields",{note:{id:e.noteId,fields:e.fields}});let r=(await this.invoke("notesInfo",{notes:[e.noteId]}))[0]?.cards??[];r.length>0&&await this.invoke("changeDeck",{cards:r,deck:e.deckName})}async storeMedia(e){await this.invoke("storeMediaFile",{filename:e.fileName,path:e.absolutePath})}async invoke(e,t){let n=(await(0,xt.requestUrl)({url:this.getBaseUrl(),method:"POST",contentType:"application/json",body:JSON.stringify({action:e,version:6,params:t})})).json;if(n.error)throw new Error(n.error);return n.result}};var Ku=class{constructor(e){this.plugin=e}async load(){return await this.plugin.loadData()??null}async save(e){await this.plugin.saveData(e)}};var c0=require("obsidian");var $n=new Set(["png","jpg","jpeg","gif","bmp","svg","webp","tiff"]),Vn=new Set(["wav","m4a","flac","mp3","wma","aac","webm","ogg"]),Ju=class{constructor(e){this.app=e}async listMarkdownFiles(){let e=this.app.vault.getMarkdownFiles();return Promise.all(e.map(t=>this.toSourceFile(t)))}async getMarkdownFile(e){let t=this.app.vault.getAbstractFileByPath(e);return!(t instanceof c0.TFile)||t.extension.toLowerCase()!=="md"?null:this.toSourceFile(t)}resolveWikiLink(e,t){let{alias:r,linkPath:n}=gt(e),c=this.app.metadataCache.getFirstLinkpathDest(Wu(n),t)?.path??Wu(n),i=r||jn(n);return{url:this.createObsidianUrl(n.includes("#")?`${c}${n.slice(n.indexOf("#"))}`:c),displayText:i}}resolveEmbed(e,t){let{alias:r,linkPath:n}=gt(e),o=this.app.metadataCache.getFirstLinkpathDest(Wu(n),t);if(!(o instanceof c0.TFile))return null;let c=o.extension.toLowerCase(),i=$n.has(c)?"image":Vn.has(c)?"audio":null;if(!i)return null;let a=Gn(this.app,o.path),s=`${ru(o.path)}-${o.name}`;return{kind:i,fileName:s,absolutePath:a,altText:r||o.name}}createBacklink(e){return this.createObsidianUrl(`${e.filePath}#${e.headingText}`)}async toSourceFile(e){return{path:e.path,basename:e.basename,content:await this.app.vault.cachedRead(e)}}createObsidianUrl(e){return`obsidian://open?vault=${encodeURIComponent(this.app.vault.getName())}&file=${encodeURIComponent(e)}`}};function gt(u){let[e,t]=u.split("|",2).map(r=>r.trim());return{linkPath:e,alias:t||void 0}}function Wu(u){return u.split("#",1)[0]}function jn(u){let e=Wu(u);return(e.split("/").pop()??e).replace(/\.[^.]+$/,"")||u}function Gn(u,e){let t=u.vault.adapter;if(!t.getFullPath)throw new Error("The active vault adapter does not expose an absolute filesystem path.");return t.getFullPath(e)}var Xu=class{constructor(e){this.pluginDataStore=e}async load(){let e=await this.pluginDataStore.load()??{},t={...$,...e.settings,includeFolders:e.settings?.includeFolders??$.includeFolders,excludeFolders:e.settings?.excludeFolders??$.excludeFolders};return J(t),t}async save(e){J(e);let t=await this.pluginDataStore.load()??{};await this.pluginDataStore.save({...t,settings:e})}};var Qu=class{constructor(e){this.pluginDataStore=e}async load(){let t=(await this.pluginDataStore.load())?.syncRegistry?.records??[];return new ou(t.map(r=>({cardKey:Nu(r.cardKey),noteId:r.noteId,filePath:r.filePath,sourceHash:qu(r.sourceHash),lastSyncedAt:r.lastSyncedAt,orphan:r.orphan})))}async save(e){let t=await this.pluginDataStore.load()??{};await this.pluginDataStore.save({...t,syncRegistry:{records:e.list().map(r=>({cardKey:r.cardKey,noteId:r.noteId,filePath:r.filePath,sourceHash:r.sourceHash,lastSyncedAt:r.lastSyncedAt,orphan:r.orphan}))}})}};function kt(u){u.addCommand({id:"sync-current-file-to-anki",name:"Sync current file to Anki",callback:()=>{u.runSyncCurrentFile()}}),u.addCommand({id:"sync-vault-to-anki",name:"Sync vault to Anki",callback:()=>{u.runSyncVault()}})}var a0=require("obsidian"),Yu=class{info(e){new a0.Notice(e,5e3)}error(e){new a0.Notice(e,8e3)}showSyncSummary(e,t){this.info(`${e}: scanned ${t.scanned}, created ${t.created}, updated ${t.updated}, orphaned ${t.markedOrphan}, media ${t.uploadedMedia}.`)}};var w=require("obsidian"),ue=class extends w.PluginSettingTab{constructor(e){super(e.app,e),this.plugin=e}display(){let{containerEl:e}=this,t=this.plugin.settings;e.empty(),e.createEl("h2",{text:"Anki Heading Sync"}),new w.Setting(e).setName("AnkiConnect URL").setDesc("Default is http://127.0.0.1:8765").addText(r=>{r.setPlaceholder("http://127.0.0.1:8765").setValue(t.ankiConnectUrl).onChange(n=>{this.plugin.updateSettings({ankiConnectUrl:n.trim()||t.ankiConnectUrl})})}),new w.Setting(e).setName("Default deck").setDesc("Used when a file does not define TARGET DECK").addText(r=>{r.setValue(t.defaultDeck).onChange(n=>{this.plugin.updateSettings({defaultDeck:n})})}),new w.Setting(e).setName("QA heading level").setDesc("Default is H4").addDropdown(r=>{for(let n=1;n<=6;n+=1)r.addOption(String(n),`H${n}`);r.setValue(String(t.qaHeadingLevel)).onChange(n=>{this.plugin.updateSettings({qaHeadingLevel:Number(n)})})}),new w.Setting(e).setName("Cloze heading level").setDesc("Default is H5").addDropdown(r=>{for(let n=1;n<=6;n+=1)r.addOption(String(n),`H${n}`);r.setValue(String(t.clozeHeadingLevel)).onChange(n=>{this.plugin.updateSettings({clozeHeadingLevel:Number(n)})})}),new w.Setting(e).setName("QA note type").setDesc("Default is Basic").addText(r=>{r.setValue(t.qaNoteType).onChange(n=>{this.plugin.updateSettings({qaNoteType:n})})}),new w.Setting(e).setName("Cloze note type").setDesc("Default is Cloze").addText(r=>{r.setValue(t.clozeNoteType).onChange(n=>{this.plugin.updateSettings({clozeNoteType:n})})}),new w.Setting(e).setName("Include folders").setDesc("Comma-separated folder paths. Empty means scan the whole vault.").addTextArea(r=>{r.setValue(t.includeFolders.join(", ")).onChange(n=>{this.plugin.updateSettings({includeFolders:yt(n)})})}),new w.Setting(e).setName("Exclude folders").setDesc("Comma-separated folder paths always filtered out of vault sync.").addTextArea(r=>{r.setValue(t.excludeFolders.join(", ")).onChange(n=>{this.plugin.updateSettings({excludeFolders:yt(n)})})}),new w.Setting(e).setName("Add Obsidian backlink").setDesc("Append a backlink to the source heading into synced cards.").addToggle(r=>{r.setValue(t.addObsidianBacklink).onChange(n=>{this.plugin.updateSettings({addObsidianBacklink:n})})}),new w.Setting(e).setName("Highlights to Cloze").setDesc("Convert ==highlight== segments into cloze deletions for cloze cards.").addToggle(r=>{r.setValue(t.convertHighlightsToCloze).onChange(n=>{this.plugin.updateSettings({convertHighlightsToCloze:n})})})}};function yt(u){return u.split(",").map(e=>e.trim()).filter(Boolean)}var pu=class extends _t.Plugin{constructor(){super(...arguments);this.settings=$;this.noticeService=new Yu}async onload(){let t=new Ku(this);this.pluginConfigRepository=new Xu(t);let r=new Qu(t),n=new Ju(this.app),o=new Zu(()=>this.settings.ankiConnectUrl);try{this.settings=await this.pluginConfigRepository.load()}catch(a){console.error("Failed to load plugin settings, falling back to defaults.",a),this.settings=$,this.noticeService.error("Invalid plugin settings were detected. Default settings were restored in memory.")}let c=new Uu(n,r),i=new Vu(o,r);this.syncCurrentFileUseCase=new ju(c,i),this.syncVaultUseCase=new Gu(c,i),kt(this),this.addSettingTab(new ue(this))}async updateSettings(t){if(!this.pluginConfigRepository)return;let r={...this.settings,...t};try{await this.pluginConfigRepository.save(r),this.settings=r}catch(n){this.noticeService.error(n instanceof Error?n.message:"Failed to save plugin settings.")}}async runSyncCurrentFile(){let t=this.app.workspace.getActiveFile();if(!t||t.extension.toLowerCase()!=="md"){this.noticeService.error("No active Markdown file is available for sync.");return}if(!this.syncCurrentFileUseCase){this.noticeService.error("Sync use case is not initialized.");return}try{let r=await this.syncCurrentFileUseCase.execute(t.path,this.settings);this.noticeService.showSyncSummary("Current file sync finished",r)}catch(r){console.error("Current file sync failed.",r),this.noticeService.error(r instanceof Error?r.message:"Current file sync failed.")}}async runSyncVault(){if(!this.syncVaultUseCase){this.noticeService.error("Sync use case is not initialized.");return}try{let t=await this.syncVaultUseCase.execute(this.settings);this.noticeService.showSyncSummary("Vault sync finished",t)}catch(t){console.error("Vault sync failed.",t),this.noticeService.error(t instanceof Error?t.message:"Vault sync failed.")}}};var Zn=pu; diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..ba3e336 --- /dev/null +++ b/main.ts @@ -0,0 +1,3 @@ +import AnkiHeadingSyncPlugin from "./src/presentation/AnkiHeadingSyncPlugin"; + +export default AnkiHeadingSyncPlugin; \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..3f2ca9b --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "anki-heading-sync", + "name": "Anki Heading Sync", + "version": "1.0.0", + "minAppVersion": "1.5.0", + "description": "Focused heading-based Obsidian to Anki sync plugin.", + "author": "GitHub Copilot", + "authorUrl": "https://github.com/github/copilot", + "isDesktopOnly": true +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2c6c7f4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3428 @@ +{ + "name": "obsidian-anki-heading-sync", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "obsidian-anki-heading-sync", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "markdown-it": "^14.1.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/markdown-it": "^14.1.2", + "@types/node": "^22.19.17", + "@typescript-eslint/eslint-plugin": "^8.46.1", + "@typescript-eslint/parser": "^8.46.1", + "builtin-modules": "^4.0.0", + "esbuild": "^0.25.3", + "eslint": "^9.39.1", + "globals": "^16.4.0", + "obsidian": "latest", + "typescript": "^5.8.3", + "typescript-eslint": "^8.46.1", + "vitest": "^3.2.4" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.14", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.14", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/type-utils": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.2", + "@typescript-eslint/types": "^8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.2", + "@typescript-eslint/tsconfig-utils": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/builtin-modules": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.6", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.14", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "10.2.5", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/obsidian": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz", + "integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.58.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.2", + "@typescript-eslint/parser": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.27.7", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f290a8b --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "obsidian-anki-heading-sync", + "version": "1.0.0", + "description": "Focused Obsidian to Anki heading-based sync plugin.", + "main": "dist/plugin/main.js", + "dependencies": { + "markdown-it": "^14.1.0" + }, + "scripts": { + "dev": "node scripts/stage-plugin-dist.mjs && node esbuild.config.mjs", + "build": "tsc --noEmit --skipLibCheck && node scripts/stage-plugin-dist.mjs && node esbuild.config.mjs production", + "lint": "eslint \"src/**/*.ts\" \"main.ts\" \"vitest.config.ts\" \"esbuild.config.mjs\" \"eslint.config.mjs\"", + "test": "vitest run" + }, + "keywords": [ + "obsidian", + "anki", + "plugin" + ], + "license": "MIT", + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^22.15.3", + "@typescript-eslint/eslint-plugin": "^8.46.1", + "@typescript-eslint/parser": "^8.46.1", + "builtin-modules": "^4.0.0", + "esbuild": "^0.25.3", + "eslint": "^9.39.1", + "globals": "^16.4.0", + "obsidian": "latest", + "typescript": "^5.8.3", + "typescript-eslint": "^8.46.1", + "vitest": "^3.2.4" + } +} \ No newline at end of file diff --git a/scripts/stage-plugin-dist.mjs b/scripts/stage-plugin-dist.mjs new file mode 100644 index 0000000..740112b --- /dev/null +++ b/scripts/stage-plugin-dist.mjs @@ -0,0 +1,31 @@ +import { copyFile, mkdir, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const rootDir = resolve(scriptDir, ".."); +const distDir = join(rootDir, "dist"); +const pluginDistDir = join(distDir, "plugin"); + +await mkdir(pluginDistDir, { recursive: true }); +await rm(join(distDir, "main.js"), { force: true }); + +await Promise.all([ + copyFile(join(rootDir, "manifest.json"), join(pluginDistDir, "manifest.json")), + copyFile(join(rootDir, "versions.json"), join(pluginDistDir, "versions.json")), +]); + +await writeFile( + join(pluginDistDir, "README.md"), + [ + "Anki Heading Sync — Obsidian plugin distribution package", + "", + "Build output lives in this folder so it can be synced directly into an Obsidian plugin directory.", + "", + "Included files:", + "- manifest.json", + "- main.js", + "- versions.json", + ].join("\n"), + "utf8", +); \ No newline at end of file diff --git a/src/application/config/PluginSettings.test.ts b/src/application/config/PluginSettings.test.ts new file mode 100644 index 0000000..05dac37 --- /dev/null +++ b/src/application/config/PluginSettings.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_SETTINGS, validatePluginSettings } from "./PluginSettings"; + +describe("PluginSettings", () => { + it("accepts the default V1 settings", () => { + expect(() => validatePluginSettings(DEFAULT_SETTINGS)).not.toThrow(); + }); + + it("rejects equal QA and Cloze heading levels", () => { + expect(() => + validatePluginSettings({ + ...DEFAULT_SETTINGS, + qaHeadingLevel: 4, + clozeHeadingLevel: 4, + }), + ).toThrow("QA and Cloze heading levels must be different."); + }); +}); \ No newline at end of file diff --git a/src/application/config/PluginSettings.ts b/src/application/config/PluginSettings.ts new file mode 100644 index 0000000..62969d5 --- /dev/null +++ b/src/application/config/PluginSettings.ts @@ -0,0 +1,55 @@ +export interface PluginSettings { + qaHeadingLevel: number; + clozeHeadingLevel: number; + qaNoteType: string; + clozeNoteType: string; + defaultDeck: string; + includeFolders: string[]; + excludeFolders: string[]; + addObsidianBacklink: boolean; + convertHighlightsToCloze: boolean; + ankiConnectUrl: string; +} + +export const DEFAULT_SETTINGS: PluginSettings = { + qaHeadingLevel: 4, + clozeHeadingLevel: 5, + qaNoteType: "Basic", + clozeNoteType: "Cloze", + defaultDeck: "Obsidian", + includeFolders: [], + excludeFolders: [], + addObsidianBacklink: true, + convertHighlightsToCloze: true, + ankiConnectUrl: "http://127.0.0.1:8765", +}; + +export function validatePluginSettings(settings: PluginSettings): void { + const headingLevels = [settings.qaHeadingLevel, settings.clozeHeadingLevel]; + + for (const level of headingLevels) { + if (!Number.isInteger(level) || level < 1 || level > 6) { + throw new Error("Heading levels must be integers between 1 and 6."); + } + } + + if (settings.qaHeadingLevel === settings.clozeHeadingLevel) { + throw new Error("QA and Cloze heading levels must be different."); + } + + if (!settings.qaNoteType.trim()) { + throw new Error("QA note type is required."); + } + + if (!settings.clozeNoteType.trim()) { + throw new Error("Cloze note type is required."); + } + + if (!settings.defaultDeck.trim()) { + throw new Error("Default deck is required."); + } + + if (!settings.ankiConnectUrl.trim()) { + throw new Error("AnkiConnect URL is required."); + } +} \ No newline at end of file diff --git a/src/application/dto/NoteModelDetails.ts b/src/application/dto/NoteModelDetails.ts new file mode 100644 index 0000000..f9a6e20 --- /dev/null +++ b/src/application/dto/NoteModelDetails.ts @@ -0,0 +1,4 @@ +export interface NoteModelDetails { + fieldNames: string[]; + isCloze: boolean; +} \ No newline at end of file diff --git a/src/application/ports/AnkiGateway.ts b/src/application/ports/AnkiGateway.ts new file mode 100644 index 0000000..0a0554d --- /dev/null +++ b/src/application/ports/AnkiGateway.ts @@ -0,0 +1,23 @@ +import type { MediaAsset } from "@/domain/card/entities/RenderedFields"; +import type { NoteModelDetails } from "@/application/dto/NoteModelDetails"; + +export interface AddAnkiNoteInput { + deckName: string; + modelName: string; + fields: Record; + tags: string[]; +} + +export interface UpdateAnkiNoteInput { + noteId: number; + deckName: string; + fields: Record; +} + +export interface AnkiGateway { + ensureDeckExists(deckName: string): Promise; + getModelDetails(modelName: string): Promise; + addNote(input: AddAnkiNoteInput): Promise; + updateNote(input: UpdateAnkiNoteInput): Promise; + storeMedia(asset: MediaAsset): Promise; +} \ No newline at end of file diff --git a/src/application/ports/PluginConfigRepository.ts b/src/application/ports/PluginConfigRepository.ts new file mode 100644 index 0000000..92ab1b3 --- /dev/null +++ b/src/application/ports/PluginConfigRepository.ts @@ -0,0 +1,6 @@ +import type { PluginSettings } from "../config/PluginSettings"; + +export interface PluginConfigRepository { + load(): Promise; + save(settings: PluginSettings): Promise; +} \ No newline at end of file diff --git a/src/application/ports/PluginDataStore.ts b/src/application/ports/PluginDataStore.ts new file mode 100644 index 0000000..e28a308 --- /dev/null +++ b/src/application/ports/PluginDataStore.ts @@ -0,0 +1,4 @@ +export interface PluginDataStore { + load(): Promise; + save(data: TData): Promise; +} \ No newline at end of file diff --git a/src/application/ports/RenderResourceResolver.ts b/src/application/ports/RenderResourceResolver.ts new file mode 100644 index 0000000..aa1ff7f --- /dev/null +++ b/src/application/ports/RenderResourceResolver.ts @@ -0,0 +1,5 @@ +export type { + RenderResourceResolver, + ResolvedEmbed, + ResolvedWikiLink, +} from "@/domain/card/ports/RenderResourceResolver"; \ No newline at end of file diff --git a/src/application/ports/SyncRegistryRepository.ts b/src/application/ports/SyncRegistryRepository.ts new file mode 100644 index 0000000..38119f2 --- /dev/null +++ b/src/application/ports/SyncRegistryRepository.ts @@ -0,0 +1,6 @@ +import type { SyncRegistry } from "@/domain/sync/entities/SyncRegistry"; + +export interface SyncRegistryRepository { + load(): Promise; + save(registry: SyncRegistry): Promise; +} \ No newline at end of file diff --git a/src/application/ports/VaultGateway.ts b/src/application/ports/VaultGateway.ts new file mode 100644 index 0000000..e654ed2 --- /dev/null +++ b/src/application/ports/VaultGateway.ts @@ -0,0 +1,7 @@ +import type { SourceFile } from "@/domain/card/entities/SourceFile"; +import type { RenderResourceResolver } from "@/domain/card/ports/RenderResourceResolver"; + +export interface VaultGateway extends RenderResourceResolver { + listMarkdownFiles(): Promise; + getMarkdownFile(path: string): Promise; +} \ No newline at end of file diff --git a/src/application/services/NoteFieldMappingService.test.ts b/src/application/services/NoteFieldMappingService.test.ts new file mode 100644 index 0000000..190130f --- /dev/null +++ b/src/application/services/NoteFieldMappingService.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import type { Card } from "@/domain/card/entities/Card"; +import { createCardKey } from "@/domain/card/value-objects/CardKey"; +import { createContentHash } from "@/domain/card/value-objects/ContentHash"; +import { createDeckName } from "@/domain/card/value-objects/DeckName"; +import { createNoteModelName } from "@/domain/card/value-objects/NoteModelName"; + +import { NoteFieldMappingService } from "./NoteFieldMappingService"; + +function createCard(overrides: Partial): Card { + return { + key: createCardKey("card-key"), + source: { + filePath: "notes/example.md", + headingLine: 1, + blockStartLine: 1, + bodyStartLine: 2, + blockEndLine: 3, + headingLevel: 4, + headingText: "Prompt", + }, + type: "basic", + heading: "Prompt", + bodyMarkdown: "Answer", + deck: createDeckName("Deck"), + noteModel: createNoteModelName("Basic"), + tags: [], + renderedFields: { + kind: "basic", + values: { + front: "Prompt", + back: "Answer", + }, + }, + fields: { + front: "Prompt", + back: "Answer", + }, + contentHash: createContentHash("hash"), + media: [], + ...overrides, + }; +} + +describe("NoteFieldMappingService", () => { + it("maps basic semantic fields onto a basic model", () => { + const service = new NoteFieldMappingService(); + const fields = service.map(createCard({}), { + fieldNames: ["Front", "Back"], + isCloze: false, + }); + + expect(fields).toEqual({ Front: "Prompt", Back: "Answer" }); + }); + + it("maps cloze semantic fields onto text and extra fields", () => { + const service = new NoteFieldMappingService(); + const fields = service.map( + createCard({ + type: "cloze", + noteModel: createNoteModelName("Cloze"), + renderedFields: { + kind: "cloze", + values: { + text: "{{c1::answer}}", + extra: "Context", + }, + }, + fields: { + text: "{{c1::answer}}", + extra: "Context", + }, + }), + { + fieldNames: ["Text", "Extra"], + isCloze: true, + }, + ); + + expect(fields).toEqual({ Text: "{{c1::answer}}", Extra: "Context" }); + }); + + it("rejects a non-cloze model for a cloze card", () => { + const service = new NoteFieldMappingService(); + + expect(() => + service.map( + createCard({ + type: "cloze", + noteModel: createNoteModelName("WrongModel"), + renderedFields: { + kind: "cloze", + values: { + text: "{{c1::answer}}", + extra: "Context", + }, + }, + fields: { + text: "{{c1::answer}}", + extra: "Context", + }, + }), + { + fieldNames: ["Front", "Back"], + isCloze: false, + }, + ), + ).toThrow("must target a cloze-compatible note model"); + }); +}); \ No newline at end of file diff --git a/src/application/services/NoteFieldMappingService.ts b/src/application/services/NoteFieldMappingService.ts new file mode 100644 index 0000000..ed84170 --- /dev/null +++ b/src/application/services/NoteFieldMappingService.ts @@ -0,0 +1,49 @@ +import type { Card } from "@/domain/card/entities/Card"; +import type { NoteModelDetails } from "@/application/dto/NoteModelDetails"; + +export class NoteFieldMappingService { + map(card: Card, noteModelDetails: NoteModelDetails): Record { + if (card.type === "basic") { + return this.mapBasic(card, noteModelDetails.fieldNames); + } + + return this.mapCloze(card, noteModelDetails); + } + + private mapBasic(card: Card, fieldNames: string[]): Record { + const frontFieldName = findFieldName(fieldNames, ["Front"]) ?? fieldNames[0]; + const backFieldName = findFieldName(fieldNames, ["Back"]) ?? fieldNames[1]; + + if (!frontFieldName || !backFieldName) { + throw new Error(`Basic note model ${card.noteModel} must expose at least two fields.`); + } + + return { + [frontFieldName]: card.fields.front, + [backFieldName]: card.fields.back, + }; + } + + private mapCloze(card: Card, noteModelDetails: NoteModelDetails): Record { + if (!noteModelDetails.isCloze) { + throw new Error(`Cloze card ${card.key} must target a cloze-compatible note model.`); + } + + const textFieldName = findFieldName(noteModelDetails.fieldNames, ["Text"]) ?? noteModelDetails.fieldNames[0]; + const extraFieldName = + findFieldName(noteModelDetails.fieldNames, ["Extra", "Context"]) ?? noteModelDetails.fieldNames[1]; + + if (!textFieldName || !extraFieldName) { + throw new Error(`Cloze note model ${card.noteModel} must expose text and auxiliary fields.`); + } + + return { + [textFieldName]: card.fields.text, + [extraFieldName]: card.fields.extra, + }; + } +} + +function findFieldName(fieldNames: string[], preferredNames: string[]): string | undefined { + return fieldNames.find((fieldName) => preferredNames.some((preferredName) => preferredName.toLowerCase() === fieldName.toLowerCase())); +} \ No newline at end of file diff --git a/src/application/services/ScanScopeService.test.ts b/src/application/services/ScanScopeService.test.ts new file mode 100644 index 0000000..0b9ea06 --- /dev/null +++ b/src/application/services/ScanScopeService.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { ScanScopeService } from "./ScanScopeService"; + +describe("ScanScopeService", () => { + it("scans the whole vault when includeFolders is empty", () => { + const service = new ScanScopeService(); + const files = service.filter( + [ + { path: "notes/one.md", basename: "one", content: "" }, + { path: "notes/two.md", basename: "two", content: "" }, + { path: "notes/three.txt", basename: "three", content: "" }, + ], + [], + [], + ); + + expect(files.map((file) => file.path)).toEqual(["notes/one.md", "notes/two.md"]); + }); + + it("applies includeFolders and excludeFolders together", () => { + const service = new ScanScopeService(); + const files = service.filter( + [ + { path: "cards/a.md", basename: "a", content: "" }, + { path: "cards/archive/b.md", basename: "b", content: "" }, + { path: "other/c.md", basename: "c", content: "" }, + ], + ["cards"], + ["cards/archive"], + ); + + expect(files.map((file) => file.path)).toEqual(["cards/a.md"]); + }); +}); \ No newline at end of file diff --git a/src/application/services/ScanScopeService.ts b/src/application/services/ScanScopeService.ts new file mode 100644 index 0000000..337bcce --- /dev/null +++ b/src/application/services/ScanScopeService.ts @@ -0,0 +1,33 @@ +import type { SourceFile } from "@/domain/card/entities/SourceFile"; + +export class ScanScopeService { + filter(files: SourceFile[], includeFolders: string[], excludeFolders: string[]): SourceFile[] { + const normalizedIncludes = includeFolders.map(normalizeFolderPath).filter(Boolean); + const normalizedExcludes = excludeFolders.map(normalizeFolderPath).filter(Boolean); + + return files.filter((file) => { + if (!file.path.toLowerCase().endsWith(".md")) { + return false; + } + + const normalizedPath = normalizeFilePath(file.path); + const included = + normalizedIncludes.length === 0 || normalizedIncludes.some((folder) => isPathInsideFolder(normalizedPath, folder)); + const excluded = normalizedExcludes.some((folder) => isPathInsideFolder(normalizedPath, folder)); + + return included && !excluded; + }); + } +} + +function normalizeFolderPath(folderPath: string): string { + return folderPath.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); +} + +function normalizeFilePath(filePath: string): string { + return filePath.trim().replace(/\\/g, "/").replace(/^\/+/, ""); +} + +function isPathInsideFolder(filePath: string, folderPath: string): boolean { + return filePath === folderPath || filePath.startsWith(`${folderPath}/`); +} \ No newline at end of file diff --git a/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts b/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts new file mode 100644 index 0000000..dd00a6e --- /dev/null +++ b/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; + +import type { AnkiGateway } from "@/application/ports/AnkiGateway"; +import type { SyncRegistryRepository } from "@/application/ports/SyncRegistryRepository"; +import type { Card } from "@/domain/card/entities/Card"; +import { createCardKey } from "@/domain/card/value-objects/CardKey"; +import { createContentHash } from "@/domain/card/value-objects/ContentHash"; +import { createDeckName } from "@/domain/card/value-objects/DeckName"; +import { createNoteModelName } from "@/domain/card/value-objects/NoteModelName"; +import { SyncRegistry } from "@/domain/sync/entities/SyncRegistry"; + +import { ExecuteSyncPlanUseCase } from "./ExecuteSyncPlanUseCase"; +import type { ScanAndPlanResult } from "./types"; + +class InMemorySyncRegistryRepository implements SyncRegistryRepository { + public savedRegistry: SyncRegistry | null = null; + + constructor(private readonly registry = new SyncRegistry()) {} + + async load(): Promise { + return this.registry; + } + + async save(registry: SyncRegistry): Promise { + this.savedRegistry = registry; + } +} + +class FakeAnkiGateway implements AnkiGateway { + public ensuredDecks: string[] = []; + public addedNotes: Array<{ deckName: string; modelName: string; fields: Record }> = []; + public updatedNotes: Array<{ noteId: number; deckName: string; fields: Record }> = []; + public storedMedia: string[] = []; + + async ensureDeckExists(deckName: string): Promise { + this.ensuredDecks.push(deckName); + } + + async getModelDetails(modelName: string) { + return modelName === "Cloze" + ? { fieldNames: ["Text", "Extra"], isCloze: true } + : { fieldNames: ["Front", "Back"], isCloze: false }; + } + + async addNote(input: { deckName: string; modelName: string; fields: Record }): Promise { + this.addedNotes.push(input); + return 9001; + } + + async updateNote(input: { noteId: number; deckName: string; fields: Record }): Promise { + this.updatedNotes.push(input); + } + + async storeMedia(asset: { fileName: string }): Promise { + this.storedMedia.push(asset.fileName); + } +} + +function createCard(overrides: Partial = {}): Card { + return { + key: createCardKey("card-1"), + source: { + filePath: "notes/current.md", + headingLine: 1, + blockStartLine: 1, + bodyStartLine: 2, + blockEndLine: 3, + headingLevel: 4, + headingText: "Prompt", + }, + type: "basic", + heading: "Prompt", + bodyMarkdown: "Answer", + deck: createDeckName("Deck"), + noteModel: createNoteModelName("Basic"), + tags: [], + renderedFields: { + kind: "basic", + values: { + front: "Prompt", + back: "Answer", + }, + }, + fields: { + front: "Prompt", + back: "Answer", + }, + contentHash: createContentHash("hash-1"), + media: [], + ...overrides, + }; +} + +describe("ExecuteSyncPlanUseCase", () => { + it("marks orphan records locally without any delete path", async () => { + const ankiGateway = new FakeAnkiGateway(); + const repository = new InMemorySyncRegistryRepository( + new SyncRegistry([ + { + cardKey: createCardKey("orphan-card"), + noteId: 42, + filePath: "notes/orphan.md", + sourceHash: createContentHash("old-hash"), + lastSyncedAt: 1, + orphan: false, + }, + ]), + ); + + const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, undefined, () => 1234); + const result: ScanAndPlanResult = { + cards: [createCard()], + registry: new SyncRegistry([ + { + cardKey: createCardKey("orphan-card"), + noteId: 42, + filePath: "notes/orphan.md", + sourceHash: createContentHash("old-hash"), + lastSyncedAt: 1, + orphan: false, + }, + ]), + plan: { + toCreateDecks: [createDeckName("Deck")], + toAdd: [createCard()], + toUpdate: [], + toMarkOrphan: [ + { + cardKey: createCardKey("orphan-card"), + noteId: 42, + filePath: "notes/orphan.md", + sourceHash: createContentHash("old-hash"), + lastSyncedAt: 1, + orphan: false, + }, + ], + }, + scopedFilePaths: ["notes/current.md", "notes/orphan.md"], + }; + + const execution = await useCase.execute(result); + + expect(execution.created).toBe(1); + expect(execution.markedOrphan).toBe(1); + expect(ankiGateway.ensuredDecks).toEqual(["Deck"]); + expect(ankiGateway.addedNotes).toHaveLength(1); + expect(ankiGateway.updatedNotes).toHaveLength(0); + expect(repository.savedRegistry?.get(createCardKey("orphan-card"))?.orphan).toBe(true); + expect(repository.savedRegistry?.get(createCardKey("orphan-card"))?.noteId).toBe(42); + }); +}); \ No newline at end of file diff --git a/src/application/use-cases/ExecuteSyncPlanUseCase.ts b/src/application/use-cases/ExecuteSyncPlanUseCase.ts new file mode 100644 index 0000000..61ad3c0 --- /dev/null +++ b/src/application/use-cases/ExecuteSyncPlanUseCase.ts @@ -0,0 +1,130 @@ +import type { AnkiGateway } from "@/application/ports/AnkiGateway"; +import type { SyncRegistryRepository } from "@/application/ports/SyncRegistryRepository"; +import { NoteFieldMappingService } from "@/application/services/NoteFieldMappingService"; +import { SyncRegistry } from "@/domain/sync/entities/SyncRegistry"; + +import type { ExecuteSyncPlanResult, ScanAndPlanResult } from "./types"; + +export class ExecuteSyncPlanUseCase { + constructor( + private readonly ankiGateway: AnkiGateway, + private readonly syncRegistryRepository: SyncRegistryRepository, + private readonly noteFieldMappingService = new NoteFieldMappingService(), + private readonly now: () => number = () => Date.now(), + ) {} + + async execute(scanAndPlanResult: ScanAndPlanResult): Promise { + const syncRegistry = new SyncRegistry(scanAndPlanResult.registry.list()); + const modelDetailsCache = new Map>>(); + const timestamp = this.now(); + + for (const deckName of scanAndPlanResult.plan.toCreateDecks) { + await this.ankiGateway.ensureDeckExists(deckName); + } + + const syncCards = [ + ...scanAndPlanResult.plan.toAdd, + ...scanAndPlanResult.plan.toUpdate.map((entry) => entry.card), + ]; + const uploadedMedia = await this.uploadMedia(syncCards); + + for (const card of scanAndPlanResult.plan.toAdd) { + const modelDetails = await this.getModelDetails(modelDetailsCache, card.noteModel); + const noteId = await this.ankiGateway.addNote({ + deckName: card.deck, + modelName: card.noteModel, + fields: this.noteFieldMappingService.map(card, modelDetails), + tags: card.tags, + }); + + syncRegistry.recordSync({ + cardKey: card.key, + noteId, + filePath: card.source.filePath, + sourceHash: card.contentHash, + lastSyncedAt: timestamp, + orphan: false, + }); + } + + for (const entry of scanAndPlanResult.plan.toUpdate) { + const modelDetails = await this.getModelDetails(modelDetailsCache, entry.card.noteModel); + await this.ankiGateway.updateNote({ + noteId: entry.noteId, + deckName: entry.card.deck, + fields: this.noteFieldMappingService.map(entry.card, modelDetails), + }); + + syncRegistry.recordSync({ + cardKey: entry.card.key, + noteId: entry.noteId, + filePath: entry.card.source.filePath, + sourceHash: entry.card.contentHash, + lastSyncedAt: timestamp, + orphan: false, + }); + } + + const mutatedCardKeys = new Set(syncCards.map((card) => card.key)); + for (const card of scanAndPlanResult.cards) { + if (mutatedCardKeys.has(card.key)) { + continue; + } + + const existingRecord = syncRegistry.get(card.key); + if (!existingRecord) { + continue; + } + + syncRegistry.refresh(card.key, card.source.filePath, card.contentHash, timestamp); + } + + for (const orphanRecord of scanAndPlanResult.plan.toMarkOrphan) { + syncRegistry.markOrphan(orphanRecord.cardKey, timestamp); + } + + await this.syncRegistryRepository.save(syncRegistry); + + return { + created: scanAndPlanResult.plan.toAdd.length, + updated: scanAndPlanResult.plan.toUpdate.length, + markedOrphan: scanAndPlanResult.plan.toMarkOrphan.length, + uploadedMedia, + scanned: scanAndPlanResult.cards.length, + unchanged: + scanAndPlanResult.cards.length - + scanAndPlanResult.plan.toAdd.length - + scanAndPlanResult.plan.toUpdate.length, + }; + } + + private async getModelDetails( + modelDetailsCache: Map>>, + modelName: string, + ): Promise>> { + const cached = modelDetailsCache.get(modelName); + if (cached) { + return cached; + } + + const resolved = await this.ankiGateway.getModelDetails(modelName); + modelDetailsCache.set(modelName, resolved); + return resolved; + } + + private async uploadMedia(cards: ScanAndPlanResult["cards"]): Promise { + const uniqueMedia = new Map(); + + for (const card of cards) { + for (const asset of card.media) { + uniqueMedia.set(`${asset.kind}:${asset.absolutePath}:${asset.fileName}`, asset); + } + } + + for (const asset of uniqueMedia.values()) { + await this.ankiGateway.storeMedia(asset); + } + + return uniqueMedia.size; + } +} \ No newline at end of file diff --git a/src/application/use-cases/ScanAndPlanSyncUseCase.ts b/src/application/use-cases/ScanAndPlanSyncUseCase.ts new file mode 100644 index 0000000..70bbb8f --- /dev/null +++ b/src/application/use-cases/ScanAndPlanSyncUseCase.ts @@ -0,0 +1,72 @@ +import type { PluginSettings } from "@/application/config/PluginSettings"; +import { validatePluginSettings } from "@/application/config/PluginSettings"; +import type { SyncRegistryRepository } from "@/application/ports/SyncRegistryRepository"; +import type { VaultGateway } from "@/application/ports/VaultGateway"; +import { ScanScopeService } from "@/application/services/ScanScopeService"; +import { CardExtractionService } from "@/domain/card/services/CardExtractionService"; +import { CardRenderingService } from "@/domain/card/services/CardRenderingService"; +import { SyncPlanningService } from "@/domain/sync/services/SyncPlanningService"; + +import type { ScanAndPlanResult } from "./types"; + +export class ScanAndPlanSyncUseCase { + constructor( + private readonly vaultGateway: VaultGateway, + private readonly syncRegistryRepository: SyncRegistryRepository, + private readonly scanScopeService = new ScanScopeService(), + private readonly cardExtractionService = new CardExtractionService(), + private readonly cardRenderingService = new CardRenderingService(), + private readonly syncPlanningService = new SyncPlanningService(), + ) {} + + async executeForVault(settings: PluginSettings): Promise { + validatePluginSettings(settings); + + const markdownFiles = await this.vaultGateway.listMarkdownFiles(); + const scopedFiles = this.scanScopeService.filter(markdownFiles, settings.includeFolders, settings.excludeFolders); + + return this.scanFiles(scopedFiles, settings); + } + + async executeForFile(filePath: string, settings: PluginSettings): Promise { + validatePluginSettings(settings); + + const sourceFile = await this.vaultGateway.getMarkdownFile(filePath); + if (!sourceFile) { + throw new Error(`Markdown file not found: ${filePath}`); + } + + return this.scanFiles([sourceFile], settings); + } + + private async scanFiles(files: Awaited>, settings: PluginSettings): Promise { + const cards = files.flatMap((file) => + this.cardExtractionService + .extract(file, { + qaHeadingLevel: settings.qaHeadingLevel, + clozeHeadingLevel: settings.clozeHeadingLevel, + }) + .map((draft) => + this.cardRenderingService.render(draft, { + defaultDeck: settings.defaultDeck, + qaNoteType: settings.qaNoteType, + clozeNoteType: settings.clozeNoteType, + addObsidianBacklink: settings.addObsidianBacklink, + convertHighlightsToCloze: settings.convertHighlightsToCloze, + resourceResolver: this.vaultGateway, + }), + ), + ); + + const registry = await this.syncRegistryRepository.load(); + const scopedFilePaths = files.map((file) => file.path); + const plan = this.syncPlanningService.plan(cards, registry, scopedFilePaths); + + return { + cards, + registry, + plan, + scopedFilePaths, + }; + } +} \ No newline at end of file diff --git a/src/application/use-cases/SyncCurrentFileUseCase.test.ts b/src/application/use-cases/SyncCurrentFileUseCase.test.ts new file mode 100644 index 0000000..8a9ca74 --- /dev/null +++ b/src/application/use-cases/SyncCurrentFileUseCase.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_SETTINGS, type PluginSettings } from "@/application/config/PluginSettings"; +import type { AnkiGateway } from "@/application/ports/AnkiGateway"; +import type { PluginDataStore } from "@/application/ports/PluginDataStore"; +import type { VaultGateway } from "@/application/ports/VaultGateway"; +import { createCardKey } from "@/domain/card/value-objects/CardKey"; +import { createContentHash } from "@/domain/card/value-objects/ContentHash"; +import type { SourceLocation } from "@/domain/card/value-objects/SourceLocation"; +import { DataJsonSyncRegistryRepository } from "@/infrastructure/persistence/DataJsonSyncRegistryRepository"; +import type { PluginDataSnapshot } from "@/infrastructure/persistence/DataJsonPluginConfigRepository"; + +import { ExecuteSyncPlanUseCase } from "./ExecuteSyncPlanUseCase"; +import { ScanAndPlanSyncUseCase } from "./ScanAndPlanSyncUseCase"; +import { SyncCurrentFileUseCase } from "./SyncCurrentFileUseCase"; + +class InMemoryPluginDataStore implements PluginDataStore { + constructor(private snapshot: PluginDataSnapshot | null = null) {} + + async load(): Promise { + return this.snapshot; + } + + async save(data: PluginDataSnapshot): Promise { + this.snapshot = data; + } + + readSnapshot(): PluginDataSnapshot | null { + return this.snapshot; + } +} + +class FakeVaultGateway implements VaultGateway { + constructor(private readonly files: Array<{ path: string; content: string }>) {} + + async listMarkdownFiles() { + return this.files.map((file) => ({ + path: file.path, + basename: file.path.split("/").pop()?.replace(/\.md$/i, "") ?? file.path, + content: file.content, + })); + } + + async getMarkdownFile(path: string) { + const file = this.files.find((candidate) => candidate.path === path); + if (!file) { + return null; + } + + return { + path: file.path, + basename: file.path.split("/").pop()?.replace(/\.md$/i, "") ?? file.path, + content: file.content, + }; + } + + resolveWikiLink(rawTarget: string) { + return { url: `obsidian://open?vault=Vault&file=${rawTarget}`, displayText: rawTarget.split("|")[1] ?? rawTarget }; + } + + resolveEmbed(rawTarget: string) { + if (rawTarget.includes(".png")) { + return { kind: "image" as const, fileName: "diagram.png", absolutePath: "/vault/diagram.png" }; + } + + return null; + } + + createBacklink(location: SourceLocation) { + return `obsidian://open?vault=Vault&file=${encodeURIComponent(location.filePath)}`; + } +} + +class FakeAnkiGateway implements AnkiGateway { + public readonly addCalls: Array<{ deckName: string; modelName: string; fields: Record }> = []; + public readonly updateCalls: Array<{ noteId: number; deckName: string; fields: Record }> = []; + public readonly ensureDeckCalls: string[] = []; + public readonly storedMedia: string[] = []; + public deleteCalls = 0; + + async ensureDeckExists(deckName: string): Promise { + this.ensureDeckCalls.push(deckName); + } + + async getModelDetails(modelName: string) { + return modelName === "Cloze" + ? { fieldNames: ["Text", "Extra"], isCloze: true } + : { fieldNames: ["Front", "Back"], isCloze: false }; + } + + async addNote(input: { deckName: string; modelName: string; fields: Record }): Promise { + this.addCalls.push(input); + return 500 + this.addCalls.length; + } + + async updateNote(input: { noteId: number; deckName: string; fields: Record }): Promise { + this.updateCalls.push(input); + } + + async storeMedia(asset: { fileName: string }): Promise { + this.storedMedia.push(asset.fileName); + } +} + +function createSettings(overrides: Partial = {}): PluginSettings { + return { + ...DEFAULT_SETTINGS, + addObsidianBacklink: false, + ...overrides, + }; +} + +describe("SyncCurrentFileUseCase", () => { + it("syncs only the requested file and marks only in-scope orphans", async () => { + const store = new InMemoryPluginDataStore({ + syncRegistry: { + records: [ + { + cardKey: createCardKey("legacy-current"), + noteId: 101, + filePath: "notes/current.md", + sourceHash: createContentHash("old-hash"), + lastSyncedAt: 1, + orphan: false, + }, + { + cardKey: createCardKey("other-file"), + noteId: 102, + filePath: "notes/other.md", + sourceHash: createContentHash("other-hash"), + lastSyncedAt: 1, + orphan: false, + }, + ], + }, + }); + const vaultGateway = new FakeVaultGateway([ + { + path: "notes/current.md", + content: ["#### Prompt", "Answer with ![[diagram.png]]"].join("\n"), + }, + { + path: "notes/other.md", + content: ["#### Other", "Other answer"].join("\n"), + }, + ]); + const ankiGateway = new FakeAnkiGateway(); + const repository = new DataJsonSyncRegistryRepository(store); + const scanUseCase = new ScanAndPlanSyncUseCase(vaultGateway, repository); + const executeUseCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, undefined, () => 1000); + const useCase = new SyncCurrentFileUseCase(scanUseCase, executeUseCase); + + const result = await useCase.execute("notes/current.md", createSettings()); + const snapshot = store.readSnapshot(); + + expect(result.created).toBe(1); + expect(result.markedOrphan).toBe(1); + expect(result.uploadedMedia).toBe(1); + expect(ankiGateway.addCalls).toHaveLength(1); + expect(ankiGateway.updateCalls).toHaveLength(0); + expect(ankiGateway.deleteCalls).toBe(0); + expect(snapshot?.syncRegistry?.records.find((record) => record.filePath === "notes/current.md" && record.orphan)).toBeTruthy(); + expect(snapshot?.syncRegistry?.records.find((record) => record.filePath === "notes/other.md" && record.orphan)).toBeFalsy(); + }); +}); \ No newline at end of file diff --git a/src/application/use-cases/SyncCurrentFileUseCase.ts b/src/application/use-cases/SyncCurrentFileUseCase.ts new file mode 100644 index 0000000..8a0e6f6 --- /dev/null +++ b/src/application/use-cases/SyncCurrentFileUseCase.ts @@ -0,0 +1,17 @@ +import type { PluginSettings } from "@/application/config/PluginSettings"; + +import type { ExecuteSyncPlanUseCase } from "./ExecuteSyncPlanUseCase"; +import type { ScanAndPlanSyncUseCase } from "./ScanAndPlanSyncUseCase"; +import type { ExecuteSyncPlanResult } from "./types"; + +export class SyncCurrentFileUseCase { + constructor( + private readonly scanAndPlanSyncUseCase: ScanAndPlanSyncUseCase, + private readonly executeSyncPlanUseCase: ExecuteSyncPlanUseCase, + ) {} + + async execute(filePath: string, settings: PluginSettings): Promise { + const scanResult = await this.scanAndPlanSyncUseCase.executeForFile(filePath, settings); + return this.executeSyncPlanUseCase.execute(scanResult); + } +} \ No newline at end of file diff --git a/src/application/use-cases/SyncVaultUseCase.test.ts b/src/application/use-cases/SyncVaultUseCase.test.ts new file mode 100644 index 0000000..bba0554 --- /dev/null +++ b/src/application/use-cases/SyncVaultUseCase.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_SETTINGS, type PluginSettings } from "@/application/config/PluginSettings"; +import type { AnkiGateway } from "@/application/ports/AnkiGateway"; +import type { PluginDataStore } from "@/application/ports/PluginDataStore"; +import type { VaultGateway } from "@/application/ports/VaultGateway"; +import type { SourceLocation } from "@/domain/card/value-objects/SourceLocation"; +import { DataJsonSyncRegistryRepository } from "@/infrastructure/persistence/DataJsonSyncRegistryRepository"; +import type { PluginDataSnapshot } from "@/infrastructure/persistence/DataJsonPluginConfigRepository"; + +import { ExecuteSyncPlanUseCase } from "./ExecuteSyncPlanUseCase"; +import { ScanAndPlanSyncUseCase } from "./ScanAndPlanSyncUseCase"; +import { SyncVaultUseCase } from "./SyncVaultUseCase"; + +class InMemoryPluginDataStore implements PluginDataStore { + constructor(private snapshot: PluginDataSnapshot | null = null) {} + + async load(): Promise { + return this.snapshot; + } + + async save(data: PluginDataSnapshot): Promise { + this.snapshot = data; + } + + readSnapshot(): PluginDataSnapshot | null { + return this.snapshot; + } +} + +class FakeVaultGateway implements VaultGateway { + constructor(private readonly files: Array<{ path: string; content: string }>) {} + + async listMarkdownFiles() { + return this.files.map((file) => ({ + path: file.path, + basename: file.path.split("/").pop()?.replace(/\.md$/i, "") ?? file.path, + content: file.content, + })); + } + + async getMarkdownFile(path: string) { + const file = this.files.find((candidate) => candidate.path === path); + if (!file) { + return null; + } + + return { + path: file.path, + basename: file.path.split("/").pop()?.replace(/\.md$/i, "") ?? file.path, + content: file.content, + }; + } + + resolveWikiLink(rawTarget: string) { + return { url: `obsidian://open?vault=Vault&file=${rawTarget}`, displayText: rawTarget.split("|")[1] ?? rawTarget }; + } + + resolveEmbed() { + return null; + } + + createBacklink(location: SourceLocation) { + return `obsidian://open?vault=Vault&file=${encodeURIComponent(location.filePath)}`; + } +} + +class FakeAnkiGateway implements AnkiGateway { + public readonly addCalls: Array<{ deckName: string; modelName: string; fields: Record }> = []; + public readonly updateCalls: Array<{ noteId: number; deckName: string; fields: Record }> = []; + public readonly ensureDeckCalls: string[] = []; + + async ensureDeckExists(deckName: string): Promise { + this.ensureDeckCalls.push(deckName); + } + + async getModelDetails(modelName: string) { + return modelName === "Cloze" + ? { fieldNames: ["Text", "Extra"], isCloze: true } + : { fieldNames: ["Front", "Back"], isCloze: false }; + } + + async addNote(input: { deckName: string; modelName: string; fields: Record }): Promise { + this.addCalls.push(input); + return 200 + this.addCalls.length; + } + + async updateNote(input: { noteId: number; deckName: string; fields: Record }): Promise { + this.updateCalls.push(input); + } + + async storeMedia(): Promise {} +} + +function createSettings(overrides: Partial = {}): PluginSettings { + return { + ...DEFAULT_SETTINGS, + addObsidianBacklink: false, + ...overrides, + }; +} + +describe("SyncVaultUseCase", () => { + it("scans the configured scope and syncs vault cards", async () => { + const store = new InMemoryPluginDataStore(); + const vaultGateway = new FakeVaultGateway([ + { + path: "cards/qa.md", + content: ["TARGET DECK: Scoped::Deck", "", "#### Prompt", "Answer"].join("\n"), + }, + { + path: "cards/skip/ignored.md", + content: ["#### Ignored", "Ignored answer"].join("\n"), + }, + { + path: "outside/out.md", + content: ["#### Outside", "Outside answer"].join("\n"), + }, + ]); + const ankiGateway = new FakeAnkiGateway(); + const repository = new DataJsonSyncRegistryRepository(store); + const scanUseCase = new ScanAndPlanSyncUseCase(vaultGateway, repository); + const executeUseCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, undefined, () => 2000); + const useCase = new SyncVaultUseCase(scanUseCase, executeUseCase); + + const result = await useCase.execute( + createSettings({ + includeFolders: ["cards"], + excludeFolders: ["cards/skip"], + }), + ); + const snapshot = store.readSnapshot(); + + expect(result.scanned).toBe(1); + expect(result.created).toBe(1); + expect(result.updated).toBe(0); + expect(ankiGateway.ensureDeckCalls).toEqual(["Scoped::Deck"]); + expect(ankiGateway.addCalls[0]?.deckName).toBe("Scoped::Deck"); + expect(snapshot?.syncRegistry?.records).toHaveLength(1); + expect(snapshot?.syncRegistry?.records[0]?.filePath).toBe("cards/qa.md"); + }); +}); \ No newline at end of file diff --git a/src/application/use-cases/SyncVaultUseCase.ts b/src/application/use-cases/SyncVaultUseCase.ts new file mode 100644 index 0000000..524bbd4 --- /dev/null +++ b/src/application/use-cases/SyncVaultUseCase.ts @@ -0,0 +1,17 @@ +import type { PluginSettings } from "@/application/config/PluginSettings"; + +import type { ExecuteSyncPlanUseCase } from "./ExecuteSyncPlanUseCase"; +import type { ScanAndPlanSyncUseCase } from "./ScanAndPlanSyncUseCase"; +import type { ExecuteSyncPlanResult } from "./types"; + +export class SyncVaultUseCase { + constructor( + private readonly scanAndPlanSyncUseCase: ScanAndPlanSyncUseCase, + private readonly executeSyncPlanUseCase: ExecuteSyncPlanUseCase, + ) {} + + async execute(settings: PluginSettings): Promise { + const scanResult = await this.scanAndPlanSyncUseCase.executeForVault(settings); + return this.executeSyncPlanUseCase.execute(scanResult); + } +} \ No newline at end of file diff --git a/src/application/use-cases/types.ts b/src/application/use-cases/types.ts new file mode 100644 index 0000000..8b399ce --- /dev/null +++ b/src/application/use-cases/types.ts @@ -0,0 +1,19 @@ +import type { Card } from "@/domain/card/entities/Card"; +import type { SyncRegistry } from "@/domain/sync/entities/SyncRegistry"; +import type { SyncPlan } from "@/domain/sync/value-objects/SyncPlan"; + +export interface ScanAndPlanResult { + cards: Card[]; + registry: SyncRegistry; + plan: SyncPlan; + scopedFilePaths: string[]; +} + +export interface ExecuteSyncPlanResult { + created: number; + markedOrphan: number; + scanned: number; + unchanged: number; + updated: number; + uploadedMedia: number; +} \ No newline at end of file diff --git a/src/domain/card/entities/Card.ts b/src/domain/card/entities/Card.ts new file mode 100644 index 0000000..8a1a928 --- /dev/null +++ b/src/domain/card/entities/Card.ts @@ -0,0 +1,21 @@ +import type { CardKey } from "../value-objects/CardKey"; +import type { ContentHash } from "../value-objects/ContentHash"; +import type { DeckName } from "../value-objects/DeckName"; +import type { NoteModelName } from "../value-objects/NoteModelName"; +import type { SourceLocation } from "../value-objects/SourceLocation"; +import type { CardType, MediaAsset, RenderedFields } from "./RenderedFields"; + +export interface Card { + key: CardKey; + source: SourceLocation; + type: CardType; + heading: string; + bodyMarkdown: string; + deck: DeckName; + noteModel: NoteModelName; + tags: string[]; + renderedFields: RenderedFields; + fields: Record; + contentHash: ContentHash; + media: MediaAsset[]; +} \ No newline at end of file diff --git a/src/domain/card/entities/CardDraft.ts b/src/domain/card/entities/CardDraft.ts new file mode 100644 index 0000000..e6d2d78 --- /dev/null +++ b/src/domain/card/entities/CardDraft.ts @@ -0,0 +1,12 @@ +import type { CardType } from "./RenderedFields"; +import type { DeckName } from "../value-objects/DeckName"; +import type { SourceLocation } from "../value-objects/SourceLocation"; + +export interface CardDraft { + source: SourceLocation; + heading: string; + headingLevel: number; + type: CardType; + bodyMarkdown: string; + deckHint?: DeckName; +} \ No newline at end of file diff --git a/src/domain/card/entities/RenderedFields.ts b/src/domain/card/entities/RenderedFields.ts new file mode 100644 index 0000000..72d8e98 --- /dev/null +++ b/src/domain/card/entities/RenderedFields.ts @@ -0,0 +1,26 @@ +export type CardType = "basic" | "cloze"; + +export interface BasicRenderedFields { + kind: "basic"; + values: { + front: string; + back: string; + }; +} + +export interface ClozeRenderedFields { + kind: "cloze"; + values: { + text: string; + extra: string; + }; +} + +export type RenderedFields = BasicRenderedFields | ClozeRenderedFields; + +export interface MediaAsset { + kind: "image" | "audio"; + fileName: string; + absolutePath: string; + altText?: string; +} \ No newline at end of file diff --git a/src/domain/card/entities/SourceFile.ts b/src/domain/card/entities/SourceFile.ts new file mode 100644 index 0000000..83c9d1e --- /dev/null +++ b/src/domain/card/entities/SourceFile.ts @@ -0,0 +1,5 @@ +export interface SourceFile { + path: string; + basename: string; + content: string; +} \ No newline at end of file diff --git a/src/domain/card/policies/CardIdentityPolicy.test.ts b/src/domain/card/policies/CardIdentityPolicy.test.ts new file mode 100644 index 0000000..f330300 --- /dev/null +++ b/src/domain/card/policies/CardIdentityPolicy.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { CardIdentityPolicy } from "./CardIdentityPolicy"; +import type { SourceLocation } from "../value-objects/SourceLocation"; + +describe("CardIdentityPolicy", () => { + it("creates a stable key for the same source location and type", () => { + const policy = new CardIdentityPolicy(); + const location: SourceLocation = { + filePath: "notes/example.md", + headingLine: 5, + blockStartLine: 5, + bodyStartLine: 6, + blockEndLine: 8, + headingLevel: 4, + headingText: "Prompt", + }; + + expect(policy.create(location, "basic")).toBe(policy.create(location, "basic")); + }); + + it("changes the key when a participating identity component changes", () => { + const policy = new CardIdentityPolicy(); + const location: SourceLocation = { + filePath: "notes/example.md", + headingLine: 5, + blockStartLine: 5, + bodyStartLine: 6, + blockEndLine: 8, + headingLevel: 4, + headingText: "Prompt", + }; + + const movedLocation: SourceLocation = { + ...location, + blockStartLine: 7, + }; + + expect(policy.create(location, "basic")).not.toBe(policy.create(movedLocation, "basic")); + }); +}); \ No newline at end of file diff --git a/src/domain/card/policies/CardIdentityPolicy.ts b/src/domain/card/policies/CardIdentityPolicy.ts new file mode 100644 index 0000000..992b219 --- /dev/null +++ b/src/domain/card/policies/CardIdentityPolicy.ts @@ -0,0 +1,20 @@ +import type { CardType } from "../entities/RenderedFields"; +import type { SourceLocation } from "../value-objects/SourceLocation"; +import { createCardKey, type CardKey } from "../value-objects/CardKey"; +import { hashString } from "../../shared/hash"; + +export class CardIdentityPolicy { + create(location: SourceLocation, cardType: CardType): CardKey { + return createCardKey( + hashString( + [ + location.filePath, + String(location.headingLevel), + location.headingText, + String(location.blockStartLine), + cardType, + ].join("|"), + ), + ); + } +} \ No newline at end of file diff --git a/src/domain/card/ports/RenderResourceResolver.ts b/src/domain/card/ports/RenderResourceResolver.ts new file mode 100644 index 0000000..be912a8 --- /dev/null +++ b/src/domain/card/ports/RenderResourceResolver.ts @@ -0,0 +1,19 @@ +import type { SourceLocation } from "../value-objects/SourceLocation"; + +export interface ResolvedWikiLink { + url: string; + displayText: string; +} + +export interface ResolvedEmbed { + kind: "image" | "audio"; + fileName: string; + absolutePath: string; + altText?: string; +} + +export interface RenderResourceResolver { + resolveWikiLink(rawTarget: string, sourcePath: string): ResolvedWikiLink | null; + resolveEmbed(rawTarget: string, sourcePath: string): ResolvedEmbed | null; + createBacklink(location: SourceLocation): string; +} \ No newline at end of file diff --git a/src/domain/card/services/CardExtractionService.test.ts b/src/domain/card/services/CardExtractionService.test.ts new file mode 100644 index 0000000..bf4c3e0 --- /dev/null +++ b/src/domain/card/services/CardExtractionService.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { CardExtractionService, validateHeadingPolicy } from "./CardExtractionService"; +import type { SourceFile } from "../entities/SourceFile"; + +describe("CardExtractionService", () => { + it("extracts heading blocks using configured QA and Cloze levels", () => { + const sourceFile: SourceFile = { + path: "notes/example.md", + basename: "example", + content: [ + "TARGET DECK: Coding::Deck", + "", + "#### What is DDD?", + "Domain-driven design keeps the model central.", + "", + "##### Fill the gap", + "Use ==ubiquitous language== across the team.", + "", + "#### Another card", + "Another answer.", + ].join("\n"), + }; + + const service = new CardExtractionService(); + const drafts = service.extract(sourceFile, { qaHeadingLevel: 4, clozeHeadingLevel: 5 }); + + expect(drafts).toHaveLength(3); + expect(drafts[0]).toMatchObject({ + heading: "What is DDD?", + type: "basic", + bodyMarkdown: [ + "Domain-driven design keeps the model central.", + "", + "##### Fill the gap", + "Use ==ubiquitous language== across the team.", + ].join("\n"), + }); + expect(drafts[1]).toMatchObject({ + heading: "Fill the gap", + type: "cloze", + bodyMarkdown: "Use ==ubiquitous language== across the team.", + }); + expect(drafts[2].deckHint).toBe("Coding::Deck"); + }); + + it("ignores headings and target deck lines inside fenced code blocks", () => { + const sourceFile: SourceFile = { + path: "notes/example.md", + basename: "example", + content: [ + "```md", + "#### Not a card", + "TARGET DECK: Fake", + "```", + "", + "#### Real card", + "Answer", + ].join("\n"), + }; + + const service = new CardExtractionService(); + const drafts = service.extract(sourceFile, { qaHeadingLevel: 4, clozeHeadingLevel: 5 }); + + expect(drafts).toHaveLength(1); + expect(drafts[0].heading).toBe("Real card"); + expect(drafts[0].deckHint).toBeUndefined(); + }); + + it("rejects equal heading levels", () => { + expect(() => validateHeadingPolicy({ qaHeadingLevel: 4, clozeHeadingLevel: 4 })).toThrow( + "QA and Cloze heading levels must not be equal.", + ); + }); +}); \ No newline at end of file diff --git a/src/domain/card/services/CardExtractionService.ts b/src/domain/card/services/CardExtractionService.ts new file mode 100644 index 0000000..bcd01f2 --- /dev/null +++ b/src/domain/card/services/CardExtractionService.ts @@ -0,0 +1,172 @@ +import type { SourceFile } from "../entities/SourceFile"; +import type { CardDraft } from "../entities/CardDraft"; +import { createDeckName } from "../value-objects/DeckName"; + +export interface HeadingPolicy { + qaHeadingLevel: number; + clozeHeadingLevel: number; +} + +interface HeadingMatch { + level: number; + text: string; + lineIndex: number; +} + +const HEADING_REGEXP = /^(#{1,6})\s+(.*?)\s*$/; +const TARGET_DECK_REGEXP = /^\s*TARGET DECK\s*:\s*(.+?)\s*$/i; + +export class CardExtractionService { + extract(sourceFile: SourceFile, headingPolicy: HeadingPolicy): CardDraft[] { + validateHeadingPolicy(headingPolicy); + + const lines = sourceFile.content.split(/\r?\n/); + const headings = collectHeadings(lines); + const targetDeck = extractTargetDeck(lines); + const drafts: CardDraft[] = []; + + for (let headingIndex = 0; headingIndex < headings.length; headingIndex += 1) { + const heading = headings[headingIndex]; + const cardType = getCardTypeForHeading(heading.level, headingPolicy); + + if (!cardType) { + continue; + } + + const blockEndLineIndex = findBlockEndLineIndex(headings, headingIndex, lines.length); + const bodyLines = lines.slice(heading.lineIndex + 1, blockEndLineIndex); + const bodyMarkdown = trimBlankEdges(bodyLines).join("\n"); + + drafts.push({ + source: { + filePath: sourceFile.path, + headingLine: heading.lineIndex + 1, + blockStartLine: heading.lineIndex + 1, + bodyStartLine: heading.lineIndex + 2, + blockEndLine: blockEndLineIndex, + headingLevel: heading.level, + headingText: heading.text, + }, + heading: heading.text, + headingLevel: heading.level, + type: cardType, + bodyMarkdown, + deckHint: targetDeck ? createDeckName(targetDeck) : undefined, + }); + } + + return drafts; + } +} + +export function validateHeadingPolicy(headingPolicy: HeadingPolicy): void { + const { qaHeadingLevel, clozeHeadingLevel } = headingPolicy; + + if (!Number.isInteger(qaHeadingLevel) || qaHeadingLevel < 1 || qaHeadingLevel > 6) { + throw new Error("QA heading level must be an integer between 1 and 6."); + } + + if (!Number.isInteger(clozeHeadingLevel) || clozeHeadingLevel < 1 || clozeHeadingLevel > 6) { + throw new Error("Cloze heading level must be an integer between 1 and 6."); + } + + if (qaHeadingLevel === clozeHeadingLevel) { + throw new Error("QA and Cloze heading levels must not be equal."); + } +} + +function collectHeadings(lines: string[]): HeadingMatch[] { + const headings: HeadingMatch[] = []; + let fenceMarker: string | null = null; + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { + const line = lines[lineIndex]; + const trimmed = line.trim(); + + if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) { + fenceMarker = fenceMarker ? null : trimmed.slice(0, 3); + continue; + } + + if (fenceMarker) { + continue; + } + + const match = line.match(HEADING_REGEXP); + if (!match) { + continue; + } + + headings.push({ + level: match[1].length, + text: match[2].replace(/\s+#+\s*$/, "").trim(), + lineIndex, + }); + } + + return headings; +} + +function extractTargetDeck(lines: string[]): string | undefined { + let fenceMarker: string | null = null; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) { + fenceMarker = fenceMarker ? null : trimmed.slice(0, 3); + continue; + } + + if (fenceMarker) { + continue; + } + + const match = line.match(TARGET_DECK_REGEXP); + if (match) { + return match[1].trim(); + } + } + + return undefined; +} + +function getCardTypeForHeading(level: number, headingPolicy: HeadingPolicy): "basic" | "cloze" | null { + if (level === headingPolicy.qaHeadingLevel) { + return "basic"; + } + + if (level === headingPolicy.clozeHeadingLevel) { + return "cloze"; + } + + return null; +} + +function findBlockEndLineIndex(headings: HeadingMatch[], currentHeadingIndex: number, totalLineCount: number): number { + const currentHeading = headings[currentHeadingIndex]; + + for (let headingIndex = currentHeadingIndex + 1; headingIndex < headings.length; headingIndex += 1) { + const nextHeading = headings[headingIndex]; + if (nextHeading.level <= currentHeading.level) { + return nextHeading.lineIndex; + } + } + + return totalLineCount; +} + +function trimBlankEdges(lines: string[]): string[] { + let startIndex = 0; + let endIndex = lines.length; + + while (startIndex < endIndex && !lines[startIndex].trim()) { + startIndex += 1; + } + + while (endIndex > startIndex && !lines[endIndex - 1].trim()) { + endIndex -= 1; + } + + return lines.slice(startIndex, endIndex); +} \ No newline at end of file diff --git a/src/domain/card/services/CardRenderingService.test.ts b/src/domain/card/services/CardRenderingService.test.ts new file mode 100644 index 0000000..86d5824 --- /dev/null +++ b/src/domain/card/services/CardRenderingService.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; + +import type { RenderResourceResolver } from "@/domain/card/ports/RenderResourceResolver"; + +import type { CardDraft } from "../entities/CardDraft"; +import { CardRenderingService } from "./CardRenderingService"; + +const resolver: RenderResourceResolver = { + resolveWikiLink(rawTarget) { + if (rawTarget.startsWith("Concept")) { + return { + url: "obsidian://open?vault=Vault&file=Concept", + displayText: rawTarget.includes("|") ? rawTarget.split("|")[1] : "Concept", + }; + } + + return null; + }, + resolveEmbed(rawTarget) { + if (rawTarget.startsWith("diagram.png")) { + return { + kind: "image", + fileName: "diagram.png", + absolutePath: "/vault/diagram.png", + altText: "diagram.png", + }; + } + + if (rawTarget.startsWith("clip.mp3")) { + return { + kind: "audio", + fileName: "clip.mp3", + absolutePath: "/vault/clip.mp3", + }; + } + + return null; + }, + createBacklink(location) { + return `obsidian://open?vault=Vault&file=${encodeURIComponent(location.filePath)}`; + }, +}; + +function createDraft(overrides: Partial = {}): CardDraft { + return { + source: { + filePath: "notes/example.md", + headingLine: 3, + blockStartLine: 3, + bodyStartLine: 4, + blockEndLine: 8, + headingLevel: 4, + headingText: "Prompt", + }, + heading: "Prompt", + headingLevel: 4, + type: "basic", + bodyMarkdown: "Answer", + ...overrides, + }; +} + +describe("CardRenderingService", () => { + it("renders a basic card with default deck, note model, and backlink", () => { + const service = new CardRenderingService(); + const card = service.render(createDraft(), { + defaultDeck: "Default", + qaNoteType: "Basic", + clozeNoteType: "Cloze", + addObsidianBacklink: true, + convertHighlightsToCloze: true, + resourceResolver: resolver, + }); + + expect(card.deck).toBe("Default"); + expect(card.noteModel).toBe("Basic"); + expect(card.renderedFields.kind).toBe("basic"); + expect(card.fields.front).toContain("Prompt"); + expect(card.fields.back).toContain("Open in Obsidian"); + expect(card.contentHash).toMatch(/^[0-9a-f]{8}$/); + }); + + it("renders a cloze card with heading in the auxiliary field", () => { + const service = new CardRenderingService(); + const card = service.render( + createDraft({ + type: "cloze", + headingLevel: 5, + source: { + filePath: "notes/example.md", + headingLine: 5, + blockStartLine: 5, + bodyStartLine: 6, + blockEndLine: 7, + headingLevel: 5, + headingText: "Context", + }, + heading: "Context", + bodyMarkdown: "Use ==ubiquitous language== in the team.", + }), + { + defaultDeck: "Default", + qaNoteType: "Basic", + clozeNoteType: "Cloze", + addObsidianBacklink: true, + convertHighlightsToCloze: true, + resourceResolver: resolver, + }, + ); + + expect(card.noteModel).toBe("Cloze"); + expect(card.renderedFields.kind).toBe("cloze"); + expect(card.fields.text).toContain("{{c1::ubiquitous language}}"); + expect(card.fields.extra).toContain("Context"); + expect(card.fields.extra).toContain("Open in Obsidian"); + }); + + it("preserves markdown fidelity for math, code, links, and media where feasible", () => { + const service = new CardRenderingService(); + const card = service.render( + createDraft({ + bodyMarkdown: [ + "Inline math $a+b$ and display:", + "", + "$$", + "x^2", + "$$", + "", + "`const value = 1`", + "", + "```ts", + "const value = 1;", + "```", + "", + "[[Concept|Read more]]", + "", + "![[diagram.png]]", + "", + "![[clip.mp3]]", + ].join("\n"), + }), + { + defaultDeck: "Default", + qaNoteType: "Basic", + clozeNoteType: "Cloze", + addObsidianBacklink: false, + convertHighlightsToCloze: true, + resourceResolver: resolver, + }, + ); + + expect(card.fields.back).toContain("\\(a+b\\)"); + expect(card.fields.back).toContain("\\[x^2\\]"); + expect(card.fields.back).toContain("const value = 1"); + expect(card.fields.back).toContain("language-ts"); + expect(card.fields.back).toContain("obsidian://open?vault=Vault&file=Concept"); + expect(card.fields.back).toContain("\"diagram.png\""); + expect(card.fields.back).toContain("[sound:clip.mp3]"); + expect(card.media).toHaveLength(2); + }); +}); \ No newline at end of file diff --git a/src/domain/card/services/CardRenderingService.ts b/src/domain/card/services/CardRenderingService.ts new file mode 100644 index 0000000..07e32d0 --- /dev/null +++ b/src/domain/card/services/CardRenderingService.ts @@ -0,0 +1,219 @@ +import MarkdownIt from "markdown-it"; + +import type { RenderResourceResolver } from "@/domain/card/ports/RenderResourceResolver"; +import { hashString } from "@/domain/shared/hash"; + +import type { Card } from "../entities/Card"; +import type { CardDraft } from "../entities/CardDraft"; +import type { MediaAsset, RenderedFields } from "../entities/RenderedFields"; +import { CardIdentityPolicy } from "../policies/CardIdentityPolicy"; +import { createContentHash } from "../value-objects/ContentHash"; +import { createNoteModelName } from "../value-objects/NoteModelName"; +import { DeckResolutionService } from "./DeckResolutionService"; + +const markdown = new MarkdownIt({ + breaks: true, + html: true, + linkify: true, +}); + +const INLINE_CODE_PATTERN = /`[^`\n]+`/g; +const FENCED_CODE_PATTERN = /```[\s\S]*?```|~~~[\s\S]*?~~~/g; +const DISPLAY_MATH_PATTERN = /(?Open in Obsidian

` + : ""; + + const renderedFields: RenderedFields = + draft.type === "basic" + ? { + kind: "basic", + values: { + front: headingResult.html, + back: bodyResult.html + backlinkHtml, + }, + } + : { + kind: "cloze", + values: { + text: bodyResult.html, + extra: headingResult.html + backlinkHtml, + }, + }; + + const fields = { ...renderedFields.values }; + const contentHash = createContentHash( + hashString( + JSON.stringify({ + deck, + fields, + heading: draft.heading, + noteModel, + sourcePath: draft.source.filePath, + type: draft.type, + }), + ), + ); + + return { + key: this.identityPolicy.create(draft.source, draft.type), + source: draft.source, + type: draft.type, + heading: draft.heading, + bodyMarkdown: draft.bodyMarkdown, + deck, + noteModel, + tags: [], + renderedFields, + fields, + contentHash, + media: dedupeMedia([...headingResult.media, ...bodyResult.media]), + }; + } + + private renderMarkdown( + markdownText: string, + draft: CardDraft, + context: CardRenderingContext, + cloze: boolean, + inline: boolean, + ): RenderResult { + const protectedBlocks = protectSegments(markdownText, FENCED_CODE_PATTERN, "FENCED_CODE"); + const protectedInline = protectSegments(protectedBlocks.text, INLINE_CODE_PATTERN, "INLINE_CODE"); + let transformed = protectedInline.text; + const media: MediaAsset[] = []; + let nextClozeIndex = 1; + + transformed = transformed.replace(DISPLAY_MATH_PATTERN, (_match, content: string) => `\\[${content.trim()}\\]`); + transformed = transformed.replace(INLINE_MATH_PATTERN, (_match, content: string) => `\\(${content.trim()}\\)`); + + if (cloze && context.convertHighlightsToCloze) { + transformed = transformed.replace(HIGHLIGHT_PATTERN, "{$1}"); + } + + if (cloze) { + transformed = transformed.replace(CLOZE_PATTERN, (_match, explicitIndex: string | undefined, content: string) => { + const clozeIndex = explicitIndex ? Number(explicitIndex) : nextClozeIndex++; + return `{{c${clozeIndex}::${content}}}`; + }); + } + + transformed = transformed.replace(EMBED_PATTERN, (_match, rawTarget: string) => { + const resolvedEmbed = context.resourceResolver.resolveEmbed(rawTarget, draft.source.filePath); + + if (!resolvedEmbed) { + return _match; + } + + media.push({ + kind: resolvedEmbed.kind, + fileName: resolvedEmbed.fileName, + absolutePath: resolvedEmbed.absolutePath, + altText: resolvedEmbed.altText, + }); + + if (resolvedEmbed.kind === "audio") { + return `[sound:${resolvedEmbed.fileName}]`; + } + + return `${escapeHtml(resolvedEmbed.altText ?? resolvedEmbed.fileName)}`; + }); + + transformed = transformed.replace(WIKILINK_PATTERN, (_match, rawTarget: string) => { + const resolvedLink = context.resourceResolver.resolveWikiLink(rawTarget, draft.source.filePath); + + if (!resolvedLink) { + return _match; + } + + return `${escapeHtml(resolvedLink.displayText)}`; + }); + + transformed = transformed.replace(HIGHLIGHT_PATTERN, "$1"); + const protectedMath = protectSegments(transformed, ANKI_MATH_PATTERN, "MATH"); + transformed = protectedMath.text; + transformed = protectedInline.restore(transformed); + transformed = protectedBlocks.restore(transformed); + + const renderedHtml = (inline ? markdown.renderInline(transformed) : markdown.render(transformed)).trim(); + + return { + html: protectedMath.restore(renderedHtml, escapeHtml), + media, + }; + } +} + +function protectSegments( + text: string, + pattern: RegExp, + prefix: string, +): { text: string; restore: (value: string, formatter?: (segment: string) => string) => string } { + const matches: string[] = []; + const nextText = text.replace(pattern, (segment) => { + const token = `@@${prefix}_${matches.length}@@`; + matches.push(segment); + return token; + }); + + return { + text: nextText, + restore: (value: string, formatter = (segment: string) => segment) => + matches.reduce((current, segment, index) => current.split(`@@${prefix}_${index}@@`).join(formatter(segment)), value), + }; +} + +function dedupeMedia(media: MediaAsset[]): MediaAsset[] { + const seen = new Set(); + + return media.filter((asset) => { + const key = `${asset.kind}:${asset.absolutePath}:${asset.fileName}`; + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} \ No newline at end of file diff --git a/src/domain/card/services/DeckResolutionService.test.ts b/src/domain/card/services/DeckResolutionService.test.ts new file mode 100644 index 0000000..943a54e --- /dev/null +++ b/src/domain/card/services/DeckResolutionService.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { DeckResolutionService } from "./DeckResolutionService"; + +describe("DeckResolutionService", () => { + it("prefers the file deck hint", () => { + const service = new DeckResolutionService(); + + expect(service.resolve("Scoped::Deck", "Default")).toBe("Scoped::Deck"); + }); + + it("falls back to the default deck", () => { + const service = new DeckResolutionService(); + + expect(service.resolve(undefined, "Default")).toBe("Default"); + }); +}); \ No newline at end of file diff --git a/src/domain/card/services/DeckResolutionService.ts b/src/domain/card/services/DeckResolutionService.ts new file mode 100644 index 0000000..f975291 --- /dev/null +++ b/src/domain/card/services/DeckResolutionService.ts @@ -0,0 +1,13 @@ +import { createDeckName, type DeckName } from "../value-objects/DeckName"; + +export class DeckResolutionService { + resolve(deckHint: string | undefined, defaultDeck: string): DeckName { + const preferredDeck = deckHint?.trim() || defaultDeck.trim(); + + if (!preferredDeck) { + throw new Error("A card deck could not be resolved."); + } + + return createDeckName(preferredDeck); + } +} \ No newline at end of file diff --git a/src/domain/card/value-objects/CardKey.ts b/src/domain/card/value-objects/CardKey.ts new file mode 100644 index 0000000..b192673 --- /dev/null +++ b/src/domain/card/value-objects/CardKey.ts @@ -0,0 +1,11 @@ +export type CardKey = string & { readonly __brand: "CardKey" }; + +export function createCardKey(value: string): CardKey { + const normalized = value.trim(); + + if (!normalized) { + throw new Error("Card key cannot be empty."); + } + + return normalized as CardKey; +} \ No newline at end of file diff --git a/src/domain/card/value-objects/ContentHash.ts b/src/domain/card/value-objects/ContentHash.ts new file mode 100644 index 0000000..c4e025d --- /dev/null +++ b/src/domain/card/value-objects/ContentHash.ts @@ -0,0 +1,11 @@ +export type ContentHash = string & { readonly __brand: "ContentHash" }; + +export function createContentHash(value: string): ContentHash { + const normalized = value.trim(); + + if (!normalized) { + throw new Error("Content hash cannot be empty."); + } + + return normalized as ContentHash; +} \ No newline at end of file diff --git a/src/domain/card/value-objects/DeckName.ts b/src/domain/card/value-objects/DeckName.ts new file mode 100644 index 0000000..3060a16 --- /dev/null +++ b/src/domain/card/value-objects/DeckName.ts @@ -0,0 +1,11 @@ +export type DeckName = string & { readonly __brand: "DeckName" }; + +export function createDeckName(value: string): DeckName { + const normalized = value.trim(); + + if (!normalized) { + throw new Error("Deck name cannot be empty."); + } + + return normalized as DeckName; +} \ No newline at end of file diff --git a/src/domain/card/value-objects/NoteModelName.ts b/src/domain/card/value-objects/NoteModelName.ts new file mode 100644 index 0000000..e84b1c9 --- /dev/null +++ b/src/domain/card/value-objects/NoteModelName.ts @@ -0,0 +1,11 @@ +export type NoteModelName = string & { readonly __brand: "NoteModelName" }; + +export function createNoteModelName(value: string): NoteModelName { + const normalized = value.trim(); + + if (!normalized) { + throw new Error("Note model name cannot be empty."); + } + + return normalized as NoteModelName; +} \ No newline at end of file diff --git a/src/domain/card/value-objects/SourceLocation.ts b/src/domain/card/value-objects/SourceLocation.ts new file mode 100644 index 0000000..c800f6a --- /dev/null +++ b/src/domain/card/value-objects/SourceLocation.ts @@ -0,0 +1,9 @@ +export interface SourceLocation { + filePath: string; + headingLine: number; + blockStartLine: number; + bodyStartLine: number; + blockEndLine: number; + headingLevel: number; + headingText: string; +} \ No newline at end of file diff --git a/src/domain/shared/hash.ts b/src/domain/shared/hash.ts new file mode 100644 index 0000000..4e761b4 --- /dev/null +++ b/src/domain/shared/hash.ts @@ -0,0 +1,10 @@ +export function hashString(input: string): string { + let hash = 2166136261; + + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + + return (hash >>> 0).toString(16).padStart(8, "0"); +} \ No newline at end of file diff --git a/src/domain/sync/entities/SyncRecord.ts b/src/domain/sync/entities/SyncRecord.ts new file mode 100644 index 0000000..b58aecb --- /dev/null +++ b/src/domain/sync/entities/SyncRecord.ts @@ -0,0 +1,11 @@ +import type { CardKey } from "../../card/value-objects/CardKey"; +import type { ContentHash } from "../../card/value-objects/ContentHash"; + +export interface SyncRecord { + cardKey: CardKey; + noteId: number; + filePath: string; + sourceHash: ContentHash; + lastSyncedAt: number; + orphan: boolean; +} \ No newline at end of file diff --git a/src/domain/sync/entities/SyncRegistry.test.ts b/src/domain/sync/entities/SyncRegistry.test.ts new file mode 100644 index 0000000..8caa087 --- /dev/null +++ b/src/domain/sync/entities/SyncRegistry.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { createCardKey } from "@/domain/card/value-objects/CardKey"; +import { createContentHash } from "@/domain/card/value-objects/ContentHash"; + +import { SyncRegistry } from "./SyncRegistry"; + +describe("SyncRegistry", () => { + it("refreshes an existing record and clears orphan state", () => { + const registry = new SyncRegistry([ + { + cardKey: createCardKey("card-1"), + noteId: 101, + filePath: "notes/old.md", + sourceHash: createContentHash("old-hash"), + lastSyncedAt: 1, + orphan: true, + }, + ]); + + registry.refresh(createCardKey("card-1"), "notes/new.md", createContentHash("new-hash"), 10); + + expect(registry.get(createCardKey("card-1"))).toEqual({ + cardKey: createCardKey("card-1"), + noteId: 101, + filePath: "notes/new.md", + sourceHash: createContentHash("new-hash"), + lastSyncedAt: 10, + orphan: false, + }); + }); + + it("marks an existing record as orphan without deleting it", () => { + const registry = new SyncRegistry([ + { + cardKey: createCardKey("card-1"), + noteId: 101, + filePath: "notes/example.md", + sourceHash: createContentHash("hash"), + lastSyncedAt: 1, + orphan: false, + }, + ]); + + registry.markOrphan(createCardKey("card-1"), 20); + + expect(registry.get(createCardKey("card-1"))?.orphan).toBe(true); + expect(registry.get(createCardKey("card-1"))?.noteId).toBe(101); + }); +}); \ No newline at end of file diff --git a/src/domain/sync/entities/SyncRegistry.ts b/src/domain/sync/entities/SyncRegistry.ts new file mode 100644 index 0000000..35ab154 --- /dev/null +++ b/src/domain/sync/entities/SyncRegistry.ts @@ -0,0 +1,82 @@ +import type { CardKey } from "../../card/value-objects/CardKey"; +import type { ContentHash } from "../../card/value-objects/ContentHash"; +import type { SyncRecord } from "./SyncRecord"; + +export class SyncRegistry { + private readonly recordsByCardKey = new Map(); + private readonly cardKeysByNoteId = new Map(); + + constructor(records: SyncRecord[] = []) { + for (const record of records) { + this.upsert(record); + } + } + + get(cardKey: CardKey): SyncRecord | undefined { + return this.recordsByCardKey.get(cardKey); + } + + findByNoteId(noteId: number): SyncRecord | undefined { + const cardKey = this.cardKeysByNoteId.get(noteId); + return cardKey ? this.recordsByCardKey.get(cardKey) : undefined; + } + + list(): SyncRecord[] { + return Array.from(this.recordsByCardKey.values()); + } + + recordSync(record: SyncRecord): void { + this.upsert({ + ...record, + orphan: false, + }); + } + + refresh(cardKey: CardKey, filePath: string, sourceHash: ContentHash, lastSyncedAt: number): void { + const existing = this.requireRecord(cardKey); + + this.upsert({ + ...existing, + filePath, + sourceHash, + lastSyncedAt, + orphan: false, + }); + } + + markOrphan(cardKey: CardKey, lastSyncedAt: number): void { + const existing = this.requireRecord(cardKey); + + this.upsert({ + ...existing, + lastSyncedAt, + orphan: true, + }); + } + + upsert(record: SyncRecord): void { + const existingForNoteId = this.cardKeysByNoteId.get(record.noteId); + + if (existingForNoteId && existingForNoteId !== record.cardKey) { + throw new Error(`Note ${record.noteId} is already assigned to another card key.`); + } + + const previous = this.recordsByCardKey.get(record.cardKey); + if (previous && previous.noteId !== record.noteId) { + this.cardKeysByNoteId.delete(previous.noteId); + } + + this.recordsByCardKey.set(record.cardKey, record); + this.cardKeysByNoteId.set(record.noteId, record.cardKey); + } + + private requireRecord(cardKey: CardKey): SyncRecord { + const existing = this.recordsByCardKey.get(cardKey); + + if (!existing) { + throw new Error(`Sync record not found for card key ${cardKey}.`); + } + + return existing; + } +} \ No newline at end of file diff --git a/src/domain/sync/services/SyncPlanningService.test.ts b/src/domain/sync/services/SyncPlanningService.test.ts new file mode 100644 index 0000000..56f14e6 --- /dev/null +++ b/src/domain/sync/services/SyncPlanningService.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import type { Card } from "../../card/entities/Card"; +import { createCardKey } from "../../card/value-objects/CardKey"; +import { createContentHash } from "../../card/value-objects/ContentHash"; +import { createDeckName } from "../../card/value-objects/DeckName"; +import { createNoteModelName } from "../../card/value-objects/NoteModelName"; +import { SyncRegistry } from "../entities/SyncRegistry"; +import type { SyncRecord } from "../entities/SyncRecord"; +import { SyncPlanningService } from "./SyncPlanningService"; + +function createCard(overrides: Partial = {}): Card { + return { + key: createCardKey("card-a"), + source: { + filePath: "notes/example.md", + headingLine: 1, + blockStartLine: 1, + bodyStartLine: 2, + blockEndLine: 3, + headingLevel: 4, + headingText: "Prompt", + }, + type: "basic", + heading: "Prompt", + bodyMarkdown: "Answer", + deck: createDeckName("Deck"), + noteModel: createNoteModelName("Basic"), + tags: [], + renderedFields: { + kind: "basic", + values: { + front: "Prompt", + back: "Answer", + }, + }, + fields: { + front: "Prompt", + back: "Answer", + }, + contentHash: createContentHash("hash-a"), + media: [], + ...overrides, + }; +} + +function createRecord(overrides: Partial = {}): SyncRecord { + return { + cardKey: createCardKey("card-a"), + noteId: 100, + filePath: "notes/example.md", + sourceHash: createContentHash("hash-a"), + lastSyncedAt: 1, + orphan: false, + ...overrides, + }; +} + +describe("SyncPlanningService", () => { + it("plans adds, updates, and orphan marking", () => { + const service = new SyncPlanningService(); + const changedCard = createCard({ contentHash: createContentHash("hash-b") }); + const newCard = createCard({ + key: createCardKey("card-b"), + contentHash: createContentHash("hash-c"), + source: { + filePath: "notes/second.md", + headingLine: 1, + blockStartLine: 1, + bodyStartLine: 2, + blockEndLine: 3, + headingLevel: 4, + headingText: "Another", + }, + heading: "Another", + }); + const registry = new SyncRegistry([ + createRecord(), + createRecord({ + cardKey: createCardKey("orphan-card"), + noteId: 101, + filePath: "notes/orphan.md", + sourceHash: createContentHash("old"), + }), + ]); + + const plan = service.plan([changedCard, newCard], registry, ["notes/example.md", "notes/second.md", "notes/orphan.md"]); + + expect(plan.toAdd).toHaveLength(1); + expect(plan.toUpdate).toHaveLength(1); + expect(plan.toMarkOrphan).toHaveLength(1); + expect(plan.toCreateDecks).toEqual([createDeckName("Deck")]); + }); + + it("rejects duplicate card keys in one scan", () => { + const service = new SyncPlanningService(); + const card = createCard(); + + expect(() => service.plan([card, card], new SyncRegistry(), ["notes/example.md"])).toThrow( + "Duplicate card key detected in current scan", + ); + }); +}); \ No newline at end of file diff --git a/src/domain/sync/services/SyncPlanningService.ts b/src/domain/sync/services/SyncPlanningService.ts new file mode 100644 index 0000000..e6af780 --- /dev/null +++ b/src/domain/sync/services/SyncPlanningService.ts @@ -0,0 +1,42 @@ +import type { Card } from "../../card/entities/Card"; +import type { SyncRegistry } from "../entities/SyncRegistry"; +import type { SyncPlan } from "../value-objects/SyncPlan"; + +export class SyncPlanningService { + plan(cards: Card[], registry: SyncRegistry, scopedFilePaths: string[]): SyncPlan { + const seenKeys = new Set(); + const createDecks = new Map(); + const toAdd: Card[] = []; + const toUpdate: SyncPlan["toUpdate"] = []; + + for (const card of cards) { + if (seenKeys.has(card.key)) { + throw new Error(`Duplicate card key detected in current scan: ${card.key}`); + } + + seenKeys.add(card.key); + const existingRecord = registry.get(card.key); + + if (!existingRecord) { + toAdd.push(card); + createDecks.set(card.deck, card.deck); + continue; + } + + if (existingRecord.sourceHash !== card.contentHash || existingRecord.orphan) { + toUpdate.push({ card, noteId: existingRecord.noteId }); + createDecks.set(card.deck, card.deck); + } + } + + const scopedPaths = new Set(scopedFilePaths); + const toMarkOrphan = registry.list().filter((record) => scopedPaths.has(record.filePath) && !record.orphan && !seenKeys.has(record.cardKey)); + + return { + toCreateDecks: Array.from(createDecks.values()), + toAdd, + toUpdate, + toMarkOrphan, + }; + } +} \ No newline at end of file diff --git a/src/domain/sync/value-objects/SyncPlan.ts b/src/domain/sync/value-objects/SyncPlan.ts new file mode 100644 index 0000000..8ffd24d --- /dev/null +++ b/src/domain/sync/value-objects/SyncPlan.ts @@ -0,0 +1,15 @@ +import type { Card } from "../../card/entities/Card"; +import type { DeckName } from "../../card/value-objects/DeckName"; +import type { SyncRecord } from "../entities/SyncRecord"; + +export interface SyncPlanUpdate { + card: Card; + noteId: number; +} + +export interface SyncPlan { + toCreateDecks: DeckName[]; + toAdd: Card[]; + toUpdate: SyncPlanUpdate[]; + toMarkOrphan: SyncRecord[]; +} \ No newline at end of file diff --git a/src/infrastructure/anki/AnkiConnectGateway.ts b/src/infrastructure/anki/AnkiConnectGateway.ts new file mode 100644 index 0000000..84c3d21 --- /dev/null +++ b/src/infrastructure/anki/AnkiConnectGateway.ts @@ -0,0 +1,104 @@ +import { requestUrl } from "obsidian"; + +import type { NoteModelDetails } from "@/application/dto/NoteModelDetails"; +import type { AddAnkiNoteInput, AnkiGateway, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway"; +import type { MediaAsset } from "@/domain/card/entities/RenderedFields"; + +interface AnkiResponse { + error: string | null; + result: T; +} + +interface NoteInfo { + cards: number[]; +} + +type ModelTemplates = Record; + +export class AnkiConnectGateway implements AnkiGateway { + constructor(private readonly getBaseUrl: () => string) {} + + async ensureDeckExists(deckName: string): Promise { + await this.invoke("createDeck", { deck: deckName }); + } + + async getModelDetails(modelName: string): Promise { + const fieldNames = await this.invoke("modelFieldNames", { modelName }); + let isCloze = modelName.toLowerCase().includes("cloze"); + + try { + const templates = await this.invoke("modelTemplates", { modelName }); + isCloze = isCloze || Object.keys(templates).some((templateName) => templateName.toLowerCase().includes("cloze")); + } catch { + isCloze = isCloze || fieldNames.some((fieldName) => fieldName.toLowerCase() === "text"); + } + + return { + fieldNames, + isCloze, + }; + } + + async addNote(input: AddAnkiNoteInput): Promise { + return this.invoke("addNote", { + note: { + deckName: input.deckName, + modelName: input.modelName, + fields: input.fields, + options: { + allowDuplicate: false, + duplicateScope: "deck", + }, + tags: input.tags, + }, + }); + } + + async updateNote(input: UpdateAnkiNoteInput): Promise { + await this.invoke("updateNoteFields", { + note: { + id: input.noteId, + fields: input.fields, + }, + }); + + const noteInfo = await this.invoke("notesInfo", { + notes: [input.noteId], + }); + const cardIds = noteInfo[0]?.cards ?? []; + + if (cardIds.length > 0) { + await this.invoke("changeDeck", { + cards: cardIds, + deck: input.deckName, + }); + } + } + + async storeMedia(asset: MediaAsset): Promise { + await this.invoke("storeMediaFile", { + filename: asset.fileName, + path: asset.absolutePath, + }); + } + + private async invoke(action: string, params: Record): Promise { + const response = await requestUrl({ + url: this.getBaseUrl(), + method: "POST", + contentType: "application/json", + body: JSON.stringify({ + action, + version: 6, + params, + }), + }); + const parsed = response.json as AnkiResponse; + + if (parsed.error) { + throw new Error(parsed.error); + } + + return parsed.result; + } +} \ No newline at end of file diff --git a/src/infrastructure/obsidian/ObsidianPluginDataStore.ts b/src/infrastructure/obsidian/ObsidianPluginDataStore.ts new file mode 100644 index 0000000..2ecd45b --- /dev/null +++ b/src/infrastructure/obsidian/ObsidianPluginDataStore.ts @@ -0,0 +1,16 @@ +import type { Plugin } from "obsidian"; + +import type { PluginDataStore } from "@/application/ports/PluginDataStore"; + +export class ObsidianPluginDataStore implements PluginDataStore { + constructor(private readonly plugin: Plugin) {} + + async load(): Promise { + const data = await this.plugin.loadData(); + return (data as TData | null) ?? null; + } + + async save(data: TData): Promise { + await this.plugin.saveData(data); + } +} \ No newline at end of file diff --git a/src/infrastructure/obsidian/ObsidianVaultGateway.ts b/src/infrastructure/obsidian/ObsidianVaultGateway.ts new file mode 100644 index 0000000..e2c8b5a --- /dev/null +++ b/src/infrastructure/obsidian/ObsidianVaultGateway.ts @@ -0,0 +1,115 @@ +import { TFile } from "obsidian"; +import type { App } from "obsidian"; + +import type { VaultGateway } from "@/application/ports/VaultGateway"; +import { hashString } from "@/domain/shared/hash"; +import type { SourceLocation } from "@/domain/card/value-objects/SourceLocation"; + +const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "tiff"]); +const AUDIO_EXTENSIONS = new Set(["wav", "m4a", "flac", "mp3", "wma", "aac", "webm", "ogg"]); + +export class ObsidianVaultGateway implements VaultGateway { + constructor(private readonly app: App) {} + + async listMarkdownFiles() { + const files = this.app.vault.getMarkdownFiles(); + + return Promise.all(files.map((file) => this.toSourceFile(file))); + } + + async getMarkdownFile(path: string) { + const abstractFile = this.app.vault.getAbstractFileByPath(path); + + if (!(abstractFile instanceof TFile) || abstractFile.extension.toLowerCase() !== "md") { + return null; + } + + return this.toSourceFile(abstractFile); + } + + resolveWikiLink(rawTarget: string, sourcePath: string) { + const { alias, linkPath } = parseLinkTarget(rawTarget); + const destination = this.app.metadataCache.getFirstLinkpathDest(stripSubpath(linkPath), sourcePath); + const resolvedPath = destination?.path ?? stripSubpath(linkPath); + const displayText = alias || deriveDisplayText(linkPath); + + return { + url: this.createObsidianUrl(linkPath.includes("#") ? `${resolvedPath}${linkPath.slice(linkPath.indexOf("#"))}` : resolvedPath), + displayText, + }; + } + + resolveEmbed(rawTarget: string, sourcePath: string) { + const { alias, linkPath } = parseLinkTarget(rawTarget); + const destination = this.app.metadataCache.getFirstLinkpathDest(stripSubpath(linkPath), sourcePath); + + if (!(destination instanceof TFile)) { + return null; + } + + const extension = destination.extension.toLowerCase(); + const kind: "image" | "audio" | null = IMAGE_EXTENSIONS.has(extension) + ? "image" + : AUDIO_EXTENSIONS.has(extension) + ? "audio" + : null; + if (!kind) { + return null; + } + + const absolutePath = getFullPath(this.app, destination.path); + const hashedFileName = `${hashString(destination.path)}-${destination.name}`; + + return { + kind, + fileName: hashedFileName, + absolutePath, + altText: alias || destination.name, + }; + } + + createBacklink(location: SourceLocation): string { + return this.createObsidianUrl(`${location.filePath}#${location.headingText}`); + } + + private async toSourceFile(file: TFile) { + return { + path: file.path, + basename: file.basename, + content: await this.app.vault.cachedRead(file), + }; + } + + private createObsidianUrl(target: string): string { + return `obsidian://open?vault=${encodeURIComponent(this.app.vault.getName())}&file=${encodeURIComponent(target)}`; + } +} + +function parseLinkTarget(rawTarget: string): { alias?: string; linkPath: string } { + const [linkPath, alias] = rawTarget.split("|", 2).map((segment) => segment.trim()); + + return { + linkPath, + alias: alias || undefined, + }; +} + +function stripSubpath(linkPath: string): string { + return linkPath.split("#", 1)[0]; +} + +function deriveDisplayText(linkPath: string): string { + const withoutSubpath = stripSubpath(linkPath); + const lastSegment = withoutSubpath.split("/").pop() ?? withoutSubpath; + return lastSegment.replace(/\.[^.]+$/, "") || linkPath; +} + +function getFullPath(app: App, vaultPath: string): string { + const adapter = app.vault.adapter as { getFullPath?: (normalizedPath: string) => string }; + + if (!adapter.getFullPath) { + throw new Error("The active vault adapter does not expose an absolute filesystem path."); + } + + return adapter.getFullPath(vaultPath); +} \ No newline at end of file diff --git a/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts b/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts new file mode 100644 index 0000000..2e20db1 --- /dev/null +++ b/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts @@ -0,0 +1,44 @@ +import { DEFAULT_SETTINGS, type PluginSettings, validatePluginSettings } from "@/application/config/PluginSettings"; +import type { PluginConfigRepository } from "@/application/ports/PluginConfigRepository"; +import type { PluginDataStore } from "@/application/ports/PluginDataStore"; + +export interface PluginDataSnapshot { + settings?: Partial; + syncRegistry?: { + records: Array<{ + cardKey: string; + filePath: string; + lastSyncedAt: number; + noteId: number; + orphan: boolean; + sourceHash: string; + }>; + }; +} + +export class DataJsonPluginConfigRepository implements PluginConfigRepository { + constructor(private readonly pluginDataStore: PluginDataStore) {} + + async load(): Promise { + const snapshot = (await this.pluginDataStore.load()) ?? {}; + const mergedSettings: PluginSettings = { + ...DEFAULT_SETTINGS, + ...snapshot.settings, + includeFolders: snapshot.settings?.includeFolders ?? DEFAULT_SETTINGS.includeFolders, + excludeFolders: snapshot.settings?.excludeFolders ?? DEFAULT_SETTINGS.excludeFolders, + }; + + validatePluginSettings(mergedSettings); + return mergedSettings; + } + + async save(settings: PluginSettings): Promise { + validatePluginSettings(settings); + const snapshot = (await this.pluginDataStore.load()) ?? {}; + + await this.pluginDataStore.save({ + ...snapshot, + settings, + }); + } +} \ No newline at end of file diff --git a/src/infrastructure/persistence/DataJsonSyncRegistryRepository.test.ts b/src/infrastructure/persistence/DataJsonSyncRegistryRepository.test.ts new file mode 100644 index 0000000..8ce23bd --- /dev/null +++ b/src/infrastructure/persistence/DataJsonSyncRegistryRepository.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import type { PluginDataStore } from "@/application/ports/PluginDataStore"; +import { createCardKey } from "@/domain/card/value-objects/CardKey"; +import { createContentHash } from "@/domain/card/value-objects/ContentHash"; +import { SyncRegistry } from "@/domain/sync/entities/SyncRegistry"; + +import { DataJsonSyncRegistryRepository } from "./DataJsonSyncRegistryRepository"; +import type { PluginDataSnapshot } from "./DataJsonPluginConfigRepository"; + +class InMemoryPluginDataStore implements PluginDataStore { + private snapshot: PluginDataSnapshot | null = null; + + async load(): Promise { + return this.snapshot; + } + + async save(data: PluginDataSnapshot): Promise { + this.snapshot = data; + } +} + +describe("DataJsonSyncRegistryRepository", () => { + it("persists and restores sync records", async () => { + const store = new InMemoryPluginDataStore(); + const repository = new DataJsonSyncRegistryRepository(store); + const registry = new SyncRegistry([ + { + cardKey: createCardKey("card-1"), + noteId: 101, + filePath: "notes/example.md", + sourceHash: createContentHash("hash-1"), + lastSyncedAt: 10, + orphan: false, + }, + ]); + + await repository.save(registry); + const reloaded = await repository.load(); + + expect(reloaded.list()).toEqual(registry.list()); + }); +}); \ No newline at end of file diff --git a/src/infrastructure/persistence/DataJsonSyncRegistryRepository.ts b/src/infrastructure/persistence/DataJsonSyncRegistryRepository.ts new file mode 100644 index 0000000..31a4235 --- /dev/null +++ b/src/infrastructure/persistence/DataJsonSyncRegistryRepository.ts @@ -0,0 +1,44 @@ +import type { SyncRegistryRepository } from "@/application/ports/SyncRegistryRepository"; +import type { PluginDataSnapshot } from "@/infrastructure/persistence/DataJsonPluginConfigRepository"; +import type { PluginDataStore } from "@/application/ports/PluginDataStore"; +import { createCardKey } from "@/domain/card/value-objects/CardKey"; +import { createContentHash } from "@/domain/card/value-objects/ContentHash"; +import { SyncRegistry } from "@/domain/sync/entities/SyncRegistry"; + +export class DataJsonSyncRegistryRepository implements SyncRegistryRepository { + constructor(private readonly pluginDataStore: PluginDataStore) {} + + async load(): Promise { + const snapshot = await this.pluginDataStore.load(); + const records = snapshot?.syncRegistry?.records ?? []; + + return new SyncRegistry( + records.map((record) => ({ + cardKey: createCardKey(record.cardKey), + noteId: record.noteId, + filePath: record.filePath, + sourceHash: createContentHash(record.sourceHash), + lastSyncedAt: record.lastSyncedAt, + orphan: record.orphan, + })), + ); + } + + async save(registry: SyncRegistry): Promise { + const snapshot = (await this.pluginDataStore.load()) ?? {}; + + await this.pluginDataStore.save({ + ...snapshot, + syncRegistry: { + records: registry.list().map((record) => ({ + cardKey: record.cardKey, + noteId: record.noteId, + filePath: record.filePath, + sourceHash: record.sourceHash, + lastSyncedAt: record.lastSyncedAt, + orphan: record.orphan, + })), + }, + }); + } +} \ No newline at end of file diff --git a/src/presentation/AnkiHeadingSyncPlugin.ts b/src/presentation/AnkiHeadingSyncPlugin.ts new file mode 100644 index 0000000..85e3cb7 --- /dev/null +++ b/src/presentation/AnkiHeadingSyncPlugin.ts @@ -0,0 +1,104 @@ +import { Plugin } from "obsidian"; + +import { DEFAULT_SETTINGS, type PluginSettings } from "@/application/config/PluginSettings"; +import { ScanAndPlanSyncUseCase } from "@/application/use-cases/ScanAndPlanSyncUseCase"; +import { ExecuteSyncPlanUseCase } from "@/application/use-cases/ExecuteSyncPlanUseCase"; +import { SyncCurrentFileUseCase } from "@/application/use-cases/SyncCurrentFileUseCase"; +import { SyncVaultUseCase } from "@/application/use-cases/SyncVaultUseCase"; +import { AnkiConnectGateway } from "@/infrastructure/anki/AnkiConnectGateway"; +import { ObsidianPluginDataStore } from "@/infrastructure/obsidian/ObsidianPluginDataStore"; +import { ObsidianVaultGateway } from "@/infrastructure/obsidian/ObsidianVaultGateway"; +import { DataJsonPluginConfigRepository, type PluginDataSnapshot } from "@/infrastructure/persistence/DataJsonPluginConfigRepository"; +import { DataJsonSyncRegistryRepository } from "@/infrastructure/persistence/DataJsonSyncRegistryRepository"; +import { registerCommands } from "@/presentation/commands/registerCommands"; +import { NoticeService } from "@/presentation/notices/NoticeService"; +import { AnkiHeadingSyncSettingTab } from "@/presentation/settings/PluginSettingTab"; + +export default class AnkiHeadingSyncPlugin extends Plugin { + settings: PluginSettings = DEFAULT_SETTINGS; + + private readonly noticeService = new NoticeService(); + + private syncCurrentFileUseCase?: SyncCurrentFileUseCase; + private syncVaultUseCase?: SyncVaultUseCase; + private pluginConfigRepository?: DataJsonPluginConfigRepository; + + async onload(): Promise { + const pluginDataStore = new ObsidianPluginDataStore(this); + this.pluginConfigRepository = new DataJsonPluginConfigRepository(pluginDataStore); + const syncRegistryRepository = new DataJsonSyncRegistryRepository(pluginDataStore); + const vaultGateway = new ObsidianVaultGateway(this.app); + const ankiGateway = new AnkiConnectGateway(() => this.settings.ankiConnectUrl); + + try { + this.settings = await this.pluginConfigRepository.load(); + } catch (error) { + console.error("Failed to load plugin settings, falling back to defaults.", error); + this.settings = DEFAULT_SETTINGS; + this.noticeService.error("Invalid plugin settings were detected. Default settings were restored in memory."); + } + + const scanAndPlanSyncUseCase = new ScanAndPlanSyncUseCase(vaultGateway, syncRegistryRepository); + const executeSyncPlanUseCase = new ExecuteSyncPlanUseCase(ankiGateway, syncRegistryRepository); + this.syncCurrentFileUseCase = new SyncCurrentFileUseCase(scanAndPlanSyncUseCase, executeSyncPlanUseCase); + this.syncVaultUseCase = new SyncVaultUseCase(scanAndPlanSyncUseCase, executeSyncPlanUseCase); + + registerCommands(this); + this.addSettingTab(new AnkiHeadingSyncSettingTab(this)); + } + + async updateSettings(partialSettings: Partial): Promise { + if (!this.pluginConfigRepository) { + return; + } + + const nextSettings: PluginSettings = { + ...this.settings, + ...partialSettings, + }; + + try { + await this.pluginConfigRepository.save(nextSettings); + this.settings = nextSettings; + } catch (error) { + this.noticeService.error(error instanceof Error ? error.message : "Failed to save plugin settings."); + } + } + + async runSyncCurrentFile(): Promise { + const activeFile = this.app.workspace.getActiveFile(); + + if (!activeFile || activeFile.extension.toLowerCase() !== "md") { + this.noticeService.error("No active Markdown file is available for sync."); + return; + } + + if (!this.syncCurrentFileUseCase) { + this.noticeService.error("Sync use case is not initialized."); + return; + } + + try { + const result = await this.syncCurrentFileUseCase.execute(activeFile.path, this.settings); + this.noticeService.showSyncSummary("Current file sync finished", result); + } catch (error) { + console.error("Current file sync failed.", error); + this.noticeService.error(error instanceof Error ? error.message : "Current file sync failed."); + } + } + + async runSyncVault(): Promise { + if (!this.syncVaultUseCase) { + this.noticeService.error("Sync use case is not initialized."); + return; + } + + try { + const result = await this.syncVaultUseCase.execute(this.settings); + this.noticeService.showSyncSummary("Vault sync finished", result); + } catch (error) { + console.error("Vault sync failed.", error); + this.noticeService.error(error instanceof Error ? error.message : "Vault sync failed."); + } + } +} \ No newline at end of file diff --git a/src/presentation/commands/registerCommands.ts b/src/presentation/commands/registerCommands.ts new file mode 100644 index 0000000..c99b9c2 --- /dev/null +++ b/src/presentation/commands/registerCommands.ts @@ -0,0 +1,19 @@ +import type AnkiHeadingSyncPlugin from "@/presentation/AnkiHeadingSyncPlugin"; + +export function registerCommands(plugin: AnkiHeadingSyncPlugin): void { + plugin.addCommand({ + id: "sync-current-file-to-anki", + name: "Sync current file to Anki", + callback: () => { + void plugin.runSyncCurrentFile(); + }, + }); + + plugin.addCommand({ + id: "sync-vault-to-anki", + name: "Sync vault to Anki", + callback: () => { + void plugin.runSyncVault(); + }, + }); +} \ No newline at end of file diff --git a/src/presentation/notices/NoticeService.ts b/src/presentation/notices/NoticeService.ts new file mode 100644 index 0000000..01ead8a --- /dev/null +++ b/src/presentation/notices/NoticeService.ts @@ -0,0 +1,19 @@ +import { Notice } from "obsidian"; + +import type { ExecuteSyncPlanResult } from "@/application/use-cases/types"; + +export class NoticeService { + info(message: string): void { + new Notice(message, 5000); + } + + error(message: string): void { + new Notice(message, 8000); + } + + showSyncSummary(prefix: string, result: ExecuteSyncPlanResult): void { + this.info( + `${prefix}: scanned ${result.scanned}, created ${result.created}, updated ${result.updated}, orphaned ${result.markedOrphan}, media ${result.uploadedMedia}.`, + ); + } +} \ No newline at end of file diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts new file mode 100644 index 0000000..46dbcb3 --- /dev/null +++ b/src/presentation/settings/PluginSettingTab.ts @@ -0,0 +1,125 @@ +import { PluginSettingTab, Setting } from "obsidian"; + +import type AnkiHeadingSyncPlugin from "@/presentation/AnkiHeadingSyncPlugin"; + +export class AnkiHeadingSyncSettingTab extends PluginSettingTab { + constructor(plugin: AnkiHeadingSyncPlugin) { + super(plugin.app, plugin); + this.plugin = plugin; + } + + declare plugin: AnkiHeadingSyncPlugin; + + display(): void { + const { containerEl } = this; + const settings = this.plugin.settings; + + containerEl.empty(); + containerEl.createEl("h2", { text: "Anki Heading Sync" }); + + new Setting(containerEl) + .setName("AnkiConnect URL") + .setDesc("Default is http://127.0.0.1:8765") + .addText((text) => { + text.setPlaceholder("http://127.0.0.1:8765").setValue(settings.ankiConnectUrl).onChange((value) => { + void this.plugin.updateSettings({ ankiConnectUrl: value.trim() || settings.ankiConnectUrl }); + }); + }); + + new Setting(containerEl) + .setName("Default deck") + .setDesc("Used when a file does not define TARGET DECK") + .addText((text) => { + text.setValue(settings.defaultDeck).onChange((value) => { + void this.plugin.updateSettings({ defaultDeck: value }); + }); + }); + + new Setting(containerEl) + .setName("QA heading level") + .setDesc("Default is H4") + .addDropdown((dropdown) => { + for (let level = 1; level <= 6; level += 1) { + dropdown.addOption(String(level), `H${level}`); + } + + dropdown.setValue(String(settings.qaHeadingLevel)).onChange((value) => { + void this.plugin.updateSettings({ qaHeadingLevel: Number(value) }); + }); + }); + + new Setting(containerEl) + .setName("Cloze heading level") + .setDesc("Default is H5") + .addDropdown((dropdown) => { + for (let level = 1; level <= 6; level += 1) { + dropdown.addOption(String(level), `H${level}`); + } + + dropdown.setValue(String(settings.clozeHeadingLevel)).onChange((value) => { + void this.plugin.updateSettings({ clozeHeadingLevel: Number(value) }); + }); + }); + + new Setting(containerEl) + .setName("QA note type") + .setDesc("Default is Basic") + .addText((text) => { + text.setValue(settings.qaNoteType).onChange((value) => { + void this.plugin.updateSettings({ qaNoteType: value }); + }); + }); + + new Setting(containerEl) + .setName("Cloze note type") + .setDesc("Default is Cloze") + .addText((text) => { + text.setValue(settings.clozeNoteType).onChange((value) => { + void this.plugin.updateSettings({ clozeNoteType: value }); + }); + }); + + new Setting(containerEl) + .setName("Include folders") + .setDesc("Comma-separated folder paths. Empty means scan the whole vault.") + .addTextArea((textArea) => { + textArea.setValue(settings.includeFolders.join(", ")).onChange((value) => { + void this.plugin.updateSettings({ includeFolders: splitFolders(value) }); + }); + }); + + new Setting(containerEl) + .setName("Exclude folders") + .setDesc("Comma-separated folder paths always filtered out of vault sync.") + .addTextArea((textArea) => { + textArea.setValue(settings.excludeFolders.join(", ")).onChange((value) => { + void this.plugin.updateSettings({ excludeFolders: splitFolders(value) }); + }); + }); + + new Setting(containerEl) + .setName("Add Obsidian backlink") + .setDesc("Append a backlink to the source heading into synced cards.") + .addToggle((toggle) => { + toggle.setValue(settings.addObsidianBacklink).onChange((value) => { + void this.plugin.updateSettings({ addObsidianBacklink: value }); + }); + }); + + new Setting(containerEl) + .setName("Highlights to Cloze") + .setDesc("Convert ==highlight== segments into cloze deletions for cloze cards.") + .addToggle((toggle) => { + toggle.setValue(settings.convertHighlightsToCloze).onChange((value) => { + void this.plugin.updateSettings({ convertHighlightsToCloze: value }); + }); + }); + } +} + +function splitFolders(value: string): string[] { + return value + .split(",") + .map((segment) => segment.trim()) + .filter(Boolean); +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8facdf0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "types": [ + "node", + "vitest/globals" + ], + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "include": [ + "main.ts", + "src/**/*.ts", + "vitest.config.ts" + ] +} \ No newline at end of file diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..a23e9ea --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "1.0.0": "1.5.0" +} \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..78d07c0 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,16 @@ +import { resolve } from "node:path"; + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@": resolve(__dirname, "src"), + }, + }, + test: { + environment: "node", + globals: true, + include: ["src/**/*.test.ts"], + }, +}); \ No newline at end of file