diff --git a/docs/qa-group-12-decisions.md b/docs/qa-group-12-decisions.md new file mode 100644 index 0000000..2bc81d8 --- /dev/null +++ b/docs/qa-group-12-decisions.md @@ -0,0 +1,191 @@ +# QA Group 12 Decisions + +## Decision Summary + +基于真实仓库结构,本轮锁定如下实现决策: + +1. 保留现有 atomic/basic、cloze、semantic-qa 路线不变。 +2. 新增一条并行的 QA Group 路线,使用单独 marker:`qaGroupMarker`。 +3. 新路线不强塞进 `IndexedCard` / `DiffPlannerService` / `AnkiBatchExecutor` 的单卡单 note 主链。 +4. group route 使用专用 block model、state、write-back 和 sync executor。 +5. `GroupId` 是 GI 丢失恢复的主锚点;`Src` 只做辅锚点。 +6. v1 固定目标 model:`ObsiAnki QA Group 12`。 +7. v1 不做 group model 的 settings field mapping UI。 +8. v1 的 `A` 只取第一条二级列表项文本。 + +## Concrete Decisions + +### 1. New setting name and validation + +新增设置: + +- `qaGroupMarker` + +默认值: + +- `#anki-list` + +校验规则: + +- 必须符合与 semantic marker 同级的 hashtag token 规则 +- 不允许等于 `semanticQaMarker` + +实现上复用现有 hashtag-style validation helper,而不是创建第二套正则协议。 + +### 2. Routing priority + +QA heading 路由顺序固定为: + +1. 命中 `qaGroupMarker` -> QA Group route +2. 命中 `semanticQaMarker` -> existing semantic-qa route +3. 其他命中 QA level 的 heading -> existing basic route + +这样能确保两个 marker 可并存且互不吞噬。 + +### 3. Group route data model stays parallel + +新增并行模型,不复用 `IndexedCard` 作为 group block 的核心表示。仓库中应存在这些概念: + +- group marker representation +- group item representation +- indexed group block representation +- group note payload builder +- recovered group state + +原因: + +- 当前 `IndexedCard` 强绑定单 `noteId` +- 当前 `PluginState.cards` 强绑定 noteId-keyed atomic state +- Group 12 需要在一条 note 内维护 slot identity + +### 4. Parser rules for v1 + +v1 固定规则: + +- `Stem` = heading 去 marker 后的文本 +- `Q` = 一级列表项文本 +- `A` = 第一条二级列表项文本 +- deeper nested content 不进入 `A` +- fenced code blocks 不能被误判成列表 +- 块尾允许存在一条 `GI` + +无效一级项定义: + +- 没有二级列表项 + +处理策略: + +- 直接跳过,不生成 group item + +### 5. GI protocol is explicit and strict + +采用目标格式: + +```md + +``` + +语义锁定: + +- `n` = noteId +- `i` = itemId -> slot +- `f` = free slots + +附加规则: + +- GI 固定写在 group block 尾部 +- group write-back 只保留一条 GI +- 命中 group route 的块会清理内部旧 `` + +### 6. Item identity strategy + +首次进入组时,插件为每个 item 分配短 token `itemId`。 + +本轮采用: + +- 轻量 deterministic short token generator +- 一旦分配并写入 GI/state,后续重排不改 `itemId` + +slot 规则: + +- 首次 `1..N` +- 删除中间项时进入 `freeSlots` +- 新增项优先复用最小 free slot +- 重排不改 slot +- 超过 12 项直接阻止同步 + +### 7. Recovery order + +GI 丢失恢复顺序固定: + +1. 先查本地 group state by `GroupId` +2. 若本地无充分信息,再查询 Anki 中同 model note 的 `GroupId` +3. `Src` 仅用于辅助缩小范围和校验唯一性 +4. 唯一命中可重建 GI +5. 多命中返回 warning/error,不自动认领 +6. 未命中则走首次创建 + +### 8. Model management strategy + +`AnkiGateway` 扩展 group model 管理接口,至少支持: + +- ensure model exists +- inspect fields +- inspect templates +- create model +- add fields +- add templates +- update templates +- update styling +- query note ids for recovery +- read note fields for recovery + +但这些能力只为 group route 服务,不反向改造 basic/cloze/semantic-qa 的 note field mapping 工作流。 + +### 9. Execution integration point + +group route 的执行入口放在 `ManualSyncService` 中,与现有 atomic sync 并列。 + +原因: + +- `ManualSyncService` 已持有 index / render / execute / write-back / save state 的总编排能力 +- 在这里并联 group 流最容易共享 deck / vault / repository / gateway 能力 +- 也最容易保证旧路线不受影响 + +### 10. Settings UI scope control + +只新增最小设置项: + +- `QA Group marker` + +本轮不新增: + +- group model selector +- group field mapping panel +- group preview complex panel + +原因: + +- v1 模型固定 +- 字段协议固定 +- 复杂 UI 会扩大 scope 而不增加正确性 + +## Intentional Deviations From The Reference Plan + +### 1. 不把 group route 接进现有 `DiffPlannerService` + +这是对参考方案的结构性调整。 + +原因:当前仓库的 planner/executor/state 都是单卡单 note 语义;强行复用会显著增加回归风险。 + +### 2. parser 层不引入大而全 warning 总线 + +原因:当前仓库没有统一 parser warnings 模型。本轮只在需要阻止同步的地方返回结构化错误,其余无效项直接跳过。 + +### 3. `A` 只取第一条二级列表项纯文本/markdown 内容 + +原因:这是最符合当前仓库复杂度预算的 v1 规则,也更容易保证 slot 稳定与恢复逻辑清晰。 + +### 4. 不做 group field mapping UI + +原因:目标 model 固定,直接生成完整 field payload 更适合当前代码结构。 \ No newline at end of file diff --git a/docs/qa-group-12-gap-report.md b/docs/qa-group-12-gap-report.md new file mode 100644 index 0000000..8e9477a --- /dev/null +++ b/docs/qa-group-12-gap-report.md @@ -0,0 +1,239 @@ +# QA Group 12 Gap Report + +## Scope + +本报告基于真实仓库现状,审查当前 manual-sync 主链与“`#anki-list` QA Group 12 并行路线”之间的差距。 + +重点检查: + +- `CardIndexingService` +- `SemanticQaListParser` +- `DiffPlannerService` +- `AnkiBatchExecutor` +- `MarkdownWriteBackService` +- `PluginState` +- `AnkiConnectGateway` +- settings / validation / settings UI + +## Current Reality + +### 1. 当前主链仍然是单卡 `IndexedCard` 主线 + +真实主链为: + +- `FileIndexerService` +- `CardIndexingService` +- `DiffPlannerService` +- `ManualCardRenderer` +- `AnkiBatchExecutor` +- `MarkdownWriteBackService` +- `PluginState` + +这条链的核心假设是: + +- 一个索引单元对应一个 `IndexedCard` +- 一个 `IndexedCard` 对应一个 `noteId` +- `PluginState.cards` 以 `noteId` 为 key +- `DiffPlannerService` 以 `noteId` 和 `renderConfigHash` 做单 note diff +- `AnkiBatchExecutor` 的 create/update 流程也是一条 card -> 一条 note + +这意味着 QA Group 12 不能无损塞进现有 `IndexedCard` / `CardState` / `ManualSyncPlan`,否则会把 group block 强行伪装成“单题单 note 的普通卡”。 + +### 2. 现有 `semantic-qa` 只是单卡主线上的多卡拆分 + +当前 `semantic-qa` 的真实实现是: + +- `CardIndexingService` 命中 `semanticQaMarker` +- `SemanticQaListParser` 把一个标题块拆成多个 `SemanticQaListCard` +- 每个子项都转成独立 `IndexedCard` +- 每个子项独立持有 `noteId` +- 每个子项独立写 `` + +所以它不是 group note,它仍属于 atomic 路线。 + +### 3. 当前 write-back 只理解 `` + +当前 marker 写回链路: + +- `CardMarkerService` 只支持 `` +- `MarkdownWriteBackService` 只会调用 `CardMarkerService.applyBatch()` +- `PendingWriteBackState` 只存单卡 marker 的 block key / rawBlockHash / targetNoteId + +没有: + +- `GI` 协议 +- 组级 marker 解析/序列化 +- group block 的旧 inner marker 清理 +- group block 的 repair 逻辑 + +### 4. 当前 state 只有 noteId-keyed atomic card state + +`PluginState` 当前只有: + +- `files` +- `cards` +- `pendingWriteBack` + +其中: + +- `cards` 只适合单卡记录 +- key 是 `noteId` +- 没有 `GroupId` +- 没有 `itemId -> slot` +- 没有 group-level recovery state + +### 5. 当前 Anki gateway 只具备基础 note/deck 能力 + +`AnkiGateway` / `AnkiConnectGateway` 当前支持: + +- deck ensure/list/stats +- model name list +- model field names + templates 只在 `getModelDetails()` 内部做只读判断 +- notesInfo/cardsInfo +- add/update/delete note +- change deck +- media upload + +缺失本需求需要的能力: + +- create model +- add fields +- add templates +- update templates +- update styling +- 按查询语句查 notes 以支持 recovery +- 获取 note fields 以恢复 slot 映射 + +### 6. 当前 settings 只对 semantic QA 有最小配置支持 + +当前 settings 已新增: + +- `semanticQaMarker` +- `semanticQaNoteType` + +settings UI 中也已有: + +- `Semantic QA marker` +- semantic QA preview +- semantic QA mapping controls + +但 QA Group 12 需要的最小新增项还不存在: + +- `qaGroupMarker` +- 与 semantic marker 的冲突校验 + +### 7. 当前 field mapping UI 与 group route 不匹配 + +当前 `NoteFieldMappingService` 和 settings mapping UI 面向: + +- `basic` +- `cloze` +- `semantic-qa` + +这些路线都属于“插件生成 rendered title/body,再映射到 note fields”。 + +QA Group 12 的目标是固定模型协议: + +- `Stem` +- `GroupId` +- `Src` +- `S01..S12` + +这更适合 dedicated payload builder,而不是重用可配置 field mapping UI。 + +## What Fits The Reference Plan Well + +以下点与真实仓库吻合: + +1. 新路线应保持并行,不要破坏现有 semantic-qa。 +2. 新路线应使用独立 marker。 +3. `GroupId` 必须成为恢复主锚点。 +4. `GI` 需要独立协议,而不是复用现有 ``。 +5. Group 12 不适合走现有 note field mapping UI。 +6. 需要补 Anki model 管理与 note 查询能力。 + +## What Needs Adjustment To Match This Repository + +### 1. 不能把 group route 硬塞进 `DiffPlannerService` + +参考方案强调并行类型,这一点在当前仓库里不是“可选优化”,而是必须条件。 + +原因: + +- `DiffPlannerService` 的 `ManualSyncPlan` 全部是 `PlannedCard` +- `PlannedCard` 内核是 `IndexedCard + noteId` +- group route 一条 block 对应一条 note,但内部又有 1..12 个 slot 和 item-level identity + +因此更合适的接法是: + +- atomic 路线继续走现有 `IndexedCard -> DiffPlannerService -> AnkiBatchExecutor` +- group 路线在 `CardIndexingService` 之后并联出单独的 group block collection +- `ManualSyncService` 再显式执行一条 group-specific sync 分支 + +### 2. 当前 parser 层没有 warning 容器 + +参考方案里大量提到“skip 或 warning”。 + +当前 `CardIndexingService` / `SemanticQaListParser` 没有结构化 warning 输出面。若强行扩大会影响现有大量签名。 + +更适配当前仓库的做法是: + +- parser 层以 deterministic parse result 为主 +- 明确无效项直接跳过 +- 对影响同步正确性的情况抛结构化 error +- warning 只在必要的 service 结果层补充 + +### 3. GI 丢失恢复不能只靠当前 `PluginState` + +因为当前 state 只按 `noteId` 记 atomic card,group 需要新 state 分支,例如: + +- `groupCards` 或等价 group state map +- key 为 `GroupId` + +否则无法实现“先查本地 `GroupId`,再查 Anki”的恢复顺序。 + +### 4. v1 里的 `A` 规则必须简化为仓库可控实现 + +当前 semantic parser 对 deeper content 的处理是“整段 child region dedent 后作为 answer”。 + +但本需求明确希望 QA Group 12 v1 只取第一条二级列表项文本。为避免把 group route 复杂化成另一个 semantic parser 变体,应固定: + +- 只取第一条二级列表项 +- 更深内容不进入 `A` +- 若存在多个二级项,保留第一条,其他项忽略 + +这样最符合当前仓库的最小安全实现风格。 + +## Missing Pieces To Build + +1. 新 settings 字段与校验:`qaGroupMarker` +2. 新 group domain model:marker/item/block/state/payload +3. 新 parser / serializer:GI + QA Group block +4. 新 group write-back service +5. 新 group state persistence +6. 新 Anki gateway model management / note query / field recovery 能力 +7. 新 ManualSyncService group 分支 +8. 新测试夹具与端到端回归 + +## Scope Drift Risks + +以下内容不应在本轮扩张: + +1. 动态 arity group model +2. group model field mapping UI +3. 自动执行 `Empty Cards` +4. 旧 semantic-qa 到 group route 的自动迁移 +5. 复杂的多级答案结构合并 +6. 重构整个 `ManualSyncPlan` + +## Recommended Integration Path + +最适合当前仓库的路径是: + +1. 保留现有 atomic/basic/cloze/semantic-qa 全链路不动。 +2. 在 `CardIndexingService` 内部新增 group route 识别,但 group 结果不转成 `IndexedCard`。 +3. `FileIndexerService` 返回 atomic cards 之外,再返回 group block 集合。 +4. `ManualSyncService` 在现有 atomic sync 前后增加显式 group sync 分支。 +5. group route 拥有自己的 state、payload builder、write-back、recovery 和 gateway helper。 + +这条路径最符合当前仓库现实,也最容易保证旧路线不回归。 \ No newline at end of file diff --git a/docs/qa-group-12-implementation-plan.md b/docs/qa-group-12-implementation-plan.md new file mode 100644 index 0000000..ba1e6da --- /dev/null +++ b/docs/qa-group-12-implementation-plan.md @@ -0,0 +1,127 @@ +# QA Group 12 Implementation Plan + +## Status + +本文件用于把本轮需求参考方案落盘到仓库中。它是强参考,不是自动生效的最终实现真相。 + +最终实现仍需以真实代码审查结果为准,并以: + +- `docs/qa-group-12-gap-report.md` +- `docs/qa-group-12-decisions.md` + +作为仓库内的实际实施依据。 + +## Feature Intent + +新增一条并行的 `#anki-list` QA Group 12 路线,同时保留现有: + +- atomic / basic +- cloze +- semantic-qa / `#anki-list-qa` + +不动。 + +目标行为: + +1. 一个命中的 Markdown heading block 同步为一条 Anki note。 +2. 该 note 固定使用 `ObsiAnki QA Group 12`。 +3. Markdown 只写回一条组级 `GI` marker。 +4. `GroupId` 是 GI 丢失恢复的主锚点。 +5. v1 固定 12 个 slot,不做动态 arity model。 + +## Reference Summary + +### Marker and routing + +- 新增 `qaGroupMarker` +- 默认 `#anki-list` +- 必须是 hashtag token +- 不允许与 `semanticQaMarker` 相同 +- 路由优先级: + - 命中新 `qaGroupMarker` -> group route + - 命中旧 `semanticQaMarker` -> semantic-qa route + - 其他 QA 标题 -> 普通 basic route + +### Parallel internal model + +参考方案要求新路线使用并行数据模型,不强行塞进现有单卡结构。核心概念包括: + +- `GroupMarker` +- `GroupItem` +- `IndexedGroupCardBlock` +- `GroupSyncPayload` +- `RecoveredGroupState` + +### Parsing protocol + +触发条件: + +- 标题级别等于当前 QA heading level +- 标题命中 `qaGroupMarker` +- 标题下存在一级列表 +- 一级项必须至少有一条二级项才算有效 +- 块尾允许存在一条 `GI` + +v1 取值规则: + +- `Stem` = 标题去 marker +- `Q` = 一级列表项文本 +- `A` = 第一条二级列表项文本 + +GI 目标格式: + +```md + +``` + +语义: + +- `n` = noteId +- `i` = itemId -> slot +- `f` = free slots + +### Slot and recovery semantics + +- 首次创建按顺序分配 `1..N` +- 删除中间项保留空洞 +- 新增项优先复用最小 free slot +- 重排不改变 slot +- 超过 12 项阻止同步 +- GI 丢失恢复以 `GroupId` 为主锚点,`Src` 为辅锚点 + +### Anki model management + +参考方案要求补齐 group model 管理能力: + +- ensure model exists +- inspect fields/templates +- create model if missing +- add missing fields/templates +- reconcile template/CSS drift +- query notes for recovery + +固定目标 model: + +- `ObsiAnki QA Group 12` + +### Write-back and state + +- group block 只保留一条 `GI` +- 命中 group route 的块写回时清理内部旧 `` +- state 需要新增以 `GroupId` 为主键的 group state + +### Required validation surface + +最少覆盖: + +- GI 解析与序列化 +- slot 分配与稳定性 +- GI 写回与旧 inner marker 清理 +- GroupId 恢复 +- model 自动创建/补齐/漂移修复 +- 端到端 sibling cards 生成 +- 原有 basic/cloze/semantic-qa 不回归 + +## Implementation Note + +本文件只保留参考方案的需求意图和约束摘要,避免后续实现过程中偏离用户 feature contract。 \ No newline at end of file diff --git a/src/application/config/PluginSettings.test.ts b/src/application/config/PluginSettings.test.ts index b4c4571..a489789 100644 --- a/src/application/config/PluginSettings.test.ts +++ b/src/application/config/PluginSettings.test.ts @@ -32,6 +32,23 @@ describe("PluginSettings", () => { expect(DEFAULT_SETTINGS.fileDeckTemplate).toBe("obsidian::filename"); expect(DEFAULT_SETTINGS.fileDeckInsertLocation).toBe("body"); expect(DEFAULT_SETTINGS.folderDeckMode).toBe("off"); + expect(DEFAULT_SETTINGS.qaGroupMarker).toBe("#anki-list"); + }); + + it("rejects invalid QA Group markers and semantic marker collisions", () => { + expect(() => + validatePluginSettings({ + ...DEFAULT_SETTINGS, + qaGroupMarker: "anki-list", + }), + ).toThrow("QA Group marker must be a hashtag-style token like #anki-list."); + + expect(() => + validatePluginSettings({ + ...DEFAULT_SETTINGS, + qaGroupMarker: "#anki-list-qa", + }), + ).toThrow("QA Group marker must be different from Semantic QA marker."); }); it("rejects invalid module 5 enum values", () => { diff --git a/src/application/config/PluginSettings.ts b/src/application/config/PluginSettings.ts index ee013c9..615bcb2 100644 --- a/src/application/config/PluginSettings.ts +++ b/src/application/config/PluginSettings.ts @@ -7,6 +7,7 @@ export type FolderDeckMode = "off" | "folder" | "folder-and-file"; export interface PluginSettings { qaHeadingLevel: number; clozeHeadingLevel: number; + qaGroupMarker: string; qaNoteType: string; clozeNoteType: string; semanticQaMarker: string; @@ -29,6 +30,7 @@ export interface PluginSettings { export const DEFAULT_SETTINGS: PluginSettings = { qaHeadingLevel: 4, clozeHeadingLevel: 5, + qaGroupMarker: "#anki-list", qaNoteType: "Basic", clozeNoteType: "Cloze", semanticQaMarker: "#anki-list-qa", @@ -50,10 +52,14 @@ export const DEFAULT_SETTINGS: PluginSettings = { export const SEMANTIC_QA_MARKER_REGEXP = /^#[^\s#]+$/; -export function isValidSemanticQaMarker(marker: string): boolean { +export function isValidHashtagMarker(marker: string): boolean { return SEMANTIC_QA_MARKER_REGEXP.test(marker); } +export function isValidSemanticQaMarker(marker: string): boolean { + return isValidHashtagMarker(marker); +} + export function validatePluginSettings(settings: PluginSettings): void { const headingLevels = [settings.qaHeadingLevel, settings.clozeHeadingLevel]; @@ -71,6 +77,14 @@ export function validatePluginSettings(settings: PluginSettings): void { throw new Error("QA note type is required."); } + if (!settings.qaGroupMarker.trim()) { + throw new Error("QA Group marker is required."); + } + + if (!isValidHashtagMarker(settings.qaGroupMarker.trim())) { + throw new Error("QA Group marker must be a hashtag-style token like #anki-list."); + } + if (!settings.clozeNoteType.trim()) { throw new Error("Cloze note type is required."); } @@ -83,6 +97,10 @@ export function validatePluginSettings(settings: PluginSettings): void { throw new Error("Semantic QA marker must be a hashtag-style token like #anki-list-qa."); } + if (settings.qaGroupMarker.trim() === settings.semanticQaMarker.trim()) { + throw new Error("QA Group marker must be different from Semantic QA marker."); + } + if (!settings.semanticQaNoteType.trim()) { throw new Error("Semantic QA note type is required."); } diff --git a/src/application/ports/AnkiGateway.ts b/src/application/ports/AnkiGateway.ts index ab5940c..e5cc652 100644 --- a/src/application/ports/AnkiGateway.ts +++ b/src/application/ports/AnkiGateway.ts @@ -31,6 +31,24 @@ export interface DeckStat { noteCount?: number; } +export interface AnkiModelTemplate { + name: string; + front: string; + back: string; +} + +export interface CreateAnkiModelInput { + modelName: string; + fieldNames: string[]; + templates: AnkiModelTemplate[]; + css: string; + isCloze?: boolean; +} + +export interface AnkiNoteDetails extends AnkiNoteSummary { + fields: Record; +} + export interface AnkiGateway { ensureDeckExists(deckName: string): Promise; ensureDecks(deckNames: string[]): Promise; @@ -48,4 +66,17 @@ export interface AnkiGateway { deleteDecks(deckNames: string[]): Promise; storeMedia(asset: MediaAsset): Promise; storeMediaFiles(assets: MediaAsset[]): Promise; +} + +export interface AnkiGroupGateway extends AnkiGateway { + getModelFieldNames(modelName: string): Promise; + getModelTemplates(modelName: string): Promise>; + getModelStyling(modelName: string): Promise; + createModel(input: CreateAnkiModelInput): Promise; + addModelField(modelName: string, fieldName: string): Promise; + addModelTemplate(modelName: string, template: AnkiModelTemplate): Promise; + updateModelTemplate(modelName: string, template: AnkiModelTemplate): Promise; + updateModelStyling(modelName: string, css: string): Promise; + findNoteIds(query: string): Promise; + getNoteDetails(noteIds: number[]): Promise; } \ No newline at end of file diff --git a/src/application/services/FileIndexerService.ts b/src/application/services/FileIndexerService.ts index 88b6ad2..885120f 100644 --- a/src/application/services/FileIndexerService.ts +++ b/src/application/services/FileIndexerService.ts @@ -2,8 +2,9 @@ import type { PluginSettings } from "@/application/config/PluginSettings"; import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway"; import { ScanScopeService } from "@/application/services/ScanScopeService"; import { createIndexedCardSyncKey, type IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; +import { buildGroupSrc, createIndexedGroupSyncKey, type IndexedGroupCardBlock } from "@/domain/manual-sync/entities/IndexedGroupCardBlock"; import type { IndexedFile } from "@/domain/manual-sync/entities/IndexedFile"; -import { toNoteIdKey, type CardState, type PendingWriteBackState, type PluginState } from "@/domain/manual-sync/entities/PluginState"; +import { toNoteIdKey, type CardState, type GroupBlockState, type PendingWriteBackState, type PluginState } from "@/domain/manual-sync/entities/PluginState"; import { CardIndexingService } from "@/domain/manual-sync/services/CardIndexingService"; import { hashString } from "@/domain/shared/hash"; @@ -12,12 +13,14 @@ export interface FileIndexerResult { scannedFiles: number; indexedFiles: IndexedFile[]; cards: IndexedCard[]; + groupBlocks: IndexedGroupCardBlock[]; skippedUnchangedFiles: number; skippedUnchangedCards: number; } interface StateIndex { cardsByFilePath: Map; + groupBlocksByFilePath: Map; pendingByFilePath: Map; } @@ -52,9 +55,11 @@ export class FileIndexerService { const indexedFile = this.cardIndexingService.index(sourceFile, { qaHeadingLevel: settings.qaHeadingLevel, clozeHeadingLevel: settings.clozeHeadingLevel, + qaGroupMarker: settings.qaGroupMarker, semanticQaMarker: settings.semanticQaMarker, fileStamp, knownCards: stateIndex.cardsByFilePath.get(filePath) ?? [], + knownGroupBlocks: stateIndex.groupBlocksByFilePath.get(filePath) ?? [], pendingWriteBack: stateIndex.pendingByFilePath.get(filePath) ?? [], fileDeckEnabled: settings.fileDeckEnabled, fileDeckMarker: settings.fileDeckMarker, @@ -65,6 +70,7 @@ export class FileIndexerService { scannedFiles: 1, indexedFiles: [indexedFile], cards: indexedFile.cards, + groupBlocks: indexedFile.groupBlocks ?? [], skippedUnchangedFiles: 0, skippedUnchangedCards: 0, }; @@ -79,6 +85,7 @@ export class FileIndexerService { ): Promise { const indexedFiles: IndexedFile[] = []; const cards: IndexedCard[] = []; + const groupBlocks: IndexedGroupCardBlock[] = []; let skippedUnchangedFiles = 0; let skippedUnchangedCards = 0; const deckRulesFingerprint = createDeckRulesFingerprint(settings); @@ -100,7 +107,11 @@ export class FileIndexerService { if (!shouldRead) { skippedUnchangedFiles += 1; - skippedUnchangedCards += (existingFileState?.noteIds ?? []).length; + const restoredGroups = (existingFileState?.groupIds ?? []) + .map((groupId) => state.groupBlocks?.[groupId]) + .filter((groupBlock): groupBlock is GroupBlockState => Boolean(groupBlock)) + .map((groupBlock) => restoreIndexedGroupBlock(groupBlock)); + skippedUnchangedCards += (existingFileState?.noteIds ?? []).length + restoredGroups.length; const restoredCards = (existingFileState?.noteIds ?? []) .map((noteId) => state.cards[toNoteIdKey(noteId)]) .filter((card): card is CardState => Boolean(card)) @@ -111,8 +122,10 @@ export class FileIndexerService { fileHash: existingFileState?.fileHash ?? "", fileStamp, cards: restoredCards, + groupBlocks: restoredGroups, }); cards.push(...restoredCards); + groupBlocks.push(...restoredGroups); continue; } @@ -124,9 +137,11 @@ export class FileIndexerService { const indexedFile = this.cardIndexingService.index(sourceFile, { qaHeadingLevel: settings.qaHeadingLevel, clozeHeadingLevel: settings.clozeHeadingLevel, + qaGroupMarker: settings.qaGroupMarker, semanticQaMarker: settings.semanticQaMarker, fileStamp, knownCards, + knownGroupBlocks: stateIndex.groupBlocksByFilePath.get(ref.path) ?? [], pendingWriteBack, fileDeckEnabled: settings.fileDeckEnabled, fileDeckMarker: settings.fileDeckMarker, @@ -134,6 +149,7 @@ export class FileIndexerService { indexedFiles.push(indexedFile); cards.push(...indexedFile.cards); + groupBlocks.push(...(indexedFile.groupBlocks ?? [])); } return { @@ -141,6 +157,7 @@ export class FileIndexerService { scannedFiles: refs.length, indexedFiles, cards, + groupBlocks, skippedUnchangedFiles, skippedUnchangedCards, }; @@ -148,6 +165,7 @@ export class FileIndexerService { private buildStateIndex(state: PluginState): StateIndex { const cardsByFilePath = new Map(); + const groupBlocksByFilePath = new Map(); const pendingByFilePath = new Map(); for (const card of Object.values(state.cards)) { @@ -160,6 +178,16 @@ export class FileIndexerService { cardsByFilePath.set(card.filePath, [card]); } + for (const groupBlock of Object.values(state.groupBlocks ?? {})) { + const groupBlocks = groupBlocksByFilePath.get(groupBlock.filePath); + if (groupBlocks) { + groupBlocks.push(groupBlock); + continue; + } + + groupBlocksByFilePath.set(groupBlock.filePath, [groupBlock]); + } + for (const pending of state.pendingWriteBack) { const pendings = pendingByFilePath.get(pending.filePath); if (pendings) { @@ -172,6 +200,7 @@ export class FileIndexerService { return { cardsByFilePath, + groupBlocksByFilePath, pendingByFilePath, }; } @@ -205,6 +234,37 @@ function restoreIndexedCard(card: CardState): IndexedCard { }; } +function restoreIndexedGroupBlock(groupBlock: GroupBlockState): IndexedGroupCardBlock { + return { + noteId: groupBlock.noteId, + groupId: groupBlock.groupId, + syncKey: createIndexedGroupSyncKey(groupBlock.filePath, groupBlock.blockStartLine, groupBlock.rawBlockHash), + markerState: "present-valid", + identitySource: "state-recovery", + filePath: groupBlock.filePath, + headingText: groupBlock.headingText, + backlinkHeadingText: groupBlock.backlinkHeadingText, + headingLevel: groupBlock.headingLevel, + stem: groupBlock.stem, + src: groupBlock.src || buildGroupSrc(groupBlock.filePath, groupBlock.backlinkHeadingText), + blockStartOffset: groupBlock.blockStartOffset, + blockEndOffset: groupBlock.blockEndOffset, + blockStartLine: groupBlock.blockStartLine, + bodyStartLine: groupBlock.bodyStartLine, + blockEndLine: groupBlock.blockEndLine, + contentEndLine: groupBlock.contentEndLine, + markerLine: groupBlock.markerLine, + markerIndent: groupBlock.markerIndent, + rawBlockText: groupBlock.rawBlockText, + rawBlockHash: groupBlock.rawBlockHash, + deckHint: groupBlock.deckHint, + deckHintSource: groupBlock.deckHintSource, + deckWarnings: [...groupBlock.deckWarnings], + items: groupBlock.items.map((item) => ({ ...item })), + freeSlots: [...groupBlock.freeSlots], + }; +} + export function createFileStamp(mtime: number, size: number): string { return `${mtime}:${size}`; } @@ -216,6 +276,7 @@ export function createDeckRulesFingerprint(settings: PluginSettings): string { version: DECK_RULES_FINGERPRINT_VERSION, qaHeadingLevel: settings.qaHeadingLevel, clozeHeadingLevel: settings.clozeHeadingLevel, + qaGroupMarker: settings.qaGroupMarker, semanticQaMarker: settings.semanticQaMarker, defaultDeck: settings.defaultDeck, fileDeckEnabled: settings.fileDeckEnabled, diff --git a/src/application/services/ManualSyncService.test.ts b/src/application/services/ManualSyncService.test.ts index cb3a7e7..dd9a560 100644 --- a/src/application/services/ManualSyncService.test.ts +++ b/src/application/services/ManualSyncService.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { QA_GROUP_MODEL_NAME } from "@/application/services/QaGroupModelDefinition"; import type { PluginSettings } from "@/application/config/PluginSettings"; import { createDeckRulesFingerprint } from "@/application/services/FileIndexerService"; import { RenderConfigService } from "@/application/services/RenderConfigService"; @@ -49,6 +50,38 @@ describe("ManualSyncService", () => { expect(stateRepository.savedState?.pendingWriteBack[0]).toMatchObject({ filePath: "notes/example.md", targetNoteId: 9001 }); }); + it("syncs a #anki-list block into one QA Group 12 note and writes one GI marker", async () => { + const vaultGateway = new FakeManualSyncVaultGateway({ + "notes/example.md": [ + "#### Concepts #anki-list", + "- Alpha", + " - First answer", + "- Beta", + " - Second answer", + ].join("\n"), + }); + const stateRepository = new InMemoryPluginStateRepository(); + const ankiGateway = new FakeManualSyncAnkiGateway(); + const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, () => 1234); + + const result = await service.syncFile("notes/example.md", createModule3Settings()); + + expect(result.created).toBe(1); + expect(result.rewrittenMarkers).toBe(1); + expect(ankiGateway.createdModels[0]?.modelName).toBe(QA_GROUP_MODEL_NAME); + expect(ankiGateway.addedNotes[0]?.modelName).toBe(QA_GROUP_MODEL_NAME); + expect(ankiGateway.addedNotes[0]?.fields).toMatchObject({ + Stem: "Concepts", + S01_Q: "Alpha", + S01_A: "First answer", + S02_Q: "Beta", + S02_A: "Second answer", + }); + expect(vaultGateway.getFileContent("notes/example.md")).toMatch(//); + expect(Object.values(stateRepository.savedState?.groupBlocks ?? {})).toHaveLength(1); + expect(stateRepository.savedState?.files["notes/example.md"]?.groupIds).toHaveLength(1); + }); + it("rebuilds the card index without writing unresolved ID markers or calling Anki", async () => { const vaultGateway = new FakeManualSyncVaultGateway({ "notes/example.md": ["#### Prompt", "Answer"].join("\n"), diff --git a/src/application/services/ManualSyncService.ts b/src/application/services/ManualSyncService.ts index c062151..774994d 100644 --- a/src/application/services/ManualSyncService.ts +++ b/src/application/services/ManualSyncService.ts @@ -1,19 +1,24 @@ import type { PluginSettings } from "@/application/config/PluginSettings"; import { validatePluginSettings } from "@/application/config/PluginSettings"; -import type { AnkiGateway } from "@/application/ports/AnkiGateway"; +import type { AnkiGateway, AnkiGroupGateway } from "@/application/ports/AnkiGateway"; import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway"; import type { PluginStateRepository } from "@/application/ports/PluginStateRepository"; import { AnkiBatchExecutor } from "@/application/services/AnkiBatchExecutor"; import { FileIndexerService } from "@/application/services/FileIndexerService"; import { createDeckRulesFingerprint } from "@/application/services/FileIndexerService"; import { MarkdownWriteBackService } from "@/application/services/MarkdownWriteBackService"; +import { QaGroupSyncService } from "@/application/services/QaGroupSyncService"; import { RenderConfigService } from "@/application/services/RenderConfigService"; import { ScanScopeService } from "@/application/services/ScanScopeService"; import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; -import { toNoteIdKey, type CardState, type PluginState } from "@/domain/manual-sync/entities/PluginState"; +import type { IndexedGroupCardBlock } from "@/domain/manual-sync/entities/IndexedGroupCardBlock"; +import { toNoteIdKey, type CardState, type GroupBlockState, type PluginState } from "@/domain/manual-sync/entities/PluginState"; import type { RenderedSyncCard } from "@/domain/manual-sync/entities/RenderedSyncCard"; +import { GroupMarkerService, type GroupMarkerWriteRequest } from "@/domain/manual-sync/services/GroupMarkerService"; import { DiffPlannerService } from "@/domain/manual-sync/services/DiffPlannerService"; import { ManualCardRenderer, type ManualCardRenderContext } from "@/domain/manual-sync/services/ManualCardRenderer"; +import type { DeckResolutionWarning } from "@/domain/manual-sync/value-objects/DeckResolution"; +import { getDeckResolutionWarningKey } from "@/domain/manual-sync/value-objects/DeckResolution"; import type { ManualSyncResult } from "@/application/use-cases/manualSyncTypes"; @@ -26,6 +31,10 @@ export class CurrentFileOutOfScopeError extends Error { export class ManualSyncService { private readonly scanScopeService = new ScanScopeService(); + private readonly qaGroupSyncService: QaGroupSyncService; + private readonly groupMarkerService: GroupMarkerService; + private readonly renderConfigService: RenderConfigService; + private readonly now: () => number; constructor( private readonly vaultGateway: ManualSyncVaultGateway, @@ -36,9 +45,21 @@ export class ManualSyncService { private readonly renderer = new ManualCardRenderer(), private readonly ankiBatchExecutor = new AnkiBatchExecutor(ankiGateway), private readonly markdownWriteBackService = new MarkdownWriteBackService(vaultGateway), - private readonly renderConfigService = new RenderConfigService(), - private readonly now: () => number = () => Date.now(), - ) {} + renderConfigServiceOrNow: RenderConfigService | (() => number) = new RenderConfigService(), + now: () => number = () => Date.now(), + ) { + this.qaGroupSyncService = new QaGroupSyncService(ankiGateway as AnkiGroupGateway); + this.groupMarkerService = new GroupMarkerService(); + + if (isRenderConfigService(renderConfigServiceOrNow)) { + this.renderConfigService = renderConfigServiceOrNow; + this.now = now; + return; + } + + this.renderConfigService = new RenderConfigService(); + this.now = renderConfigServiceOrNow; + } async syncVault(settings: PluginSettings): Promise { validatePluginSettings(settings); @@ -65,21 +86,26 @@ export class ManualSyncService { const plan = this.diffPlannerService.plan(indexResult.cards, state, indexResult.scopedFilePaths, settings); const indexedFilesByPath = new Map(indexResult.indexedFiles.map((file) => [file.filePath, file])); const markerWrites = plan.toRewriteMarker.filter((plannedCard) => plannedCard.noteId !== undefined); + const rebuildGroupResult = this.buildReindexedGroupStates(indexResult.groupBlocks, state); - const writeBackResult = await this.markdownWriteBackService.write(markerWrites, indexedFilesByPath); + const writeBackResult = await this.markdownWriteBackService.write(markerWrites, indexedFilesByPath, rebuildGroupResult.markerWrites); + const orphanGroupBlocks = collectOrphanGroupBlocks(state, indexResult.scopedFilePaths, rebuildGroupResult.syncedGroupBlocks); const nextState = this.buildNextState( state, indexResult.cards, indexResult.indexedFiles, + rebuildGroupResult.syncedGroupBlocks, settings, new Set(writeBackResult.writtenSyncKeys), new Map(), plan.toOrphan, + orphanGroupBlocks, ); nextState.pendingWriteBack = [ ...state.pendingWriteBack.filter((pending) => !new Set(indexResult.scopedFilePaths).has(pending.filePath)), ...writeBackResult.pendingEntries, ]; + this.markFilesDirty(nextState, [...writeBackResult.failureFiles.map((failure) => failure.filePath), ...writeBackResult.conflictFiles]); await this.pluginStateRepository.save(nextState); if (writeBackResult.failureFiles.length > 0) { @@ -88,16 +114,16 @@ export class ManualSyncService { return { scannedFiles: indexResult.scannedFiles, - scannedCards: indexResult.cards.length, + scannedCards: indexResult.cards.length + indexResult.groupBlocks.length, created: 0, updated: 0, migratedDecks: 0, - orphaned: plan.toOrphan.length, + orphaned: plan.toOrphan.length + orphanGroupBlocks.length, uploadedMedia: 0, skippedUnchangedCards: indexResult.skippedUnchangedCards, rewrittenMarkers: writeBackResult.writtenSyncKeys.length, markerWriteConflictFiles: writeBackResult.conflictFiles, - warnings: plan.warnings, + warnings: mergeDeckWarnings(plan.warnings, rebuildGroupResult.warnings), }; } @@ -126,24 +152,29 @@ export class ManualSyncService { async (plannedCard) => this.renderer.render(plannedCard, renderContext), settings.noteFieldMappings, ); + const qaGroupExecution = await this.qaGroupSyncService.sync(indexResult.groupBlocks, state, settings); const indexedFilesByPath = new Map(indexResult.indexedFiles.map((file) => [file.filePath, file])); - const writeBackResult = await this.markdownWriteBackService.write(executionResult.markerWrites, indexedFilesByPath); + const writeBackResult = await this.markdownWriteBackService.write(executionResult.markerWrites, indexedFilesByPath, qaGroupExecution.markerWrites); + const orphanGroupBlocks = collectOrphanGroupBlocks(state, indexResult.scopedFilePaths, qaGroupExecution.syncedGroupBlocks); const nextState = this.buildNextState( state, indexResult.cards, indexResult.indexedFiles, + qaGroupExecution.syncedGroupBlocks, settings, - new Set([...executionResult.touchedSyncKeys, ...writeBackResult.writtenSyncKeys]), + new Set([...executionResult.touchedSyncKeys, ...qaGroupExecution.touchedSyncKeys, ...writeBackResult.writtenSyncKeys]), executionResult.resolvedNoteIds, plan.toOrphan, + orphanGroupBlocks, ); nextState.pendingWriteBack = [ ...state.pendingWriteBack.filter((pending) => !new Set(indexResult.scopedFilePaths).has(pending.filePath)), ...writeBackResult.pendingEntries, ]; + this.markFilesDirty(nextState, [...writeBackResult.failureFiles.map((failure) => failure.filePath), ...writeBackResult.conflictFiles]); await this.pluginStateRepository.save(nextState); @@ -153,34 +184,46 @@ export class ManualSyncService { return { scannedFiles: indexResult.scannedFiles, - scannedCards: indexResult.cards.length, - created: executionResult.created, - updated: executionResult.updated, - migratedDecks: executionResult.migratedDecks, - orphaned: plan.toOrphan.length, + scannedCards: indexResult.cards.length + indexResult.groupBlocks.length, + created: executionResult.created + qaGroupExecution.created, + updated: executionResult.updated + qaGroupExecution.updated, + migratedDecks: executionResult.migratedDecks + qaGroupExecution.migratedDecks, + orphaned: plan.toOrphan.length + orphanGroupBlocks.length, uploadedMedia: executionResult.uploadedMedia, skippedUnchangedCards: indexResult.skippedUnchangedCards, rewrittenMarkers: writeBackResult.writtenSyncKeys.length, markerWriteConflictFiles: writeBackResult.conflictFiles, - warnings: plan.warnings, + warnings: mergeDeckWarnings(plan.warnings, qaGroupExecution.warnings), }; } private buildNextState( previousState: PluginState, cards: IndexedCard[], - indexedFiles: Array<{ filePath: string; fileHash: string; fileStamp: string; content?: string; cards: IndexedCard[] }>, + indexedFiles: Array<{ filePath: string; fileHash: string; fileStamp: string; content?: string; cards: IndexedCard[]; groupBlocks?: IndexedGroupCardBlock[] }>, + syncedGroupBlocks: GroupBlockState[], settings: PluginSettings, touchedSyncKeys: Set, resolvedNoteIds: Map, orphanCards: CardState[], + orphanGroupBlocks: GroupBlockState[], ): PluginState { const now = this.now(); const deckRulesFingerprint = createDeckRulesFingerprint(settings); const scopedFilePaths = new Set(indexedFiles.filter((indexedFile) => indexedFile.content !== undefined).map((indexedFile) => indexedFile.filePath)); + const groupBlocksByFilePath = new Map(); + for (const groupBlock of syncedGroupBlocks) { + const entries = groupBlocksByFilePath.get(groupBlock.filePath); + if (entries) { + entries.push(groupBlock); + } else { + groupBlocksByFilePath.set(groupBlock.filePath, [groupBlock]); + } + } const nextState: PluginState = { files: { ...previousState.files }, cards: { ...previousState.cards }, + groupBlocks: { ...(previousState.groupBlocks ?? {}) }, pendingWriteBack: [...previousState.pendingWriteBack], }; @@ -194,6 +237,12 @@ export class ManualSyncService { } } + for (const [groupId, groupBlockState] of Object.entries(nextState.groupBlocks ?? {})) { + if (scopedFilePaths.has(groupBlockState.filePath)) { + delete nextState.groupBlocks?.[groupId]; + } + } + const resolvedNoteIdsBySyncKey = new Map(); for (const card of cards) { @@ -221,6 +270,7 @@ export class ManualSyncService { deckRulesFingerprint, lastIndexedAt: now, noteIds, + groupIds: (groupBlocksByFilePath.get(indexedFile.filePath) ?? []).map((groupBlock) => groupBlock.groupId), }; } @@ -268,10 +318,172 @@ export class ManualSyncService { }; } + for (const groupBlock of syncedGroupBlocks) { + nextState.groupBlocks ??= {}; + nextState.groupBlocks[groupBlock.groupId] = groupBlock; + } + + for (const orphanGroupBlock of orphanGroupBlocks) { + nextState.groupBlocks ??= {}; + nextState.groupBlocks[orphanGroupBlock.groupId] = { + ...orphanGroupBlock, + orphan: true, + }; + } + return nextState; } + private buildReindexedGroupStates( + groupBlocks: IndexedGroupCardBlock[], + state: PluginState, + ): { syncedGroupBlocks: GroupBlockState[]; markerWrites: GroupMarkerWriteRequest[]; warnings: DeckResolutionWarning[] } { + const syncedGroupBlocks: GroupBlockState[] = []; + const markerWrites: GroupMarkerWriteRequest[] = []; + const warnings: DeckResolutionWarning[] = []; + const byNoteId = new Map(Object.values(state.groupBlocks ?? {}) + .filter((groupBlock) => !groupBlock.orphan) + .map((groupBlock) => [groupBlock.noteId, groupBlock])); + const bySrc = new Map(); + + for (const groupBlock of Object.values(state.groupBlocks ?? {}).filter((entry) => !entry.orphan)) { + const entries = bySrc.get(groupBlock.src); + if (entries) { + entries.push(groupBlock); + } else { + bySrc.set(groupBlock.src, [groupBlock]); + } + } + + for (const block of groupBlocks) { + const existingState = (block.groupId ? state.groupBlocks?.[block.groupId] : undefined) + ?? (block.noteId !== undefined ? byNoteId.get(block.noteId) : undefined) + ?? uniqueGroupStateMatch(bySrc.get(block.src)); + if (!existingState) { + continue; + } + + const itemToSlot = Object.fromEntries(existingState.items + .filter((item) => item.itemId && item.slot) + .map((item) => [item.itemId as string, item.slot as number])); + + syncedGroupBlocks.push({ + ...existingState, + filePath: block.filePath, + headingText: block.headingText, + backlinkHeadingText: block.backlinkHeadingText, + headingLevel: block.headingLevel, + stem: block.stem, + src: block.src, + blockStartOffset: block.blockStartOffset, + blockEndOffset: block.blockEndOffset, + blockStartLine: block.blockStartLine, + bodyStartLine: block.bodyStartLine, + blockEndLine: block.blockEndLine, + contentEndLine: block.contentEndLine, + markerLine: block.markerLine, + markerIndent: block.markerIndent, + rawBlockText: block.rawBlockText, + rawBlockHash: block.rawBlockHash, + deckHint: block.deckHint, + deckHintSource: block.deckHintSource, + deckWarnings: [...block.deckWarnings], + orphan: false, + }); + + warnings.push(...block.deckWarnings); + + if ( + block.sourceContent + && ( + block.markerState !== "present-valid" + || !this.groupMarkerService.isEquivalent(block.groupMarker, existingState.noteId, itemToSlot, existingState.freeSlots) + || hasLegacyInnerIdMarkers(block) + ) + ) { + markerWrites.push({ + syncKey: block.syncKey, + filePath: block.filePath, + blockStartLine: block.blockStartLine, + blockEndLine: block.blockEndLine, + markerLine: block.markerLine, + markerIndent: block.markerIndent, + noteId: existingState.noteId, + groupId: existingState.groupId, + itemToSlot, + freeSlots: [...existingState.freeSlots], + sourceContent: block.sourceContent, + }); + } + } + + return { + syncedGroupBlocks, + markerWrites, + warnings, + }; + } + + private markFilesDirty(nextState: PluginState, filePaths: string[]): void { + for (const filePath of new Set(filePaths)) { + if (nextState.files[filePath]) { + nextState.files[filePath] = { + ...nextState.files[filePath], + fileHash: "", + fileStamp: "", + }; + } + } + } + private createWriteBackFailureMessage(failures: Array<{ filePath: string; message: string }>): string { return `Markdown marker write-back failed for ${failures.length} file(s).\n${failures.map((failure) => `${failure.filePath}: ${failure.message}`).join("\n")}`; } +} + +function isRenderConfigService(value: RenderConfigService | (() => number)): value is RenderConfigService { + return typeof value === "object" && value !== null && "resolve" in value; +} + +function collectOrphanGroupBlocks(previousState: PluginState, scopedFilePaths: string[], syncedGroupBlocks: GroupBlockState[]): GroupBlockState[] { + const scopedPaths = new Set(scopedFilePaths); + const seenGroupIds = new Set(syncedGroupBlocks.map((groupBlock) => groupBlock.groupId)); + + return Object.values(previousState.groupBlocks ?? {}) + .filter((groupBlock) => scopedPaths.has(groupBlock.filePath) && !seenGroupIds.has(groupBlock.groupId)) + .map((groupBlock) => ({ ...groupBlock, orphan: true })); +} + +function mergeDeckWarnings(...warningGroups: DeckResolutionWarning[][]): DeckResolutionWarning[] { + const warningMap = new Map(); + for (const warningGroup of warningGroups) { + for (const warning of warningGroup) { + warningMap.set(getDeckResolutionWarningKey(warning), warning); + } + } + + return [...warningMap.values()]; +} + +function uniqueGroupStateMatch(groupBlocks: GroupBlockState[] | undefined): GroupBlockState | undefined { + if (!groupBlocks || groupBlocks.length !== 1) { + return undefined; + } + + return groupBlocks[0]; +} + +function hasLegacyInnerIdMarkers(block: IndexedGroupCardBlock): boolean { + if (!block.sourceContent) { + return false; + } + + const lines = block.sourceContent.split(/\r?\n/); + for (let lineIndex = block.blockStartLine; lineIndex < block.blockEndLine; lineIndex += 1) { + if (/^\s*", + ].join("\n"), + }, + { + qaHeadingLevel: 4, + clozeHeadingLevel: 5, + qaGroupMarker: "#anki-list", + fileStamp: "1:1", + knownCards: [], + pendingWriteBack: [], + }, + ); + + expect(indexedFile.cards).toHaveLength(0); + expect(indexedFile.groupBlocks).toHaveLength(1); + expect(indexedFile.groupBlocks?.[0]).toMatchObject({ + noteId: 42, + markerState: "present-valid", + stem: "Concepts", + items: [ + { title: "Alpha", answer: "First answer", ordinalInMarkdown: 1 }, + { title: "Beta", answer: "Second answer", ordinalInMarkdown: 2 }, + ], + freeSlots: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + }); + }); + + it("recovers QA Group note identity from prior group state when GI is missing", () => { + const service = new CardIndexingService(); + const headingText = "Concepts #anki-list"; + const rawBlockText = [ + "qa-group:Concepts", + "Alpha\nFirst answer", + "Beta\nSecond answer", + "- Alpha", + " - First answer", + "- Beta", + " - Second answer", + ].join("\n"); + const indexedFile = service.index( + { + path: "notes/example.md", + basename: "example", + content: [ + `#### ${headingText}`, + "- Alpha", + " - First answer", + "- Beta", + " - Second answer", + ].join("\n"), + }, + { + qaHeadingLevel: 4, + clozeHeadingLevel: 5, + qaGroupMarker: "#anki-list", + fileStamp: "1:1", + knownCards: [], + knownGroupBlocks: [createKnownGroupState({ + groupId: "group-1", + noteId: 42, + headingText, + backlinkHeadingText: headingText, + stem: "Concepts", + src: buildGroupSrc("notes/example.md", headingText), + rawBlockText, + rawBlockHash: hashString(rawBlockText), + })], + pendingWriteBack: [], + }, + ); + + expect(indexedFile.groupBlocks).toHaveLength(1); + expect(indexedFile.groupBlocks?.[0]).toMatchObject({ + noteId: 42, + groupId: "group-1", + identitySource: "state-recovery", + markerState: "missing", + }); + }); + it("extracts YAML deck, prefers it over conflicting body deck, and stores a warning on indexed cards", () => { const service = new CardIndexingService(); const indexedFile = service.index( @@ -350,4 +443,35 @@ function createKnownCardState(overrides: Partial = {}): CardState { lastSyncedAt: overrides.lastSyncedAt ?? 1, orphan: overrides.orphan ?? false, }; +} + +function createKnownGroupState(overrides: Partial = {}): GroupBlockState { + return { + groupId: overrides.groupId ?? "group-1", + noteId: overrides.noteId ?? 42, + filePath: overrides.filePath ?? "notes/example.md", + headingText: overrides.headingText ?? "Concepts #anki-list", + backlinkHeadingText: overrides.backlinkHeadingText ?? "Concepts #anki-list", + headingLevel: overrides.headingLevel ?? 4, + stem: overrides.stem ?? "Concepts", + src: overrides.src ?? buildGroupSrc("notes/example.md", "Concepts #anki-list"), + blockStartOffset: overrides.blockStartOffset ?? 0, + blockEndOffset: overrides.blockEndOffset ?? 0, + blockStartLine: overrides.blockStartLine ?? 1, + bodyStartLine: overrides.bodyStartLine ?? 2, + blockEndLine: overrides.blockEndLine ?? 5, + contentEndLine: overrides.contentEndLine ?? 5, + markerLine: overrides.markerLine, + markerIndent: overrides.markerIndent, + rawBlockText: overrides.rawBlockText ?? "qa-group:Concepts", + rawBlockHash: overrides.rawBlockHash ?? hashString(overrides.rawBlockText ?? "qa-group:Concepts"), + deck: overrides.deck ?? "notes", + deckHint: overrides.deckHint, + deckHintSource: overrides.deckHintSource, + deckWarnings: overrides.deckWarnings ?? [], + items: overrides.items ?? [], + freeSlots: overrides.freeSlots ?? [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + lastSyncedAt: overrides.lastSyncedAt ?? 1, + orphan: overrides.orphan ?? false, + }; } \ No newline at end of file diff --git a/src/domain/manual-sync/services/CardIndexingService.ts b/src/domain/manual-sync/services/CardIndexingService.ts index d6f1329..b98f204 100644 --- a/src/domain/manual-sync/services/CardIndexingService.ts +++ b/src/domain/manual-sync/services/CardIndexingService.ts @@ -1,11 +1,13 @@ import type { SourceFile } from "@/domain/card/entities/SourceFile"; import { createIndexedCardSyncKey, type IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; +import { buildGroupSrc, createIndexedGroupSyncKey, type IndexedGroupCardBlock } from "@/domain/manual-sync/entities/IndexedGroupCardBlock"; import type { IndexedFile } from "@/domain/manual-sync/entities/IndexedFile"; -import { createPendingWriteBackKey, type CardState, type PendingWriteBackState } from "@/domain/manual-sync/entities/PluginState"; +import { createPendingWriteBackKey, type CardState, type GroupBlockState, type PendingWriteBackState } from "@/domain/manual-sync/entities/PluginState"; import { hashString } from "@/domain/shared/hash"; import { CardMarkerService } from "./CardMarkerService"; import { DeckExtractionService } from "./DeckExtractionService"; +import { QaGroupBlockParser } from "./QaGroupBlockParser"; import { SemanticQaListParser } from "./SemanticQaListParser"; interface HeadingMatch { @@ -25,9 +27,11 @@ interface MarkerExtractionResult { export interface CardIndexingContext { qaHeadingLevel: number; clozeHeadingLevel: number; + qaGroupMarker?: string; semanticQaMarker?: string; fileStamp: string; knownCards: CardState[]; + knownGroupBlocks?: GroupBlockState[]; pendingWriteBack: PendingWriteBackState[]; fileDeckEnabled?: boolean; fileDeckMarker?: string; @@ -38,6 +42,7 @@ export class CardIndexingService { constructor( private readonly markerService = new CardMarkerService(), private readonly deckExtractionService = new DeckExtractionService(), + private readonly qaGroupBlockParser = new QaGroupBlockParser(), private readonly semanticQaListParser = new SemanticQaListParser(), ) {} @@ -51,9 +56,12 @@ export class CardIndexingService { ? this.deckExtractionService.extract(sourceFile, context.fileDeckMarker ?? "TARGET DECK") : { warnings: [] }; const cards: IndexedCard[] = []; + const groupBlocks: IndexedGroupCardBlock[] = []; const knownCardsByBlockKey = groupKnownCardsByBlockKey(context.knownCards); const pendingByBlockKey = groupPendingWriteBackByBlockKey(context.pendingWriteBack); const usedNoteIds = new Set(); + const usedGroupIds = new Set(); + const knownGroupBlocks = context.knownGroupBlocks ?? []; for (let headingIndex = 0; headingIndex < headings.length; headingIndex += 1) { const heading = headings[headingIndex]; @@ -64,6 +72,66 @@ export class CardIndexingService { const blockEndLineIndex = findBlockEndLineIndex(headings, headingIndex, lines.length); const bodyLines = lines.slice(heading.lineIndex + 1, blockEndLineIndex); + const qaGroupMarker = context.qaGroupMarker ?? "#anki-list"; + if (cardType === "basic" && this.qaGroupBlockParser.isQaGroupHeading(heading.text, qaGroupMarker)) { + const parsedGroupBlock = this.qaGroupBlockParser.parse({ + parentHeadingText: heading.text, + marker: qaGroupMarker, + bodyLines, + bodyStartLine: heading.lineIndex + 2, + }); + const src = buildGroupSrc(sourceFile.path, heading.text); + const resolvedGroupIdentity = this.resolveGroupIdentity( + src, + parsedGroupBlock.rawBlockHash, + parsedGroupBlock.groupMarker?.noteId, + knownGroupBlocks, + usedNoteIds, + usedGroupIds, + ); + + if (resolvedGroupIdentity.noteId !== undefined) { + usedNoteIds.add(resolvedGroupIdentity.noteId); + } + + if (resolvedGroupIdentity.groupId) { + usedGroupIds.add(resolvedGroupIdentity.groupId); + } + + groupBlocks.push({ + noteId: resolvedGroupIdentity.noteId, + groupId: resolvedGroupIdentity.groupId, + syncKey: createIndexedGroupSyncKey(sourceFile.path, heading.lineIndex + 1, parsedGroupBlock.rawBlockHash), + markerState: parsedGroupBlock.markerState, + identitySource: resolvedGroupIdentity.identitySource, + filePath: sourceFile.path, + headingText: heading.text, + backlinkHeadingText: heading.text, + headingLevel: heading.level, + stem: parsedGroupBlock.stem, + src, + blockStartOffset: lineStartOffsets[heading.lineIndex] ?? 0, + blockEndOffset: blockEndLineIndex < lines.length ? (lineStartOffsets[blockEndLineIndex] ?? sourceFile.content.length) : sourceFile.content.length, + blockStartLine: heading.lineIndex + 1, + bodyStartLine: heading.lineIndex + 2, + blockEndLine: blockEndLineIndex, + contentEndLine: parsedGroupBlock.contentEndLine, + markerLine: parsedGroupBlock.markerLine, + markerIndent: parsedGroupBlock.markerIndent, + rawBlockText: parsedGroupBlock.rawBlockText, + rawBlockHash: parsedGroupBlock.rawBlockHash, + deckHint: extractedDeck.explicitDeckHint, + deckHintSource: extractedDeck.explicitDeckSource, + deckWarnings: [...extractedDeck.warnings], + items: parsedGroupBlock.items, + groupMarker: parsedGroupBlock.groupMarker, + freeSlots: parsedGroupBlock.groupMarker?.freeSlots ?? resolvedGroupIdentity.freeSlots, + sourceContent: sourceFile.content, + }); + + continue; + } + const semanticQaMarker = context.semanticQaMarker ?? "#anki-list-qa"; if (cardType === "basic" && this.semanticQaListParser.isSemanticQaHeading(heading.text, semanticQaMarker)) { for (const semanticCard of this.semanticQaListParser.parse({ @@ -173,6 +241,7 @@ export class CardIndexingService { fileStamp: context.fileStamp, content: sourceFile.content, cards, + groupBlocks, }; } @@ -222,6 +291,59 @@ export class CardIndexingService { return {}; } + + private resolveGroupIdentity( + src: string, + rawBlockHash: string, + markerNoteId: number | undefined, + knownGroupBlocks: GroupBlockState[], + usedNoteIds: Set, + usedGroupIds: Set, + ): { noteId?: number; groupId?: string; freeSlots: number[]; identitySource?: IndexedGroupCardBlock["identitySource"] } { + if (markerNoteId !== undefined && !usedNoteIds.has(markerNoteId)) { + const stateMatch = knownGroupBlocks.find((groupBlock) => !groupBlock.orphan && groupBlock.noteId === markerNoteId); + if (!stateMatch) { + return { + noteId: markerNoteId, + freeSlots: [], + identitySource: "gi-marker", + }; + } + + if (!usedGroupIds.has(stateMatch.groupId)) { + return { + noteId: stateMatch.noteId, + groupId: stateMatch.groupId, + freeSlots: [...stateMatch.freeSlots], + identitySource: "gi-marker", + }; + } + } + + const srcMatches = knownGroupBlocks.filter((groupBlock) => !groupBlock.orphan && groupBlock.src === src); + if (srcMatches.length === 1 && !usedGroupIds.has(srcMatches[0].groupId) && !usedNoteIds.has(srcMatches[0].noteId)) { + return { + noteId: srcMatches[0].noteId, + groupId: srcMatches[0].groupId, + freeSlots: [...srcMatches[0].freeSlots], + identitySource: "state-recovery", + }; + } + + const hashMatches = knownGroupBlocks.filter((groupBlock) => !groupBlock.orphan && groupBlock.rawBlockHash === rawBlockHash); + if (hashMatches.length === 1 && !usedGroupIds.has(hashMatches[0].groupId) && !usedNoteIds.has(hashMatches[0].noteId)) { + return { + noteId: hashMatches[0].noteId, + groupId: hashMatches[0].groupId, + freeSlots: [...hashMatches[0].freeSlots], + identitySource: "state-recovery", + }; + } + + return { + freeSlots: [], + }; + } } function groupKnownCardsByBlockKey(knownCards: CardState[]): Map { @@ -248,7 +370,7 @@ function groupKnownCardsByBlockKey(knownCards: CardState[]): Map { const grouped = new Map(); - for (const pending of pendingWriteBack) { + for (const pending of pendingWriteBack.filter((entry) => entry.markerKind !== "group-gi")) { const key = createPendingWriteBackKey(pending.filePath, pending.blockStartLine, pending.rawBlockHash); const entries = grouped.get(key); if (entries) { diff --git a/src/domain/manual-sync/services/CardMarkerService.ts b/src/domain/manual-sync/services/CardMarkerService.ts index b049f27..b95fc84 100644 --- a/src/domain/manual-sync/services/CardMarkerService.ts +++ b/src/domain/manual-sync/services/CardMarkerService.ts @@ -61,11 +61,22 @@ export class CardMarkerService { return sourceContent; } - const filePath = writes[0]?.filePath; - const seenBlocks = new Set(); + this.validateBatch(sourceContent, writes); const lineEnding = sourceContent.includes("\r\n") ? "\r\n" : "\n"; const lines = sourceContent.split(/\r?\n/); + const sortedWrites = [...writes].sort((left, right) => right.blockStartLine - left.blockStartLine); + for (const write of sortedWrites) { + this.applyWrite(lines, write); + } + + return lines.join(lineEnding); + } + + validateBatch(sourceContent: string, writes: MarkerWriteRequest[]): void { + const filePath = writes[0]?.filePath; + const seenBlocks = new Set(); + for (const write of writes) { if (write.filePath !== filePath) { throw new CardMarkerError("Batch marker writes must belong to the same Markdown file."); @@ -89,21 +100,18 @@ export class CardMarkerService { seenBlocks.add(write.blockStartLine); } + } - const sortedWrites = [...writes].sort((left, right) => right.blockStartLine - left.blockStartLine); - for (const write of sortedWrites) { - const adjustedBlockEndLine = write.markerLine !== undefined ? write.blockEndLine - 1 : write.blockEndLine; - if (write.contentEndLine < write.blockStartLine || write.contentEndLine > adjustedBlockEndLine) { - throw new CardMarkerError(`Cannot write marker outside the heading block in ${write.filePath}.`); - } - - if (write.markerLine !== undefined) { - lines.splice(write.markerLine - 1, 1); - } - - lines.splice(write.contentEndLine, 0, `${write.markerIndent ?? ""}${this.create(write.noteId).raw}`); + applyWrite(lines: string[], write: MarkerWriteRequest): void { + const adjustedBlockEndLine = write.markerLine !== undefined ? write.blockEndLine - 1 : write.blockEndLine; + if (write.contentEndLine < write.blockStartLine || write.contentEndLine > adjustedBlockEndLine) { + throw new CardMarkerError(`Cannot write marker outside the heading block in ${write.filePath}.`); } - return lines.join(lineEnding); + if (write.markerLine !== undefined) { + lines.splice(write.markerLine - 1, 1); + } + + lines.splice(write.contentEndLine, 0, `${write.markerIndent ?? ""}${this.create(write.noteId).raw}`); } } \ No newline at end of file diff --git a/src/domain/manual-sync/services/DiffPlannerService.ts b/src/domain/manual-sync/services/DiffPlannerService.ts index 35a796b..a7bc13a 100644 --- a/src/domain/manual-sync/services/DiffPlannerService.ts +++ b/src/domain/manual-sync/services/DiffPlannerService.ts @@ -10,7 +10,9 @@ export class DiffPlannerService { plan(cards: IndexedCard[], state: PluginState, scopedFilePaths: string[], settings: PluginSettings): ManualSyncPlan { const cardsBySyncKey = new Map(); - const pendingByBlockKey = new Set(state.pendingWriteBack.map((pending) => createPendingWriteBackKey(pending.filePath, pending.blockStartLine, pending.rawBlockHash))); + const pendingByBlockKey = new Set(state.pendingWriteBack + .filter((pending) => pending.markerKind !== "group-gi") + .map((pending) => createPendingWriteBackKey(pending.filePath, pending.blockStartLine, pending.rawBlockHash))); const seenNoteKeys = new Set(); const toCreate: PlannedCard[] = []; const toUpdate: PlannedCard[] = []; diff --git a/src/domain/manual-sync/services/GroupMarkerService.test.ts b/src/domain/manual-sync/services/GroupMarkerService.test.ts new file mode 100644 index 0000000..a02d8d7 --- /dev/null +++ b/src/domain/manual-sync/services/GroupMarkerService.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { GroupMarkerService } from "./GroupMarkerService"; + +describe("GroupMarkerService", () => { + it("rewrites the trailing GI marker and removes legacy inner ID markers", () => { + const service = new GroupMarkerService(); + const sourceContent = [ + "#### Concepts #anki-list", + "- Alpha", + " - First answer", + " ", + "- Beta", + " - Second answer", + "", + ].join("\n"); + + const nextContent = service.applyBatch(sourceContent, [{ + syncKey: "notes/example.md\u0000group\u00001\u0000hash", + filePath: "notes/example.md", + blockStartLine: 1, + blockEndLine: 7, + markerLine: 7, + noteId: 42, + itemToSlot: { item_a: 1, item_b: 2 }, + freeSlots: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + sourceContent, + }]); + + expect(nextContent).not.toContain(""); + expect(nextContent.split("\n").at(-1)).toBe(""); + }); +}); \ No newline at end of file diff --git a/src/domain/manual-sync/services/GroupMarkerService.ts b/src/domain/manual-sync/services/GroupMarkerService.ts new file mode 100644 index 0000000..a3e2f8f --- /dev/null +++ b/src/domain/manual-sync/services/GroupMarkerService.ts @@ -0,0 +1,269 @@ +import type { GroupMarker } from "@/domain/manual-sync/entities/IndexedGroupCardBlock"; + +export interface ParsedGroupMarker extends GroupMarker { + raw: string; + lineIndex: number; +} + +export interface GroupMarkerWriteRequest { + syncKey: string; + filePath: string; + blockStartLine: number; + blockEndLine: number; + markerLine?: number; + markerIndent?: string; + noteId: number; + groupId?: string; + itemToSlot: Record; + freeSlots: number[]; + sourceContent: string; +} + +export class GroupMarkerError extends Error { + constructor(message: string) { + super(message); + this.name = "GroupMarkerError"; + } +} + +const GROUP_MARKER_CANDIDATE_REGEXP = /\s*$/; +const GROUP_ITEM_ID_REGEXP = /^[A-Za-z][A-Za-z0-9_-]*$/; + +export class GroupMarkerService { + isCandidate(line: string): boolean { + return GROUP_MARKER_CANDIDATE_REGEXP.test(line); + } + + parse(line: string, lineIndex: number): ParsedGroupMarker | null { + const match = line.match(GROUP_MARKER_REGEXP); + if (!match) { + return null; + } + + const noteId = Number(match[1]); + const itemToSlot = parseItemToSlotMapping(match[2]); + const freeSlots = parseFreeSlots(match[3]); + validateSlotRanges(itemToSlot, freeSlots); + + return { + noteId, + itemToSlot, + freeSlots, + raw: match[0].trim(), + lineIndex, + }; + } + + create(noteId: number, itemToSlot: Record, freeSlots: number[]): ParsedGroupMarker { + validateSlotRanges(itemToSlot, freeSlots); + + return { + noteId, + itemToSlot: { ...itemToSlot }, + freeSlots: [...new Set(freeSlots)].sort((left, right) => left - right), + raw: serializeGroupMarker(noteId, itemToSlot, freeSlots), + lineIndex: -1, + }; + } + + isEquivalent(marker: GroupMarker | undefined, noteId: number, itemToSlot: Record, freeSlots: number[]): boolean { + if (!marker || marker.noteId !== noteId) { + return false; + } + + const existingEntries = Object.entries(marker.itemToSlot).sort(compareMarkerEntry); + const nextEntries = Object.entries(itemToSlot).sort(compareMarkerEntry); + if (existingEntries.length !== nextEntries.length) { + return false; + } + + for (let index = 0; index < existingEntries.length; index += 1) { + if (existingEntries[index][0] !== nextEntries[index][0] || existingEntries[index][1] !== nextEntries[index][1]) { + return false; + } + } + + const existingFreeSlots = [...marker.freeSlots].sort((left, right) => left - right); + const nextFreeSlots = [...freeSlots].sort((left, right) => left - right); + if (existingFreeSlots.length !== nextFreeSlots.length) { + return false; + } + + return existingFreeSlots.every((slot, index) => slot === nextFreeSlots[index]); + } + + validateBatch(sourceContent: string, writes: GroupMarkerWriteRequest[]): void { + if (writes.length === 0) { + return; + } + + const filePath = writes[0]?.filePath; + const seenBlocks = new Set(); + + for (const write of writes) { + if (write.filePath !== filePath) { + throw new GroupMarkerError("Batch GI marker writes must belong to the same Markdown file."); + } + + if (write.sourceContent !== sourceContent) { + throw new GroupMarkerError(`GI marker writes for ${write.filePath} must share the scanned source content.`); + } + + if (write.markerLine !== undefined && write.markerLine < write.blockStartLine) { + throw new GroupMarkerError(`Cannot replace GI marker outside the heading block in ${write.filePath}.`); + } + + if (seenBlocks.has(write.blockStartLine)) { + throw new GroupMarkerError(`Duplicate GI marker write detected for block ${write.blockStartLine} in ${write.filePath}.`); + } + + seenBlocks.add(write.blockStartLine); + } + } + + applyBatch(sourceContent: string, writes: GroupMarkerWriteRequest[]): string { + if (writes.length === 0) { + return sourceContent; + } + + this.validateBatch(sourceContent, writes); + const lineEnding = sourceContent.includes("\r\n") ? "\r\n" : "\n"; + const lines = sourceContent.split(/\r?\n/); + + const sortedWrites = [...writes].sort((left, right) => right.blockStartLine - left.blockStartLine); + for (const write of sortedWrites) { + this.applyWrite(lines, write); + } + + return lines.join(lineEnding); + } + + applyWrite(lines: string[], write: GroupMarkerWriteRequest): void { + const uniqueRemovals = new Set(collectLegacyIdMarkerLineIndexes(lines, write.blockStartLine, write.blockEndLine)); + if (write.markerLine !== undefined) { + uniqueRemovals.add(write.markerLine - 1); + } + + const removalIndexes = [...uniqueRemovals].sort((left, right) => right - left); + const removalsWithinBlock = [...uniqueRemovals].filter((lineIndex) => lineIndex <= write.blockEndLine - 1).length; + const insertionIndex = write.blockEndLine - removalsWithinBlock; + + for (const lineIndex of removalIndexes) { + lines.splice(lineIndex, 1); + } + + lines.splice(insertionIndex, 0, `${write.markerIndent ?? ""}${serializeGroupMarker(write.noteId, write.itemToSlot, write.freeSlots)}`); + } +} + +export function serializeGroupMarker(noteId: number, itemToSlot: Record, freeSlots: number[]): string { + validateSlotRanges(itemToSlot, freeSlots); + const itemEntries = Object.entries(itemToSlot) + .sort((left, right) => left[1] - right[1] || left[0].localeCompare(right[0])) + .map(([itemId, slot]) => `${itemId}:${slot}`) + .join(","); + const freeEntry = [...new Set(freeSlots)].sort((left, right) => left - right).join(","); + return ``; +} + +function parseItemToSlotMapping(rawValue: string): Record { + if (!rawValue.trim()) { + return {}; + } + + const itemToSlot: Record = {}; + const usedSlots = new Set(); + + for (const entry of rawValue.split(",")) { + const trimmedEntry = entry.trim(); + if (!trimmedEntry) { + continue; + } + + const [itemId, slotValue] = trimmedEntry.split(":"); + if (!itemId || !slotValue || !GROUP_ITEM_ID_REGEXP.test(itemId)) { + throw new GroupMarkerError(`Invalid GI item mapping entry: ${entry}`); + } + + const slot = Number(slotValue); + if (!Number.isInteger(slot) || slot < 1) { + throw new GroupMarkerError(`Invalid GI slot value: ${slotValue}`); + } + + if (itemToSlot[itemId] !== undefined || usedSlots.has(slot)) { + throw new GroupMarkerError(`Duplicate GI mapping entry detected: ${entry}`); + } + + itemToSlot[itemId] = slot; + usedSlots.add(slot); + } + + return itemToSlot; +} + +function parseFreeSlots(rawValue: string): number[] { + if (!rawValue.trim()) { + return []; + } + + const freeSlots: number[] = []; + const seen = new Set(); + for (const part of rawValue.split(",")) { + const trimmed = part.trim(); + if (!trimmed) { + continue; + } + + const slot = Number(trimmed); + if (!Number.isInteger(slot) || slot < 1) { + throw new GroupMarkerError(`Invalid GI free slot value: ${trimmed}`); + } + + if (seen.has(slot)) { + throw new GroupMarkerError(`Duplicate GI free slot detected: ${trimmed}`); + } + + seen.add(slot); + freeSlots.push(slot); + } + + return freeSlots.sort((left, right) => left - right); +} + +function validateSlotRanges(itemToSlot: Record, freeSlots: number[]): void { + const usedSlots = new Set(); + for (const slot of Object.values(itemToSlot)) { + if (!Number.isInteger(slot) || slot < 1 || slot > 12) { + throw new GroupMarkerError(`GI item slot must be an integer between 1 and 12. Received: ${slot}`); + } + + usedSlots.add(slot); + } + + for (const slot of freeSlots) { + if (!Number.isInteger(slot) || slot < 1 || slot > 12) { + throw new GroupMarkerError(`GI free slot must be an integer between 1 and 12. Received: ${slot}`); + } + + if (usedSlots.has(slot)) { + throw new GroupMarkerError(`GI free slot ${slot} overlaps with an occupied slot.`); + } + } +} + +function collectLegacyIdMarkerLineIndexes(lines: string[], blockStartLine: number, blockEndLine: number): number[] { + const indexes: number[] = []; + + for (let lineIndex = blockStartLine; lineIndex < blockEndLine; lineIndex += 1) { + if (/^\s*