feat: add #anki-list QA Group 12 route / 新增 #anki-list QA Group 12 路线

Add a parallel QA Group 12 sync path with GI writeback, fixed model management, and GroupId recovery semantics.

新增并行 QA Group 12 同步路径,支持 GI 写回、固定模型管理,以及基于 GroupId 的恢复语义。
This commit is contained in:
Dusk 2026-04-20 13:42:30 +08:00
parent b1b2051502
commit 4845c721d5
30 changed files with 3121 additions and 93 deletions

View file

@ -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
<!--GI:n=999;i=a1:1,b2:2,c3:3;f=-->
```
语义锁定:
- `n` = noteId
- `i` = itemId -> slot
- `f` = free slots
附加规则:
- GI 固定写在 group block 尾部
- group write-back 只保留一条 GI
- 命中 group route 的块会清理内部旧 `<!--ID: ...-->`
### 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 更适合当前代码结构。

View file

@ -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`
- 每个子项独立写 `<!--ID: noteId-->`
所以它不是 group note它仍属于 atomic 路线。
### 3. 当前 write-back 只理解 `<!--ID: noteId-->`
当前 marker 写回链路:
- `CardMarkerService` 只支持 `<!--ID: ...-->`
- `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` 需要独立协议,而不是复用现有 `<!--ID: ...-->`
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 cardgroup 需要新 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 modelmarker/item/block/state/payload
3. 新 parser / serializerGI + 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。
这条路径最符合当前仓库现实,也最容易保证旧路线不回归。

View file

@ -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
<!--GI:n=999;i=a1:1,b2:2,c3:3;f=-->
```
语义:
- `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 的块写回时清理内部旧 `<!--ID: ...-->`
- state 需要新增以 `GroupId` 为主键的 group state
### Required validation surface
最少覆盖:
- GI 解析与序列化
- slot 分配与稳定性
- GI 写回与旧 inner marker 清理
- GroupId 恢复
- model 自动创建/补齐/漂移修复
- 端到端 sibling cards 生成
- 原有 basic/cloze/semantic-qa 不回归
## Implementation Note
本文件只保留参考方案的需求意图和约束摘要,避免后续实现过程中偏离用户 feature contract。

View file

@ -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", () => {

View file

@ -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.");
}

View file

@ -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<string, string>;
}
export interface AnkiGateway {
ensureDeckExists(deckName: string): Promise<void>;
ensureDecks(deckNames: string[]): Promise<void>;
@ -48,4 +66,17 @@ export interface AnkiGateway {
deleteDecks(deckNames: string[]): Promise<void>;
storeMedia(asset: MediaAsset): Promise<void>;
storeMediaFiles(assets: MediaAsset[]): Promise<void>;
}
export interface AnkiGroupGateway extends AnkiGateway {
getModelFieldNames(modelName: string): Promise<string[]>;
getModelTemplates(modelName: string): Promise<Record<string, AnkiModelTemplate>>;
getModelStyling(modelName: string): Promise<string>;
createModel(input: CreateAnkiModelInput): Promise<void>;
addModelField(modelName: string, fieldName: string): Promise<void>;
addModelTemplate(modelName: string, template: AnkiModelTemplate): Promise<void>;
updateModelTemplate(modelName: string, template: AnkiModelTemplate): Promise<void>;
updateModelStyling(modelName: string, css: string): Promise<void>;
findNoteIds(query: string): Promise<number[]>;
getNoteDetails(noteIds: number[]): Promise<AnkiNoteDetails[]>;
}

View file

@ -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<string, CardState[]>;
groupBlocksByFilePath: Map<string, GroupBlockState[]>;
pendingByFilePath: Map<string, PendingWriteBackState[]>;
}
@ -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<FileIndexerResult> {
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<string, CardState[]>();
const groupBlocksByFilePath = new Map<string, GroupBlockState[]>();
const pendingByFilePath = new Map<string, PendingWriteBackState[]>();
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,

View file

@ -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(/<!--GI:n=9001;i=[^;]+;f=3,4,5,6,7,8,9,10,11,12-->/);
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"),

View file

@ -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<ManualSyncResult> {
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<string>,
resolvedNoteIds: Map<string, number | undefined>,
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<string, GroupBlockState[]>();
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<string, number>();
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<number, GroupBlockState>(Object.values(state.groupBlocks ?? {})
.filter((groupBlock) => !groupBlock.orphan)
.map((groupBlock) => [groupBlock.noteId, groupBlock]));
const bySrc = new Map<string, GroupBlockState[]>();
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<string, DeckResolutionWarning>();
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*<!--\s*ID:/i.test(lines[lineIndex] ?? "")) {
return true;
}
}
return false;
}

View file

@ -4,6 +4,7 @@ import type { IndexedFile } from "@/domain/manual-sync/entities/IndexedFile";
import type { PendingWriteBackState } from "@/domain/manual-sync/entities/PluginState";
import type { PlannedCard } from "@/domain/manual-sync/value-objects/ManualSyncPlan";
import { CardMarkerService, type MarkerWriteRequest } from "@/domain/manual-sync/services/CardMarkerService";
import { GroupMarkerService, type GroupMarkerWriteRequest, serializeGroupMarker } from "@/domain/manual-sync/services/GroupMarkerService";
export interface MarkdownWriteBackResult {
writtenSyncKeys: string[];
@ -16,10 +17,15 @@ export class MarkdownWriteBackService {
constructor(
private readonly vaultGateway: ManualSyncVaultGateway,
private readonly markerService = new CardMarkerService(),
private readonly groupMarkerService = new GroupMarkerService(),
) {}
async write(plannedCards: PlannedCard[], indexedFilesByPath: Map<string, IndexedFile>): Promise<MarkdownWriteBackResult> {
const plannedByFile = new Map<string, PlannedCard[]>();
async write(
plannedCards: PlannedCard[],
indexedFilesByPath: Map<string, IndexedFile>,
groupWrites: GroupMarkerWriteRequest[] = [],
): Promise<MarkdownWriteBackResult> {
const plannedByFile = new Map<string, Array<{ kind: "card"; plannedCard: PlannedCard } | { kind: "group"; write: GroupMarkerWriteRequest }>>();
const writtenSyncKeys: string[] = [];
const conflictFiles: string[] = [];
const failureFiles: Array<{ filePath: string; message: string }> = [];
@ -28,33 +34,61 @@ export class MarkdownWriteBackService {
for (const plannedCard of plannedCards) {
const entries = plannedByFile.get(plannedCard.card.filePath);
if (entries) {
entries.push(plannedCard);
entries.push({ kind: "card", plannedCard });
continue;
}
plannedByFile.set(plannedCard.card.filePath, [plannedCard]);
plannedByFile.set(plannedCard.card.filePath, [{ kind: "card", plannedCard }]);
}
for (const [filePath, fileCards] of plannedByFile.entries()) {
for (const groupWrite of groupWrites) {
const entries = plannedByFile.get(groupWrite.filePath);
if (entries) {
entries.push({ kind: "group", write: groupWrite });
continue;
}
plannedByFile.set(groupWrite.filePath, [{ kind: "group", write: groupWrite }]);
}
for (const [filePath, fileWrites] of plannedByFile.entries()) {
const indexedFile = indexedFilesByPath.get(filePath);
const sourceContent = indexedFile?.content ?? fileCards[0]?.card.sourceContent;
const sourceContent = indexedFile?.content ?? resolveSourceContent(fileWrites[0]);
if (!sourceContent || !indexedFile) {
pendingEntries.push(...fileCards.map((plannedCard) => this.createPendingEntry(filePath, plannedCard, indexedFile?.fileHash ?? "")));
pendingEntries.push(...fileWrites.map((fileWrite) => this.createPendingEntry(filePath, fileWrite, indexedFile?.fileHash ?? "")));
failureFiles.push({ filePath, message: `Missing scanned source content for ${filePath}.` });
continue;
}
try {
const nextContent = this.markerService.applyBatch(
sourceContent,
fileCards.map((plannedCard) => this.toWriteRequest(plannedCard, sourceContent)),
);
const cardWrites = fileWrites.flatMap((fileWrite) => fileWrite.kind === "card" ? [this.toWriteRequest(fileWrite.plannedCard, sourceContent)] : []);
const resolvedGroupWrites = fileWrites.flatMap((fileWrite) => fileWrite.kind === "group" ? [fileWrite.write] : []);
this.markerService.validateBatch(sourceContent, cardWrites);
this.groupMarkerService.validateBatch(sourceContent, resolvedGroupWrites);
const lineEnding = sourceContent.includes("\r\n") ? "\r\n" : "\n";
const lines = sourceContent.split(/\r?\n/);
const orderedWrites = [
...cardWrites.map((write) => ({ kind: "card" as const, blockStartLine: write.blockStartLine, write })),
...resolvedGroupWrites.map((write) => ({ kind: "group" as const, blockStartLine: write.blockStartLine, write })),
].sort((left, right) => right.blockStartLine - left.blockStartLine || (left.kind === right.kind ? 0 : left.kind === "group" ? -1 : 1));
for (const orderedWrite of orderedWrites) {
if (orderedWrite.kind === "card") {
this.markerService.applyWrite(lines, orderedWrite.write);
continue;
}
this.groupMarkerService.applyWrite(lines, orderedWrite.write);
}
const nextContent = lines.join(lineEnding);
await this.vaultGateway.replaceMarkdownFile(filePath, sourceContent, nextContent);
writtenSyncKeys.push(...fileCards.map((plannedCard) => plannedCard.card.syncKey));
writtenSyncKeys.push(...fileWrites.map((fileWrite) => fileWrite.kind === "card" ? fileWrite.plannedCard.card.syncKey : fileWrite.write.syncKey));
} catch (error) {
pendingEntries.push(...fileCards.map((plannedCard) => this.createPendingEntry(filePath, plannedCard, indexedFile.fileHash)));
pendingEntries.push(...fileWrites.map((fileWrite) => this.createPendingEntry(filePath, fileWrite, indexedFile.fileHash)));
if (error instanceof MarkdownWriteConflictError) {
conflictFiles.push(filePath);
@ -89,20 +123,49 @@ export class MarkdownWriteBackService {
};
}
private createPendingEntry(filePath: string, plannedCard: PlannedCard, expectedFileHash: string): PendingWriteBackState {
const noteId = requireNoteId(plannedCard);
private createPendingEntry(
filePath: string,
fileWrite: { kind: "card"; plannedCard: PlannedCard } | { kind: "group"; write: GroupMarkerWriteRequest },
expectedFileHash: string,
): PendingWriteBackState {
if (fileWrite.kind === "card") {
const noteId = requireNoteId(fileWrite.plannedCard);
return {
filePath,
blockStartLine: fileWrite.plannedCard.card.blockStartLine,
expectedFileHash,
targetMarker: this.markerService.create(noteId).raw,
rawBlockHash: fileWrite.plannedCard.card.rawBlockHash,
targetNoteId: noteId,
markerKind: "card-id",
};
}
return {
filePath,
blockStartLine: plannedCard.card.blockStartLine,
blockStartLine: fileWrite.write.blockStartLine,
expectedFileHash,
targetMarker: this.markerService.create(noteId).raw,
rawBlockHash: plannedCard.card.rawBlockHash,
targetNoteId: noteId,
targetMarker: serializeGroupMarker(fileWrite.write.noteId, fileWrite.write.itemToSlot, fileWrite.write.freeSlots),
rawBlockHash: getRawBlockHashFromSyncKey(fileWrite.write.syncKey),
targetNoteId: fileWrite.write.noteId,
markerKind: "group-gi",
targetGroupId: fileWrite.write.groupId,
};
}
}
function resolveSourceContent(fileWrite: { kind: "card"; plannedCard: PlannedCard } | { kind: "group"; write: GroupMarkerWriteRequest } | undefined): string | undefined {
if (!fileWrite) {
return undefined;
}
return fileWrite.kind === "card" ? fileWrite.plannedCard.card.sourceContent : fileWrite.write.sourceContent;
}
function getRawBlockHashFromSyncKey(syncKey: string): string {
return syncKey.split("\u0000").at(-1) ?? "";
}
function requireNoteId(plannedCard: PlannedCard): number {
if (plannedCard.noteId === undefined) {
throw new Error(`Cannot write marker without noteId for block ${plannedCard.card.filePath}:${plannedCard.card.blockStartLine}.`);

View file

@ -0,0 +1,97 @@
import type { AnkiModelTemplate, CreateAnkiModelInput } from "@/application/ports/AnkiGateway";
import type { GroupItem } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
export const QA_GROUP_MODEL_NAME = "ObsiAnki QA Group 12";
export const QA_GROUP_SLOT_COUNT = 12;
export function buildQaGroupModelDefinition(): CreateAnkiModelInput {
return {
modelName: QA_GROUP_MODEL_NAME,
fieldNames: buildQaGroupFieldNames(),
templates: buildQaGroupTemplates(),
css: QA_GROUP_MODEL_CSS,
};
}
export function buildQaGroupFieldNames(): string[] {
const fieldNames = ["Stem", "GroupId", "Src"];
for (let slot = 1; slot <= QA_GROUP_SLOT_COUNT; slot += 1) {
const slotId = formatQaGroupSlot(slot);
fieldNames.push(`${slotId}_Id`, `${slotId}_Q`, `${slotId}_A`);
}
return fieldNames;
}
export function buildQaGroupTemplates(): AnkiModelTemplate[] {
const templates: AnkiModelTemplate[] = [];
for (let slot = 1; slot <= QA_GROUP_SLOT_COUNT; slot += 1) {
const slotId = formatQaGroupSlot(slot);
templates.push({
name: `Q${slotId.slice(1)}`,
front: `{{#${slotId}_Q}}{{#${slotId}_A}}<div class="stem">{{Stem}}</div>\n<div class="q">{{${slotId}_Q}}</div>{{/${slotId}_A}}{{/${slotId}_Q}}`,
back: `{{FrontSide}}\n\n<hr id="answer">\n\n<div class="a">{{${slotId}_A}}</div>\n<div class="meta">{{Src}}</div>`,
});
}
return templates;
}
export function buildQaGroupNoteFields(stem: string, groupId: string, src: string, items: GroupItem[]): Record<string, string> {
const fields = Object.fromEntries(buildQaGroupFieldNames().map((fieldName) => [fieldName, ""]));
fields.Stem = stem;
fields.GroupId = groupId;
fields.Src = src;
for (const item of items) {
if (!item.itemId || !item.slot) {
continue;
}
const slotId = formatQaGroupSlot(item.slot);
fields[`${slotId}_Id`] = item.itemId;
fields[`${slotId}_Q`] = item.title;
fields[`${slotId}_A`] = item.answer;
}
return fields;
}
export function formatQaGroupSlot(slot: number): string {
return `S${String(slot).padStart(2, "0")}`;
}
export const QA_GROUP_MODEL_CSS = [
".card {",
" font-family: 'Helvetica Neue', Arial, sans-serif;",
" font-size: 22px;",
" line-height: 1.45;",
" text-align: left;",
"}",
"",
".stem {",
" font-size: 0.85em;",
" opacity: 0.72;",
" margin-bottom: 0.75em;",
"}",
"",
".q {",
" font-size: 1.25em;",
" font-weight: 700;",
" margin-bottom: 0.85em;",
"}",
"",
".a {",
" font-size: 1.08em;",
"}",
"",
".meta {",
" margin-top: 1.2em;",
" padding-top: 0.65em;",
" border-top: 1px solid rgba(0, 0, 0, 0.16);",
" font-size: 0.75em;",
" opacity: 0.72;",
"}",
].join("\n");

View file

@ -0,0 +1,44 @@
import type { AnkiGroupGateway } from "@/application/ports/AnkiGateway";
import { buildQaGroupModelDefinition } from "./QaGroupModelDefinition";
export class QaGroupModelService {
constructor(private readonly ankiGateway: AnkiGroupGateway) {}
async ensureModel(): Promise<void> {
const definition = buildQaGroupModelDefinition();
const modelNames = await this.ankiGateway.listNoteModels();
if (!modelNames.includes(definition.modelName)) {
await this.ankiGateway.createModel(definition);
return;
}
const existingFieldNames = await this.ankiGateway.getModelFieldNames(definition.modelName);
for (const fieldName of definition.fieldNames) {
if (existingFieldNames.includes(fieldName)) {
continue;
}
await this.ankiGateway.addModelField(definition.modelName, fieldName);
}
const existingTemplates = await this.ankiGateway.getModelTemplates(definition.modelName);
for (const template of definition.templates) {
const existingTemplate = existingTemplates[template.name];
if (!existingTemplate) {
await this.ankiGateway.addModelTemplate(definition.modelName, template);
continue;
}
if (existingTemplate.front !== template.front || existingTemplate.back !== template.back) {
await this.ankiGateway.updateModelTemplate(definition.modelName, template);
}
}
const existingCss = await this.ankiGateway.getModelStyling(definition.modelName);
if (existingCss !== definition.css) {
await this.ankiGateway.updateModelStyling(definition.modelName, definition.css);
}
}
}

View file

@ -0,0 +1,166 @@
import { describe, expect, it } from "vitest";
import { buildGroupSrc, type IndexedGroupCardBlock } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
import { createEmptyPluginState, type GroupBlockState } from "@/domain/manual-sync/entities/PluginState";
import { QA_GROUP_MODEL_NAME, buildQaGroupNoteFields } from "@/application/services/QaGroupModelDefinition";
import { createModule3Settings, FakeManualSyncAnkiGateway } from "@/test-support/manualSyncFakes";
import { QaGroupSyncService } from "./QaGroupSyncService";
describe("QaGroupSyncService", () => {
it("creates one QA Group note and assigns fresh item ids and slots", async () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
const itemIds = ["item-1", "item-2"];
const service = new QaGroupSyncService(
ankiGateway,
undefined,
undefined,
undefined,
() => 1234,
() => "group-1",
() => itemIds.shift() ?? "item-x",
);
const result = await service.sync([createIndexedGroupBlock()], createEmptyPluginState(), createModule3Settings());
expect(result.created).toBe(1);
expect(ankiGateway.addedNotes[0]?.modelName).toBe(QA_GROUP_MODEL_NAME);
expect(ankiGateway.addedNotes[0]?.fields).toMatchObject({
Stem: "Concepts",
GroupId: "group-1",
Src: buildGroupSrc("notes/example.md", "Concepts #anki-list"),
S01_Q: "Alpha",
S01_A: "First answer",
S02_Q: "Beta",
S02_A: "Second answer",
});
expect(result.syncedGroupBlocks[0]?.items).toMatchObject([
{ itemId: "item-1", slot: 1 },
{ itemId: "item-2", slot: 2 },
]);
expect(result.markerWrites[0]?.itemToSlot).toEqual({ "item-1": 1, "item-2": 2 });
});
it("preserves slots across reorder and reuses a free slot for a new item", async () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
const existingState = createStoredGroupBlockState();
ankiGateway.noteDetailsById.set(42, {
noteId: 42,
modelName: QA_GROUP_MODEL_NAME,
cardIds: [7001, 7002],
deckNames: ["notes"],
fields: buildQaGroupNoteFields(existingState.stem, existingState.groupId, existingState.src, existingState.items),
});
const service = new QaGroupSyncService(
ankiGateway,
undefined,
undefined,
undefined,
() => 1234,
() => "group-x",
() => "item-c",
);
const result = await service.sync([
createIndexedGroupBlock({
noteId: 42,
groupId: "group-1",
rawBlockHash: "hash-updated",
items: [
{ title: "Beta", answer: "Second answer", ordinalInMarkdown: 1 },
{ title: "Gamma", answer: "Third answer", ordinalInMarkdown: 2 },
{ title: "Alpha", answer: "First answer", ordinalInMarkdown: 3 },
],
}),
], {
...createEmptyPluginState(),
groupBlocks: {
"group-1": existingState,
},
}, createModule3Settings());
expect(result.updated).toBe(1);
expect(result.syncedGroupBlocks[0]?.items).toMatchObject([
{ title: "Beta", itemId: "item-b", slot: 3 },
{ title: "Gamma", itemId: "item-c", slot: 2 },
{ title: "Alpha", itemId: "item-a", slot: 1 },
]);
expect(result.markerWrites[0]?.itemToSlot).toEqual({
"item-a": 1,
"item-c": 2,
"item-b": 3,
});
});
});
function createIndexedGroupBlock(overrides: Partial<IndexedGroupCardBlock> = {}): IndexedGroupCardBlock {
return {
syncKey: overrides.syncKey ?? "notes/example.md\u0000group\u00001\u0000hash-1",
markerState: overrides.markerState ?? "missing",
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 ?? 64,
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 ?? "hash-1",
deckWarnings: overrides.deckWarnings ?? [],
items: overrides.items ?? [
{ title: "Alpha", answer: "First answer", ordinalInMarkdown: 1 },
{ title: "Beta", answer: "Second answer", ordinalInMarkdown: 2 },
],
groupMarker: overrides.groupMarker,
freeSlots: overrides.freeSlots ?? [],
sourceContent: overrides.sourceContent ?? [
"#### Concepts #anki-list",
"- Alpha",
" - First answer",
"- Beta",
" - Second answer",
].join("\n"),
noteId: overrides.noteId,
groupId: overrides.groupId,
identitySource: overrides.identitySource,
deckHint: overrides.deckHint,
deckHintSource: overrides.deckHintSource,
};
}
function createStoredGroupBlockState(): GroupBlockState {
return {
groupId: "group-1",
noteId: 42,
filePath: "notes/example.md",
headingText: "Concepts #anki-list",
backlinkHeadingText: "Concepts #anki-list",
headingLevel: 4,
stem: "Concepts",
src: buildGroupSrc("notes/example.md", "Concepts #anki-list"),
blockStartOffset: 0,
blockEndOffset: 64,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 5,
contentEndLine: 5,
rawBlockText: "qa-group:Concepts",
rawBlockHash: "hash-previous",
deck: "notes",
deckWarnings: [],
items: [
{ itemId: "item-a", title: "Alpha", answer: "First answer", slot: 1, ordinalInMarkdown: 1 },
{ itemId: "item-b", title: "Beta", answer: "Second answer", slot: 3, ordinalInMarkdown: 2 },
],
freeSlots: [2, 4, 5, 6, 7, 8, 9, 10, 11, 12],
lastSyncedAt: 100,
orphan: false,
};
}

View file

@ -0,0 +1,483 @@
import type { PluginSettings } from "@/application/config/PluginSettings";
import type { AnkiGroupGateway, AnkiNoteDetails } from "@/application/ports/AnkiGateway";
import { buildGroupSrc, type GroupItem, type IndexedGroupCardBlock } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
import type { GroupBlockState, PluginState } from "@/domain/manual-sync/entities/PluginState";
import { GroupMarkerService, type GroupMarkerWriteRequest } from "@/domain/manual-sync/services/GroupMarkerService";
import { DeckResolutionService } from "@/domain/manual-sync/services/DeckResolutionService";
import type { DeckResolutionWarning } from "@/domain/manual-sync/value-objects/DeckResolution";
import { hashString } from "@/domain/shared/hash";
import { buildQaGroupNoteFields, formatQaGroupSlot, QA_GROUP_MODEL_NAME, QA_GROUP_SLOT_COUNT } from "./QaGroupModelDefinition";
import { QaGroupModelService } from "./QaGroupModelService";
export interface QaGroupSyncExecutionResult {
created: number;
updated: number;
migratedDecks: number;
markerWrites: GroupMarkerWriteRequest[];
resolvedNoteIds: Map<string, number>;
touchedSyncKeys: string[];
syncedGroupBlocks: GroupBlockState[];
warnings: DeckResolutionWarning[];
}
interface GroupStateIndex {
byGroupId: Map<string, GroupBlockState>;
byNoteId: Map<number, GroupBlockState>;
bySrc: Map<string, GroupBlockState[]>;
}
interface RecoveredGroupRecord {
noteId?: number;
groupId?: string;
items: GroupItem[];
freeSlots: number[];
noteDetails?: AnkiNoteDetails;
stateRecord?: GroupBlockState;
}
export class QaGroupSyncService {
private sequence = 0;
constructor(
private readonly ankiGateway: AnkiGroupGateway,
private readonly qaGroupModelService = new QaGroupModelService(ankiGateway),
private readonly deckResolutionService = new DeckResolutionService(),
private readonly groupMarkerService = new GroupMarkerService(),
private readonly now: () => number = () => Date.now(),
private readonly createGroupId: () => string = () => `g_${hashString(`${Date.now()}_${Math.random()}_${Date.now()}`).slice(0, 12)}`,
private readonly createItemId: () => string = () => {
this.sequence += 1;
return `i_${hashString(`${Date.now()}_${this.sequence}_${Math.random()}`).slice(0, 10)}`;
},
) {}
async sync(blocks: IndexedGroupCardBlock[], state: PluginState, settings: PluginSettings): Promise<QaGroupSyncExecutionResult> {
if (blocks.length === 0) {
return {
created: 0,
updated: 0,
migratedDecks: 0,
markerWrites: [],
resolvedNoteIds: new Map(),
touchedSyncKeys: [],
syncedGroupBlocks: [],
warnings: [],
};
}
await this.qaGroupModelService.ensureModel();
const stateIndex = buildGroupStateIndex(state.groupBlocks ?? {});
const ensuredDecks = new Set<string>();
const warningMap = new Map<string, DeckResolutionWarning>();
const markerWrites: GroupMarkerWriteRequest[] = [];
const resolvedNoteIds = new Map<string, number>();
const touchedSyncKeys = new Set<string>();
const syncedGroupBlocks: GroupBlockState[] = [];
let created = 0;
let updated = 0;
let migratedDecks = 0;
for (const block of blocks) {
if (block.items.length > QA_GROUP_SLOT_COUNT) {
throw new Error(`QA Group block exceeds 12 items at ${block.filePath}:${block.blockStartLine}.`);
}
const recovered = await this.resolveRecoveredGroup(block, stateIndex);
const groupId = recovered.groupId ?? block.groupId ?? this.createGroupId();
const resolvedItems = reconcileGroupItems(block.items, recovered.items, this.createItemId);
if (resolvedItems.length > QA_GROUP_SLOT_COUNT) {
throw new Error(`QA Group block exceeds 12 items after recovery at ${block.filePath}:${block.blockStartLine}.`);
}
const freeSlots = buildFreeSlotsFromItems(resolvedItems);
const deckResolution = this.deckResolutionService.resolve({
filePath: block.filePath,
deckHint: block.deckHint,
deckHintSource: block.deckHintSource,
deckWarnings: block.deckWarnings,
} as never, settings.defaultDeck, settings.folderDeckMode);
for (const warning of deckResolution.warnings) {
warningMap.set(`${warning.filePath}:${warning.code}:${warning.message}`, warning);
}
const deck = deckResolution.resolvedDeck.value;
if (!ensuredDecks.has(deck)) {
await this.ankiGateway.ensureDeckExists(deck);
ensuredDecks.add(deck);
}
const fields = buildQaGroupNoteFields(block.stem, groupId, block.src, resolvedItems);
let noteId = recovered.noteId ?? block.noteId;
let existingNote = recovered.noteDetails;
if (noteId === undefined) {
noteId = await this.ankiGateway.addNote({
deckName: deck,
modelName: QA_GROUP_MODEL_NAME,
fields,
tags: [],
});
created += 1;
touchedSyncKeys.add(block.syncKey);
} else {
const stateUnchanged = recovered.stateRecord
&& recovered.stateRecord.rawBlockHash === block.rawBlockHash
&& recovered.stateRecord.deck === deck
&& !recovered.stateRecord.orphan;
let fieldsChanged = !stateUnchanged;
let deckChanged = recovered.stateRecord ? recovered.stateRecord.deck !== deck : false;
if (!stateUnchanged) {
existingNote ??= await this.loadQaGroupNote(noteId, block);
fieldsChanged = !haveEqualFields(existingNote.fields, fields);
deckChanged = !(existingNote.deckNames ?? []).includes(deck);
}
if (fieldsChanged || deckChanged) {
await this.ankiGateway.updateNote({
noteId,
fields,
deckName: deckChanged ? deck : undefined,
});
if (fieldsChanged) {
updated += 1;
}
if (deckChanged) {
migratedDecks += 1;
}
touchedSyncKeys.add(block.syncKey);
}
}
resolvedNoteIds.set(block.syncKey, noteId);
const itemToSlot = Object.fromEntries(resolvedItems
.filter((item) => item.itemId && item.slot)
.map((item) => [item.itemId as string, item.slot as number]));
if (
block.markerState !== "present-valid"
|| !this.groupMarkerService.isEquivalent(block.groupMarker, noteId, itemToSlot, freeSlots)
|| hasLegacyInnerIdMarkers(block)
) {
markerWrites.push({
syncKey: block.syncKey,
filePath: block.filePath,
blockStartLine: block.blockStartLine,
blockEndLine: block.blockEndLine,
markerLine: block.markerLine,
markerIndent: block.markerIndent,
noteId,
groupId,
itemToSlot,
freeSlots,
sourceContent: block.sourceContent ?? "",
});
}
const now = this.now();
syncedGroupBlocks.push({
groupId,
noteId,
filePath: block.filePath,
headingText: block.headingText,
backlinkHeadingText: block.backlinkHeadingText,
headingLevel: block.headingLevel,
stem: block.stem,
src: block.src || buildGroupSrc(block.filePath, block.backlinkHeadingText),
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,
deck,
deckHint: block.deckHint,
deckHintSource: block.deckHintSource,
deckWarnings: [...deckResolution.warnings],
items: resolvedItems,
freeSlots,
lastSyncedAt: touchedSyncKeys.has(block.syncKey) ? now : recovered.stateRecord?.lastSyncedAt ?? now,
orphan: false,
});
}
return {
created,
updated,
migratedDecks,
markerWrites,
resolvedNoteIds,
touchedSyncKeys: [...touchedSyncKeys],
syncedGroupBlocks,
warnings: [...warningMap.values()],
};
}
private async resolveRecoveredGroup(block: IndexedGroupCardBlock, stateIndex: GroupStateIndex): Promise<RecoveredGroupRecord> {
const stateRecord = (block.groupId ? stateIndex.byGroupId.get(block.groupId) : undefined)
?? (block.noteId !== undefined ? stateIndex.byNoteId.get(block.noteId) : undefined)
?? uniqueMatch(stateIndex.bySrc.get(block.src));
if (stateRecord) {
return {
noteId: stateRecord.noteId,
groupId: stateRecord.groupId,
items: stateRecord.items.map((item) => ({ ...item })),
freeSlots: [...stateRecord.freeSlots],
stateRecord,
};
}
if (block.groupId) {
const recoveredByGroupId = await this.findSingleQaGroupNote(`note:${quoteAnkiValue(QA_GROUP_MODEL_NAME)} GroupId:${quoteAnkiValue(block.groupId)}`, block);
if (recoveredByGroupId) {
return noteDetailsToRecoveredGroup(recoveredByGroupId, block.groupId);
}
}
if (block.noteId !== undefined) {
return noteDetailsToRecoveredGroup(await this.loadQaGroupNote(block.noteId, block), block.groupId);
}
const recoveredBySrc = await this.findSingleQaGroupNote(`note:${quoteAnkiValue(QA_GROUP_MODEL_NAME)} Src:${quoteAnkiValue(block.src)}`, block);
if (recoveredBySrc) {
return noteDetailsToRecoveredGroup(recoveredBySrc, block.groupId);
}
return {
items: [],
freeSlots: Array.from({ length: QA_GROUP_SLOT_COUNT }, (_value, index) => index + 1),
};
}
private async findSingleQaGroupNote(query: string, block: IndexedGroupCardBlock): Promise<AnkiNoteDetails | null> {
const noteIds = await this.ankiGateway.findNoteIds(query);
if (noteIds.length === 0) {
return null;
}
if (noteIds.length > 1) {
throw new Error(`QA Group recovery is ambiguous at ${block.filePath}:${block.blockStartLine}. Query: ${query}`);
}
return this.loadQaGroupNote(noteIds[0], block);
}
private async loadQaGroupNote(noteId: number, block: IndexedGroupCardBlock): Promise<AnkiNoteDetails> {
const note = (await this.ankiGateway.getNoteDetails([noteId]))[0];
if (!note) {
throw new Error(`Unable to load QA Group note ${noteId} for ${block.filePath}:${block.blockStartLine}.`);
}
if (note.modelName !== QA_GROUP_MODEL_NAME) {
throw new Error(`Note ${noteId} for ${block.filePath}:${block.blockStartLine} is ${note.modelName}, expected ${QA_GROUP_MODEL_NAME}.`);
}
return note;
}
}
function buildGroupStateIndex(groupBlocks: Record<string, GroupBlockState>): GroupStateIndex {
const byGroupId = new Map<string, GroupBlockState>();
const byNoteId = new Map<number, GroupBlockState>();
const bySrc = new Map<string, GroupBlockState[]>();
for (const groupBlock of Object.values(groupBlocks)) {
if (groupBlock.orphan) {
continue;
}
byGroupId.set(groupBlock.groupId, groupBlock);
byNoteId.set(groupBlock.noteId, groupBlock);
const entries = bySrc.get(groupBlock.src);
if (entries) {
entries.push(groupBlock);
continue;
}
bySrc.set(groupBlock.src, [groupBlock]);
}
return {
byGroupId,
byNoteId,
bySrc,
};
}
function noteDetailsToRecoveredGroup(note: AnkiNoteDetails, fallbackGroupId?: string): RecoveredGroupRecord {
const items: GroupItem[] = [];
const occupiedSlots = new Set<number>();
for (let slot = 1; slot <= QA_GROUP_SLOT_COUNT; slot += 1) {
const slotId = formatQaGroupSlot(slot);
const itemId = (note.fields[`${slotId}_Id`] ?? "").trim() || `legacy_${slotId.toLowerCase()}`;
const title = (note.fields[`${slotId}_Q`] ?? "").trim();
const answer = (note.fields[`${slotId}_A`] ?? "").trim();
if (!title && !answer && !(note.fields[`${slotId}_Id`] ?? "").trim()) {
continue;
}
occupiedSlots.add(slot);
items.push({
itemId,
title,
answer,
slot,
ordinalInMarkdown: slot,
});
}
return {
noteId: note.noteId,
groupId: (note.fields.GroupId ?? "").trim() || fallbackGroupId,
items,
freeSlots: Array.from({ length: QA_GROUP_SLOT_COUNT }, (_value, index) => index + 1).filter((slot) => !occupiedSlots.has(slot)),
noteDetails: note,
};
}
function reconcileGroupItems(currentItems: GroupItem[], recoveredItems: GroupItem[], createItemId: () => string): GroupItem[] {
const recoveredQueues = new Map<string, GroupItem[]>();
const unmatchedRecovered: GroupItem[] = [];
for (const item of recoveredItems) {
const key = createItemContentKey(item.title, item.answer);
const queue = recoveredQueues.get(key);
if (queue) {
queue.push(item);
} else {
recoveredQueues.set(key, [item]);
}
unmatchedRecovered.push(item);
}
const resolvedItems: GroupItem[] = [];
const unmatchedCurrentIndexes: number[] = [];
for (let index = 0; index < currentItems.length; index += 1) {
const currentItem = currentItems[index];
const queue = recoveredQueues.get(createItemContentKey(currentItem.title, currentItem.answer));
const matchedRecovered = queue?.shift();
if (!matchedRecovered) {
unmatchedCurrentIndexes.push(index);
continue;
}
removeRecoveredReference(unmatchedRecovered, matchedRecovered);
resolvedItems[index] = {
...currentItem,
itemId: matchedRecovered.itemId,
slot: matchedRecovered.slot,
};
}
const remainingRecovered = [...unmatchedRecovered].sort((left, right) => (left.ordinalInMarkdown - right.ordinalInMarkdown) || ((left.slot ?? 999) - (right.slot ?? 999)));
for (const currentIndex of unmatchedCurrentIndexes) {
const currentItem = currentItems[currentIndex];
const recoveredItem = remainingRecovered.shift();
if (recoveredItem) {
resolvedItems[currentIndex] = {
...currentItem,
itemId: recoveredItem.itemId,
slot: recoveredItem.slot,
};
continue;
}
resolvedItems[currentIndex] = {
...currentItem,
itemId: createItemId(),
};
}
const occupiedSlots = new Set<number>(resolvedItems.map((item) => item.slot).filter((slot): slot is number => typeof slot === "number"));
const freeSlots = Array.from({ length: QA_GROUP_SLOT_COUNT }, (_value, index) => index + 1).filter((slot) => !occupiedSlots.has(slot));
for (const item of resolvedItems) {
if (item.slot !== undefined) {
continue;
}
const nextSlot = freeSlots.shift();
if (nextSlot === undefined) {
throw new Error("No free QA Group slots are available.");
}
item.slot = nextSlot;
}
return resolvedItems.map((item, index) => ({
...item,
ordinalInMarkdown: index + 1,
}));
}
function buildFreeSlotsFromItems(items: GroupItem[]): number[] {
const occupiedSlots = new Set<number>(items.map((item) => item.slot).filter((slot): slot is number => typeof slot === "number"));
return Array.from({ length: QA_GROUP_SLOT_COUNT }, (_value, index) => index + 1).filter((slot) => !occupiedSlots.has(slot));
}
function createItemContentKey(title: string, answer: string): string {
return `${title}\u0000${answer}`;
}
function removeRecoveredReference(items: GroupItem[], target: GroupItem): void {
const index = items.findIndex((item) => item.itemId === target.itemId && item.slot === target.slot && item.title === target.title && item.answer === target.answer);
if (index >= 0) {
items.splice(index, 1);
}
}
function uniqueMatch<T>(items: T[] | undefined): T | undefined {
if (!items || items.length !== 1) {
return undefined;
}
return items[0];
}
function haveEqualFields(left: Record<string, string>, right: Record<string, string>): boolean {
const leftKeys = Object.keys(left).sort();
const rightKeys = Object.keys(right).sort();
if (leftKeys.length !== rightKeys.length) {
return false;
}
for (let index = 0; index < leftKeys.length; index += 1) {
if (leftKeys[index] !== rightKeys[index]) {
return false;
}
if ((left[leftKeys[index]] ?? "") !== (right[rightKeys[index]] ?? "")) {
return false;
}
}
return true;
}
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*<!--\s*ID:/i.test(lines[lineIndex] ?? "")) {
return true;
}
}
return false;
}
function quoteAnkiValue(value: string): string {
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
}

View file

@ -1,4 +1,5 @@
import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
import type { IndexedGroupCardBlock } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
export interface IndexedFile {
filePath: string;
@ -6,4 +7,5 @@ export interface IndexedFile {
fileStamp: string;
content?: string;
cards: IndexedCard[];
groupBlocks?: IndexedGroupCardBlock[];
}

View file

@ -0,0 +1,57 @@
import type { DeckResolutionWarning, DeckResolutionSource } from "@/domain/manual-sync/value-objects/DeckResolution";
export type GroupMarkerState = "missing" | "present-valid" | "present-invalid";
export type GroupIdentitySource = "gi-marker" | "state-recovery" | "anki-recovery";
export interface GroupMarker {
noteId?: number;
itemToSlot: Record<string, number>;
freeSlots: number[];
}
export interface GroupItem {
itemId?: string;
title: string;
answer: string;
slot?: number;
ordinalInMarkdown: number;
}
export interface IndexedGroupCardBlock {
noteId?: number;
groupId?: string;
syncKey: string;
markerState: GroupMarkerState;
identitySource?: GroupIdentitySource;
filePath: string;
headingText: string;
backlinkHeadingText: string;
headingLevel: number;
stem: string;
src: string;
blockStartOffset: number;
blockEndOffset: number;
blockStartLine: number;
bodyStartLine: number;
blockEndLine: number;
contentEndLine: number;
markerLine?: number;
markerIndent?: string;
rawBlockText: string;
rawBlockHash: string;
deckHint?: string;
deckHintSource?: Extract<DeckResolutionSource, "frontmatter" | "body">;
deckWarnings: DeckResolutionWarning[];
items: GroupItem[];
groupMarker?: GroupMarker;
freeSlots: number[];
sourceContent?: string;
}
export function createIndexedGroupSyncKey(filePath: string, blockStartLine: number, rawBlockHash: string): string {
return `${filePath}\u0000group\u0000${blockStartLine}\u0000${rawBlockHash}`;
}
export function buildGroupSrc(filePath: string, headingText: string): string {
return `${filePath}#${headingText}`;
}

View file

@ -1,4 +1,5 @@
import type { CardType } from "@/domain/card/entities/RenderedFields";
import type { GroupItem } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
import type { DeckResolutionWarning, DeckResolutionSource } from "@/domain/manual-sync/value-objects/DeckResolution";
export interface FileState {
@ -8,6 +9,7 @@ export interface FileState {
deckRulesFingerprint?: string;
lastIndexedAt: number;
noteIds: number[];
groupIds?: string[];
}
export interface CardState {
@ -44,11 +46,43 @@ export interface PendingWriteBackState {
targetMarker: string;
rawBlockHash: string;
targetNoteId: number;
markerKind?: "card-id" | "group-gi";
targetGroupId?: string;
}
export interface GroupBlockState {
groupId: string;
noteId: number;
filePath: string;
headingText: string;
backlinkHeadingText: string;
headingLevel: number;
stem: string;
src: string;
blockStartOffset: number;
blockEndOffset: number;
blockStartLine: number;
bodyStartLine: number;
blockEndLine: number;
contentEndLine: number;
markerLine?: number;
markerIndent?: string;
rawBlockText: string;
rawBlockHash: string;
deck: string;
deckHint?: string;
deckHintSource?: Extract<DeckResolutionSource, "frontmatter" | "body">;
deckWarnings: DeckResolutionWarning[];
items: GroupItem[];
freeSlots: number[];
lastSyncedAt: number;
orphan: boolean;
}
export interface PluginState {
files: Record<string, FileState>;
cards: Record<string, CardState>;
groupBlocks?: Record<string, GroupBlockState>;
pendingWriteBack: PendingWriteBackState[];
}
@ -56,6 +90,7 @@ export function createEmptyPluginState(): PluginState {
return {
files: {},
cards: {},
groupBlocks: {},
pendingWriteBack: [],
};
}

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { CardState } from "@/domain/manual-sync/entities/PluginState";
import { buildGroupSrc } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
import type { CardState, GroupBlockState } from "@/domain/manual-sync/entities/PluginState";
import { hashString } from "@/domain/shared/hash";
import { CardIndexingService } from "./CardIndexingService";
@ -222,6 +223,98 @@ describe("CardIndexingService", () => {
});
});
it("indexes a #anki-list heading as one QA Group block and parses its GI marker", () => {
const service = new CardIndexingService();
const indexedFile = service.index(
{
path: "notes/example.md",
basename: "example",
content: [
"#### Concepts #anki-list",
"- Alpha",
" - First answer",
"- Beta",
" - Second answer",
"<!--GI:n=42;i=item_a:1,item_b:2;f=3,4,5,6,7,8,9,10,11,12-->",
].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> = {}): CardState {
lastSyncedAt: overrides.lastSyncedAt ?? 1,
orphan: overrides.orphan ?? false,
};
}
function createKnownGroupState(overrides: Partial<GroupBlockState> = {}): 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,
};
}

View file

@ -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<number>();
const usedGroupIds = new Set<string>();
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<number>,
usedGroupIds: Set<string>,
): { 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<string, CardState[]> {
@ -248,7 +370,7 @@ function groupKnownCardsByBlockKey(knownCards: CardState[]): Map<string, CardSta
function groupPendingWriteBackByBlockKey(pendingWriteBack: PendingWriteBackState[]): Map<string, PendingWriteBackState[]> {
const grouped = new Map<string, PendingWriteBackState[]>();
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) {

View file

@ -61,11 +61,22 @@ export class CardMarkerService {
return sourceContent;
}
const filePath = writes[0]?.filePath;
const seenBlocks = new Set<number>();
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<number>();
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}`);
}
}

View file

@ -10,7 +10,9 @@ export class DiffPlannerService {
plan(cards: IndexedCard[], state: PluginState, scopedFilePaths: string[], settings: PluginSettings): ManualSyncPlan {
const cardsBySyncKey = new Map<string, IndexedCard>();
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<string>();
const toCreate: PlannedCard[] = [];
const toUpdate: PlannedCard[] = [];

View file

@ -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",
" <!--ID: 42-->",
"- Beta",
" - Second answer",
"<!--GI:n=42;i=item_a:1;f=2,3,4,5,6,7,8,9,10,11,12-->",
].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("<!--ID: 42-->");
expect(nextContent.split("\n").at(-1)).toBe("<!--GI:n=42;i=item_a:1,item_b:2;f=3,4,5,6,7,8,9,10,11,12-->");
});
});

View file

@ -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<string, number>;
freeSlots: number[];
sourceContent: string;
}
export class GroupMarkerError extends Error {
constructor(message: string) {
super(message);
this.name = "GroupMarkerError";
}
}
const GROUP_MARKER_CANDIDATE_REGEXP = /<!--\s*GI:/;
const GROUP_MARKER_REGEXP = /^\s*<!--\s*GI:n=([1-9]\d*);i=([^;]*);f=([^;]*)\s*-->\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<string, number>, 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<string, number>, 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<number>();
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<number>(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<string, number>, 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 `<!--GI:n=${noteId};i=${itemEntries};f=${freeEntry}-->`;
}
function parseItemToSlotMapping(rawValue: string): Record<string, number> {
if (!rawValue.trim()) {
return {};
}
const itemToSlot: Record<string, number> = {};
const usedSlots = new Set<number>();
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<number>();
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<string, number>, freeSlots: number[]): void {
const usedSlots = new Set<number>();
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*<!--\s*ID:/i.test(lines[lineIndex] ?? "")) {
indexes.push(lineIndex);
}
}
return indexes;
}
function compareMarkerEntry(left: [string, number], right: [string, number]): number {
return left[1] - right[1] || left[0].localeCompare(right[0]);
}

View file

@ -0,0 +1,307 @@
import { hashString } from "@/domain/shared/hash";
import type { GroupItem, GroupMarkerState } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
import { GroupMarkerService, type ParsedGroupMarker } from "./GroupMarkerService";
const LIST_ITEM_REGEXP = /^([ \t]*)(?:[-+*]|\d+[.)])\s+(.*)$/;
export interface QaGroupBlockParserInput {
parentHeadingText: string;
marker: string;
bodyLines: string[];
bodyStartLine: number;
}
export interface ParsedQaGroupBlock {
stem: string;
items: GroupItem[];
markerState: GroupMarkerState;
groupMarker?: ParsedGroupMarker;
contentEndLine: number;
markerLine?: number;
markerIndent?: string;
rawBlockText: string;
rawBlockHash: string;
}
interface ListItemMatch {
index: number;
indent: string;
labelMarkdown: string;
}
interface ChildRegion {
rawLines: string[];
}
export class QaGroupBlockParser {
constructor(private readonly groupMarkerService = new GroupMarkerService()) {}
isQaGroupHeading(headingText: string, marker: string): boolean {
return headingText.trimEnd().endsWith(marker);
}
parse(input: QaGroupBlockParserInput): ParsedQaGroupBlock {
const stem = stripQaGroupMarker(input.parentHeadingText, input.marker);
const trailingMarker = extractTrailingGroupMarker(input.bodyLines, input.bodyStartLine, this.groupMarkerService);
const rootItems = collectRootListItems(trailingMarker.bodyLines);
const items: GroupItem[] = [];
for (let index = 0; index < rootItems.length; index += 1) {
const item = rootItems[index];
const nextItem = rootItems[index + 1];
const childRegion = collectChildRegion(
trailingMarker.bodyLines,
item.index + 1,
nextItem?.index ?? trailingMarker.bodyLines.length,
item.indent.length,
);
if (!childRegion) {
continue;
}
const firstSecondLevelItem = findFirstSecondLevelItem(childRegion.rawLines, item.indent.length);
if (!firstSecondLevelItem) {
continue;
}
items.push({
title: item.labelMarkdown,
answer: firstSecondLevelItem.labelMarkdown,
ordinalInMarkdown: index + 1,
});
}
const normalizedBodyLines = trimBlankEdges(trailingMarker.bodyLines.filter((line) => !isLegacyCardMarkerLine(line)));
const rawBlockText = [
`qa-group:${stem}`,
...items.map((item) => `${item.title}\n${item.answer}`),
...normalizedBodyLines,
].join("\n").trimEnd();
return {
stem,
items,
markerState: trailingMarker.markerState,
groupMarker: trailingMarker.groupMarker,
contentEndLine: findContentEndLine(trailingMarker.bodyLines, input.bodyStartLine, input.bodyStartLine - 1),
markerLine: trailingMarker.markerLine,
markerIndent: trailingMarker.markerIndent,
rawBlockText,
rawBlockHash: hashString(rawBlockText),
};
}
}
function stripQaGroupMarker(headingText: string, marker: string): string {
const trimmedHeading = headingText.trimEnd();
if (!trimmedHeading.endsWith(marker)) {
return trimmedHeading;
}
return trimmedHeading.slice(0, trimmedHeading.length - marker.length).trimEnd();
}
function extractTrailingGroupMarker(
rawLines: string[],
bodyStartLine: number,
groupMarkerService: GroupMarkerService,
): {
bodyLines: string[];
markerState: GroupMarkerState;
groupMarker?: ParsedGroupMarker;
markerLine?: number;
markerIndent?: string;
} {
let lastNonEmptyIndex = rawLines.length - 1;
while (lastNonEmptyIndex >= 0 && !rawLines[lastNonEmptyIndex].trim()) {
lastNonEmptyIndex -= 1;
}
if (lastNonEmptyIndex < 0) {
return {
bodyLines: rawLines,
markerState: "missing",
};
}
const lastLine = rawLines[lastNonEmptyIndex];
if (!groupMarkerService.isCandidate(lastLine)) {
return {
bodyLines: rawLines,
markerState: "missing",
};
}
const bodyLines = rawLines.filter((_line, index) => index !== lastNonEmptyIndex);
const parsedMarker = groupMarkerService.parse(lastLine, bodyStartLine + lastNonEmptyIndex);
return {
bodyLines,
markerState: parsedMarker ? "present-valid" : "present-invalid",
groupMarker: parsedMarker ?? undefined,
markerLine: bodyStartLine + lastNonEmptyIndex,
markerIndent: leadingWhitespace(lastLine),
};
}
function collectRootListItems(lines: string[]): ListItemMatch[] {
const candidates: ListItemMatch[] = [];
let fenceMarker: string | null = null;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const trimmed = line.trim();
if (isFenceLine(trimmed)) {
fenceMarker = fenceMarker ? null : trimmed.slice(0, 3);
continue;
}
if (fenceMarker) {
continue;
}
const match = line.match(LIST_ITEM_REGEXP);
if (!match) {
continue;
}
candidates.push({
index,
indent: match[1],
labelMarkdown: match[2].trimEnd(),
});
}
if (candidates.length === 0) {
return [];
}
const rootIndentLength = Math.min(...candidates.map((candidate) => candidate.indent.length));
return candidates.filter((candidate) => candidate.indent.length === rootIndentLength);
}
function collectChildRegion(
lines: string[],
startIndex: number,
endIndexExclusive: number,
parentIndentLength: number,
): ChildRegion | null {
let childStartIndex: number | undefined;
let childEndIndex = startIndex - 1;
let fenceMarker: string | null = null;
for (let index = startIndex; index < endIndexExclusive; index += 1) {
const line = lines[index];
const trimmed = line.trim();
const indentLength = leadingWhitespace(line).length;
const insideFence = Boolean(fenceMarker);
if (childStartIndex === undefined) {
if (!trimmed) {
continue;
}
if (indentLength <= parentIndentLength) {
return null;
}
childStartIndex = index;
} else if (!insideFence && trimmed && indentLength <= parentIndentLength) {
break;
}
childEndIndex = index;
if (isFenceLine(trimmed)) {
fenceMarker = fenceMarker ? null : trimmed.slice(0, 3);
}
}
if (childStartIndex === undefined) {
return null;
}
return {
rawLines: lines.slice(childStartIndex, childEndIndex + 1),
};
}
function findFirstSecondLevelItem(lines: string[], parentIndentLength: number): ListItemMatch | null {
const candidates: ListItemMatch[] = [];
let fenceMarker: string | null = null;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const trimmed = line.trim();
if (isFenceLine(trimmed)) {
fenceMarker = fenceMarker ? null : trimmed.slice(0, 3);
continue;
}
if (fenceMarker) {
continue;
}
const match = line.match(LIST_ITEM_REGEXP);
if (!match) {
continue;
}
const indent = match[1];
if (indent.length <= parentIndentLength) {
continue;
}
candidates.push({
index,
indent,
labelMarkdown: match[2].trimEnd(),
});
}
if (candidates.length === 0) {
return null;
}
const directIndentLength = Math.min(...candidates.map((candidate) => candidate.indent.length));
return candidates.find((candidate) => candidate.indent.length === directIndentLength) ?? null;
}
function findContentEndLine(bodyLines: string[], bodyStartLine: number, fallbackLine: number): number {
for (let index = bodyLines.length - 1; index >= 0; index -= 1) {
if (bodyLines[index].trim()) {
return bodyStartLine + index;
}
}
return fallbackLine;
}
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);
}
function leadingWhitespace(line: string): string {
const match = line.match(/^[ \t]*/);
return match?.[0] ?? "";
}
function isFenceLine(trimmedLine: string): boolean {
return trimmedLine.startsWith("```") || trimmedLine.startsWith("~~~");
}
function isLegacyCardMarkerLine(line: string): boolean {
return /^\s*<!--\s*ID:/i.test(line);
}

View file

@ -1,7 +1,7 @@
import { requestUrl } from "obsidian";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
import type { AddAnkiNoteInput, AnkiGateway, AnkiNoteSummary, ChangeDeckInput, DeckStat, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway";
import type { AddAnkiNoteInput, AnkiGroupGateway, AnkiModelTemplate, AnkiNoteDetails, AnkiNoteSummary, ChangeDeckInput, CreateAnkiModelInput, DeckStat, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway";
import type { MediaAsset } from "@/domain/card/entities/RenderedFields";
interface AnkiResponse<T> {
@ -11,6 +11,7 @@ interface AnkiResponse<T> {
interface NoteInfo {
cards: number[];
fields?: Record<string, { value?: string }>;
modelName?: string;
noteId?: number;
}
@ -26,9 +27,13 @@ interface RawDeckStats {
total_in_deck?: number;
}
type ModelTemplates = Record<string, unknown>;
type ModelTemplates = Record<string, { Front?: string; Back?: string }>;
export class AnkiConnectGateway implements AnkiGateway {
interface ModelStylingResponse {
css?: string;
}
export class AnkiConnectGateway implements AnkiGroupGateway {
constructor(private readonly getBaseUrl: () => string) {}
async ensureDeckExists(deckName: string): Promise<void> {
@ -52,40 +57,82 @@ export class AnkiConnectGateway implements AnkiGateway {
return Object.keys(deckNamesAndIds);
}
async getModelDetails(modelName: string): Promise<NoteModelDetails> {
const fieldNames = await this.invoke<string[]>("modelFieldNames", { modelName });
let isCloze = modelName.toLowerCase().includes("cloze");
try {
const templates = await this.invoke<ModelTemplates>("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 getModelFieldNames(modelName: string): Promise<string[]> {
return this.invoke<string[]>("modelFieldNames", { modelName });
}
async getDeckStats(deckNames: string[]): Promise<DeckStat[]> {
if (deckNames.length === 0) {
return [];
async getModelTemplates(modelName: string): Promise<Record<string, AnkiModelTemplate>> {
const templates = await this.invoke<ModelTemplates>("modelTemplates", { modelName });
return Object.fromEntries(Object.entries(templates).map(([templateName, template]) => [templateName, {
name: templateName,
front: template.Front ?? "",
back: template.Back ?? "",
}]));
}
async getModelStyling(modelName: string): Promise<string> {
const styling = await this.invoke<ModelStylingResponse | string>("modelStyling", { modelName });
if (typeof styling === "string") {
return styling;
}
const deckNamesAndIds = await this.invoke<Record<string, number>>("deckNamesAndIds", {});
const rawStats = await this.invoke<unknown>("getDeckStats", {
decks: deckNames,
return styling.css ?? "";
}
async createModel(input: CreateAnkiModelInput): Promise<void> {
await this.invoke("createModel", {
modelName: input.modelName,
inOrderFields: input.fieldNames,
css: input.css,
isCloze: Boolean(input.isCloze),
cardTemplates: input.templates.map((template) => ({
Name: template.name,
Front: template.front,
Back: template.back,
})),
});
return deckNames.map((deckName) => ({
deckName,
noteCount: extractDeckNoteCount(rawStats, deckNamesAndIds[deckName]),
}));
}
async getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]> {
async addModelField(modelName: string, fieldName: string): Promise<void> {
await this.invoke("modelFieldAdd", {
modelName,
fieldName,
});
}
async addModelTemplate(modelName: string, template: AnkiModelTemplate): Promise<void> {
await this.invoke("modelTemplateAdd", {
modelName,
templateName: template.name,
Front: template.front,
Back: template.back,
});
}
async updateModelTemplate(modelName: string, template: AnkiModelTemplate): Promise<void> {
await this.invoke("updateModelTemplates", {
model: modelName,
templates: {
[template.name]: {
Front: template.front,
Back: template.back,
},
},
});
}
async updateModelStyling(modelName: string, css: string): Promise<void> {
await this.invoke("updateModelStyling", {
model: modelName,
css,
});
}
async findNoteIds(query: string): Promise<number[]> {
return this.invoke<number[]>("findNotes", { query });
}
async getNoteDetails(noteIds: number[]): Promise<AnkiNoteDetails[]> {
if (noteIds.length === 0) {
return [];
}
@ -117,6 +164,7 @@ export class AnkiConnectGateway implements AnkiGateway {
}
const cardIds = Array.isArray(entry.cards) ? entry.cards : [];
const fields = Object.fromEntries(Object.entries(entry.fields ?? {}).map(([fieldName, fieldValue]) => [fieldName, fieldValue?.value ?? ""]));
return [{
noteId: entry.noteId,
@ -125,10 +173,53 @@ export class AnkiConnectGateway implements AnkiGateway {
deckNames: Array.from(new Set(cardIds
.map((cardId) => cardDeckNamesByCardId.get(cardId))
.filter((deckName): deckName is string => typeof deckName === "string"))),
fields,
}];
});
}
async getModelDetails(modelName: string): Promise<NoteModelDetails> {
const fieldNames = await this.getModelFieldNames(modelName);
let isCloze = modelName.toLowerCase().includes("cloze");
try {
const templates = await this.getModelTemplates(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 getDeckStats(deckNames: string[]): Promise<DeckStat[]> {
if (deckNames.length === 0) {
return [];
}
const deckNamesAndIds = await this.invoke<Record<string, number>>("deckNamesAndIds", {});
const rawStats = await this.invoke<unknown>("getDeckStats", {
decks: deckNames,
});
return deckNames.map((deckName) => ({
deckName,
noteCount: extractDeckNoteCount(rawStats, deckNamesAndIds[deckName]),
}));
}
async getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]> {
return (await this.getNoteDetails(noteIds)).map((note) => ({
noteId: note.noteId,
modelName: note.modelName,
cardIds: note.cardIds,
deckNames: note.deckNames,
}));
}
async addNote(input: AddAnkiNoteInput): Promise<number> {
return this.invoke<number>("addNote", {
note: {

View file

@ -41,6 +41,7 @@ describe("DataJsonPluginConfigRepository", () => {
expect(settings.fileDeckTemplate).toBe("obsidian::filename");
expect(settings.fileDeckInsertLocation).toBe("body");
expect(settings.folderDeckMode).toBe("off");
expect(settings.qaGroupMarker).toBe("#anki-list");
expect(settings.semanticQaMarker).toBe("#anki-list-qa");
expect(settings.semanticQaNoteType).toBe("Semantic QA");
});

View file

@ -1,6 +1,6 @@
import type { PluginStateRepository } from "@/application/ports/PluginStateRepository";
import type { PluginDataStore } from "@/application/ports/PluginDataStore";
import { createEmptyPluginState, toNoteIdKey, type CardState, type FileState, type PendingWriteBackState, type PluginState } from "@/domain/manual-sync/entities/PluginState";
import { createEmptyPluginState, toNoteIdKey, type CardState, type FileState, type GroupBlockState, type PendingWriteBackState, type PluginState } from "@/domain/manual-sync/entities/PluginState";
import type { PluginDataSnapshot } from "./DataJsonPluginConfigRepository";
@ -18,9 +18,14 @@ interface LegacyPendingWriteBackState extends Partial<PendingWriteBackState> {
noteId?: number;
}
interface LegacyGroupBlockState extends Partial<GroupBlockState> {
groupId?: string;
}
interface LegacyPluginState {
files?: Record<string, LegacyFileState>;
cards?: Record<string, LegacyCardState>;
groupBlocks?: Record<string, LegacyGroupBlockState>;
pendingWriteBack?: LegacyPendingWriteBackState[];
}
@ -73,12 +78,27 @@ export function migratePluginState(pluginState?: PluginState | LegacyPluginState
deckRulesFingerprint: typeof rawFile.deckRulesFingerprint === "string" ? rawFile.deckRulesFingerprint : undefined,
lastIndexedAt: typeof rawFile.lastIndexedAt === "number" ? rawFile.lastIndexedAt : 0,
noteIds: collectMigratedFileNoteIds(rawFile, rawCards, cards),
groupIds: collectMigratedFileGroupIds(rawFile, pluginState.groupBlocks ?? {}),
};
}
const groupBlocks: Record<string, GroupBlockState> = {};
for (const [groupId, rawGroupBlock] of Object.entries(pluginState.groupBlocks ?? {})) {
const nextGroupBlock = migrateGroupBlockState(rawGroupBlock, groupId);
if (!nextGroupBlock) {
continue;
}
const existingGroupBlock = groupBlocks[groupId];
if (!existingGroupBlock || existingGroupBlock.lastSyncedAt <= nextGroupBlock.lastSyncedAt) {
groupBlocks[groupId] = nextGroupBlock;
}
}
return {
files,
cards,
groupBlocks,
pendingWriteBack: [],
};
}
@ -122,6 +142,65 @@ function sanitizeCardType(value: unknown): CardState["cardType"] {
return "basic";
}
function migrateGroupBlockState(rawGroupBlock: LegacyGroupBlockState, groupId: string): GroupBlockState | null {
const noteId = sanitizeNoteId(rawGroupBlock.noteId);
if (noteId === undefined || !groupId.trim()) {
return null;
}
return {
groupId,
noteId,
filePath: typeof rawGroupBlock.filePath === "string" ? rawGroupBlock.filePath : "",
headingText: typeof rawGroupBlock.headingText === "string" ? rawGroupBlock.headingText : "",
backlinkHeadingText: typeof rawGroupBlock.backlinkHeadingText === "string"
? rawGroupBlock.backlinkHeadingText
: (typeof rawGroupBlock.headingText === "string" ? rawGroupBlock.headingText : ""),
headingLevel: typeof rawGroupBlock.headingLevel === "number" ? rawGroupBlock.headingLevel : 1,
stem: typeof rawGroupBlock.stem === "string" ? rawGroupBlock.stem : "",
src: typeof rawGroupBlock.src === "string" ? rawGroupBlock.src : "",
blockStartOffset: typeof rawGroupBlock.blockStartOffset === "number" ? rawGroupBlock.blockStartOffset : 0,
blockEndOffset: typeof rawGroupBlock.blockEndOffset === "number" ? rawGroupBlock.blockEndOffset : 0,
blockStartLine: typeof rawGroupBlock.blockStartLine === "number" ? rawGroupBlock.blockStartLine : 1,
bodyStartLine: typeof rawGroupBlock.bodyStartLine === "number" ? rawGroupBlock.bodyStartLine : 1,
blockEndLine: typeof rawGroupBlock.blockEndLine === "number" ? rawGroupBlock.blockEndLine : 1,
contentEndLine: typeof rawGroupBlock.contentEndLine === "number" ? rawGroupBlock.contentEndLine : 1,
markerLine: typeof rawGroupBlock.markerLine === "number" ? rawGroupBlock.markerLine : undefined,
markerIndent: typeof rawGroupBlock.markerIndent === "string" ? rawGroupBlock.markerIndent : undefined,
rawBlockText: typeof rawGroupBlock.rawBlockText === "string" ? rawGroupBlock.rawBlockText : "",
rawBlockHash: typeof rawGroupBlock.rawBlockHash === "string" ? rawGroupBlock.rawBlockHash : "",
deck: typeof rawGroupBlock.deck === "string" ? rawGroupBlock.deck : "",
deckHint: typeof rawGroupBlock.deckHint === "string" ? rawGroupBlock.deckHint : undefined,
deckHintSource: rawGroupBlock.deckHintSource === "frontmatter" || rawGroupBlock.deckHintSource === "body" ? rawGroupBlock.deckHintSource : undefined,
deckWarnings: Array.isArray(rawGroupBlock.deckWarnings) ? [...rawGroupBlock.deckWarnings] : [],
items: Array.isArray(rawGroupBlock.items)
? rawGroupBlock.items.flatMap((item) => {
if (!item || typeof item !== "object") {
return [];
}
const nextItem = item as GroupBlockState["items"][number];
if (typeof nextItem.title !== "string" || typeof nextItem.answer !== "string" || typeof nextItem.ordinalInMarkdown !== "number") {
return [];
}
return [{
itemId: typeof nextItem.itemId === "string" ? nextItem.itemId : undefined,
title: nextItem.title,
answer: nextItem.answer,
slot: typeof nextItem.slot === "number" ? nextItem.slot : undefined,
ordinalInMarkdown: nextItem.ordinalInMarkdown,
}];
})
: [],
freeSlots: Array.isArray(rawGroupBlock.freeSlots)
? rawGroupBlock.freeSlots.filter((slot): slot is number => typeof slot === "number" && Number.isInteger(slot) && slot > 0)
: [],
lastSyncedAt: typeof rawGroupBlock.lastSyncedAt === "number" ? rawGroupBlock.lastSyncedAt : 0,
orphan: Boolean(rawGroupBlock.orphan),
};
}
function collectMigratedFileNoteIds(
rawFile: LegacyFileState,
rawCards: Record<string, LegacyCardState>,
@ -150,6 +229,17 @@ function collectMigratedFileNoteIds(
return Array.from(noteIds);
}
function collectMigratedFileGroupIds(
rawFile: LegacyFileState,
rawGroupBlocks: Record<string, LegacyGroupBlockState>,
): string[] {
if (!Array.isArray(rawFile.groupIds)) {
return [];
}
return rawFile.groupIds.filter((groupId): groupId is string => typeof groupId === "string" && Boolean(rawGroupBlocks[groupId]));
}
function sanitizeNoteId(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
}

View file

@ -559,6 +559,18 @@ describe("AnkiHeadingSyncSettingTab", () => {
expect(container.textNodes).toContain("Trigger heading example: 城市更新 #semantic-qa");
});
it("saves the QA Group marker independently from the semantic QA marker", async () => {
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
const container = tab.containerEl as unknown as FakeContainerElInstance;
tab.display();
await getText(findSetting(container, "QA Group marker")).triggerChange("#anki-list-12");
expect(plugin.settings.qaGroupMarker).toBe("#anki-list-12");
expect(plugin.settings.semanticQaMarker).toBe("#anki-list-qa");
});
it("removes the old folder textareas and hides the folder tree in all mode", async () => {
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);

View file

@ -1,6 +1,6 @@
import { PluginSettingTab, Setting } from "obsidian";
import { isValidSemanticQaMarker, type FileDeckInsertLocation, type FolderDeckMode, type ScopeMode } from "@/application/config/PluginSettings";
import { isValidHashtagMarker, isValidSemanticQaMarker, type FileDeckInsertLocation, type FolderDeckMode, type ScopeMode } from "@/application/config/PluginSettings";
import { createNoteFieldMappingKey, type NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
import type { FolderTreeNode } from "@/application/dto/FolderTreeNode";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
@ -97,6 +97,22 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
});
containerEl.createEl("h3", { text: "QA Group 12" });
new Setting(containerEl)
.setName("QA Group marker")
.setDesc("When a QA heading ends with this hashtag marker, the whole heading block syncs as one ObsiAnki QA Group 12 note.")
.addText((text) => {
text.setPlaceholder("#anki-list").setValue(settings.qaGroupMarker).onChange(async (value) => {
const nextValue = value.trim();
if (!nextValue || !isValidHashtagMarker(nextValue) || nextValue === settings.semanticQaMarker) {
return;
}
await this.plugin.updateSettings({ qaGroupMarker: nextValue });
});
});
containerEl.createEl("h3", { text: "Semantic QA" });
new Setting(containerEl)

View file

@ -1,7 +1,7 @@
import type { PluginSettings } from "@/application/config/PluginSettings";
import { DEFAULT_SETTINGS } from "@/application/config/PluginSettings";
import type { FolderTreeNode } from "@/application/dto/FolderTreeNode";
import type { AddAnkiNoteInput, AnkiGateway, AnkiNoteSummary, ChangeDeckInput, DeckStat, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway";
import type { AddAnkiNoteInput, AnkiGroupGateway, AnkiModelTemplate, AnkiNoteDetails, AnkiNoteSummary, ChangeDeckInput, CreateAnkiModelInput, DeckStat, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway";
import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
import type { PluginStateRepository } from "@/application/ports/PluginStateRepository";
import { MarkdownWriteConflictError } from "@/application/ports/VaultGateway";
@ -134,7 +134,7 @@ export class FakeManualSyncVaultGateway implements ManualSyncVaultGateway {
}
}
export class FakeManualSyncAnkiGateway implements AnkiGateway {
export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
public ensuredDecks: string[][] = [];
public addedNotes: AddAnkiNoteInput[] = [];
public deletedNotes: number[][] = [];
@ -143,12 +143,21 @@ export class FakeManualSyncAnkiGateway implements AnkiGateway {
public deletedDecks: string[][] = [];
public storedMedia: MediaAsset[] = [];
public noteSummariesById = new Map<number, AnkiNoteSummary>();
public noteDetailsById = new Map<number, AnkiNoteDetails>();
public deckStatsByName = new Map<string, DeckStat>();
public listedDeckNames: string[] | null = null;
public createdModels: CreateAnkiModelInput[] = [];
public addedModelFields: Array<{ modelName: string; fieldName: string }> = [];
public addedModelTemplates: Array<{ modelName: string; template: AnkiModelTemplate }> = [];
public updatedModelTemplates: Array<{ modelName: string; template: AnkiModelTemplate }> = [];
public updatedModelStyling: Array<{ modelName: string; css: string }> = [];
public foundNoteIds = new Map<string, number[]>();
public modelDetailsByName: Record<string, NoteModelDetails> = {
Basic: { fieldNames: ["Front", "Back"], isCloze: false },
Cloze: { fieldNames: ["Text", "Extra"], isCloze: true },
};
public modelTemplatesByName: Record<string, Record<string, AnkiModelTemplate>> = {};
public modelStylingByName: Record<string, string> = {};
private nextNoteId = 9000;
@ -172,6 +181,74 @@ export class FakeManualSyncAnkiGateway implements AnkiGateway {
return this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"], isCloze: false };
}
async getModelFieldNames(modelName: string): Promise<string[]> {
return this.modelDetailsByName[modelName]?.fieldNames ?? [];
}
async getModelTemplates(modelName: string): Promise<Record<string, AnkiModelTemplate>> {
return this.modelTemplatesByName[modelName] ?? {};
}
async getModelStyling(modelName: string): Promise<string> {
return this.modelStylingByName[modelName] ?? "";
}
async createModel(input: CreateAnkiModelInput): Promise<void> {
this.createdModels.push(input);
this.modelDetailsByName[input.modelName] = {
fieldNames: [...input.fieldNames],
isCloze: Boolean(input.isCloze),
};
this.modelTemplatesByName[input.modelName] = Object.fromEntries(input.templates.map((template) => [template.name, { ...template }]));
this.modelStylingByName[input.modelName] = input.css;
}
async addModelField(modelName: string, fieldName: string): Promise<void> {
this.addedModelFields.push({ modelName, fieldName });
const existing = this.modelDetailsByName[modelName] ?? { fieldNames: [], isCloze: false };
if (!existing.fieldNames.includes(fieldName)) {
existing.fieldNames.push(fieldName);
}
this.modelDetailsByName[modelName] = existing;
}
async addModelTemplate(modelName: string, template: AnkiModelTemplate): Promise<void> {
this.addedModelTemplates.push({ modelName, template });
this.modelTemplatesByName[modelName] = {
...(this.modelTemplatesByName[modelName] ?? {}),
[template.name]: { ...template },
};
}
async updateModelTemplate(modelName: string, template: AnkiModelTemplate): Promise<void> {
this.updatedModelTemplates.push({ modelName, template });
this.modelTemplatesByName[modelName] = {
...(this.modelTemplatesByName[modelName] ?? {}),
[template.name]: { ...template },
};
}
async updateModelStyling(modelName: string, css: string): Promise<void> {
this.updatedModelStyling.push({ modelName, css });
this.modelStylingByName[modelName] = css;
}
async findNoteIds(query: string): Promise<number[]> {
return [...(this.foundNoteIds.get(query) ?? [])];
}
async getNoteDetails(noteIds: number[]): Promise<AnkiNoteDetails[]> {
return noteIds.flatMap((noteId) => {
const detail = this.noteDetailsById.get(noteId);
if (detail) {
return [{ ...detail, cardIds: [...detail.cardIds], deckNames: detail.deckNames ? [...detail.deckNames] : undefined, fields: { ...detail.fields } }];
}
const summary = this.noteSummariesById.get(noteId);
return summary ? [{ ...summary, deckNames: summary.deckNames ? [...summary.deckNames] : undefined, fields: {} }] : [];
});
}
async getDeckStats(deckNames: string[]): Promise<DeckStat[]> {
return deckNames.map((deckName) => this.deckStatsByName.get(deckName) ?? { deckName });
}