diff --git a/docs/cleanup-reset-decisions.md b/docs/cleanup-reset-decisions.md new file mode 100644 index 0000000..09244dd --- /dev/null +++ b/docs/cleanup-reset-decisions.md @@ -0,0 +1,123 @@ +# Cleanup / Reset Decisions + +## 1. 命令边界 + +两个能力都作为显式管理命令实现,不接入 `ManualSyncService` 的普通同步主链路: + +1. `清空当前文件已同步卡片` +2. `清理空牌组` + +这样可以直接满足“普通 sync / rebuild / orphan 语义不变”的硬约束。 + +## 2. AnkiGateway 扩展 + +新增接口: + +1. `deleteNotes(noteIds: number[]): Promise` +2. `listDeckNames(): Promise` +3. `getDeckStats(deckNames: string[]): Promise>` +4. `deleteDecks(deckNames: string[]): Promise` + +实现策略: + +1. 上层只看“列 deck / 判空 / 删除 deck”语义 +2. AnkiConnect action 的兼容细节全部封装在 `AnkiConnectGateway` +3. `getDeckStats` 只要求足够判断空 deck;如果底层返回的是 card 统计而不是 note 统计,网关内部统一映射为 `noteCount` 风格字段供上层使用 + +## 3. Current-file reset 编排 + +新增独立 use case:`ClearCurrentFileSyncedCardsUseCase`。 + +固定执行顺序: + +1. 读取 `pluginState` +2. 找出当前文件对应 card records +3. 收集有 `noteId` 的 noteIds +4. 先调用 `deleteNotes` +5. 再尝试删除该文件内对应 marker +6. 最后保存清理后的本地 state + +保存规则: + +1. marker 删除成功时:删除对应 `pluginState.cards`、`pluginState.files[filePath].cardIds`、相关 `pendingWriteBack` +2. marker 删除失败时:不回滚已删除的 Anki notes;保留失败文件对应 card record,但清空其 `noteId`,并清理相关 `pendingWriteBack` + +另外,无论 marker 删除成功还是失败,都删除该文件的 `FileState` 记录,强制下一次普通同步重新读取正文,而不是复用“当前文件没有卡片变更”的旧索引结论。 + +这样做的原因: + +1. 文件内容仍带旧 marker 时,完全删除本地 state 会让后续索引误把旧 marker 的 `noteId` 当成仍可更新的 note +2. 保留 cardId 但清空 `noteId`,能让下一次普通同步把它当作“正文仍在、需要重新创建”的卡片,而不是对已删除 note 做 update + +这是一项为满足“失败时保持内部一致”而做的最小兼容调整,不扩展成长期 tombstone 状态。 + +## 4. Marker removal 实现 + +不把删除逻辑塞进现有 `MarkdownWriteBackService`。 + +新增两层: + +1. 纯内容变换:marker removal service +2. 文件改写编排:Markdown marker removal application service + +删除原则: + +1. 仅删除匹配的 AHS marker 行 +2. 不改正文 +3. 不调整其他 card block 顺序 + +## 5. Empty deck cleanup 编排 + +新增独立 use case:`CleanupEmptyDecksUseCase`。 + +分两步: + +1. `listCandidates()`:列 deck、取统计、筛空 deck +2. `execute(selectedDeckNames)`:删除用户确认的 deck,并把“已不存在/已非空”归入 skipped + +UI 选择发生在 presentation 层 modal 中,不下沉到 application 层。 + +## 6. Presentation 交互 + +新增两个 plugin 入口方法与两个命令注册项。 + +空 deck 清理使用一个小型多选 modal: + +1. 先展示候选空 deck +2. 用户勾选 +3. 确认删除后才调用 use case + +如果没有候选 deck,则直接提示,不进入确认流程。 + +## 7. 结果类型与 notice + +新增独立结果类型: + +1. `ClearCurrentFileSyncedCardsResult` +2. `CleanupEmptyDecksResult` + +不扩展 `ManualSyncResult`。 + +`NoticeService` 新增两组摘要: + +1. reset:deleted notes、removed markers、deleted local records、failed/conflict files +2. empty decks:candidate count、selected count、deleted count、skipped count + +## 8. 索引兼容修正 + +当 marker 中有 cardId,且本地 `CardState` 已存在但 `noteId` 被清空时,索引恢复身份时应优先信任本地 state 的“无 noteId”结果,而不是回退到 marker 里的旧 `noteId`。 + +该修正只服务于 reset 命令的失败恢复,不改变普通同步的删除语义。 + +## 9. 测试范围 + +至少覆盖: + +1. current-file reset 的全成功路径 +2. current-file reset 的无 `noteId` 路径 +3. current-file reset 的 marker 写回失败路径 +4. reset 后普通同步可重建 +5. empty deck 候选识别 +6. empty deck 手选删除 +7. 已非空/已不存在 deck 的 skip 汇总 +8. 普通 sync / rebuild / orphan 行为不变 \ No newline at end of file diff --git a/docs/cleanup-reset-gap-report.md b/docs/cleanup-reset-gap-report.md new file mode 100644 index 0000000..1eca747 --- /dev/null +++ b/docs/cleanup-reset-gap-report.md @@ -0,0 +1,121 @@ +# Cleanup / Reset Gap Report + +## 审查范围 + +本报告只覆盖本任务要求的两个显式管理命令: + +1. 清理空牌组 +2. 清空当前文件已同步卡片 + +审查对象: + +1. `AnkiGateway` / `AnkiConnectGateway` +2. marker 写回与 Markdown 文件改写链路 +3. `pluginState` 读取、删除与持久化 +4. 当前文件命令入口与命令注册 +5. `NoticeService` +6. 当前 UI 是否已有确认/多选路径 +7. 回归风险:是否会误泄漏到普通 sync/rebuild 主链路 + +## 当前缺口 + +### 1. 网关层没有任何删除型能力 + +当前 `AnkiGateway` 只覆盖: + +1. note add/update +2. deck ensure/change +3. model 查询 +4. media 上传 + +缺口: + +1. 不能删除 note +2. 不能列出 deck +3. 不能查询 deck 是否为空 +4. 不能删除 deck + +这意味着两个管理命令都无法从基础设施层闭环。 + +### 2. Markdown 写回只有“插入/替换 marker”,没有“删除 marker”路径 + +当前 `MarkdownWriteBackService` 只为同步主链路服务: + +1. 批量插入 marker +2. 替换 card-only / stale marker +3. 失败时生成 pending write-back + +缺口: + +1. 没有独立的 marker removal service +2. 删除型命令如果复用现有写回逻辑,会把“同步写 marker”和“管理命令删 marker”混成同一套 pending 语义 + +### 3. 没有“当前文件重置已同步卡片”的独立 use case + +当前只有: + +1. `ManualSyncCurrentFileUseCase` +2. `ManualSyncVaultUseCase` +3. `RebuildCardIndexUseCase` + +缺口: + +1. 没有只按当前文件删 note / 删 marker / 删本地 state 的独立编排 +2. 现有 `ManualSyncService` 主链路不应承担删除行为 + +### 4. Presentation 层没有任何用户确认或多选交互 + +当前插件入口只注册三个命令: + +1. 同步当前文件 +2. 同步全库 +3. 重建卡片索引 + +缺口: + +1. 没有“当前文件 reset”命令入口 +2. 没有“空牌组清理”命令入口 +3. 没有 deck 候选列表确认/多选 modal + +### 5. Notice 层只会展示普通同步和 rebuild 摘要 + +当前 `NoticeService` 不支持: + +1. 当前文件清空结果摘要 +2. 空 deck 清理结果摘要 +3. 空结果/手动取消/跳过删除的管理命令提示 + +### 6. 当前状态模型对“删 note 后 marker 删除失败”的恢复不够安全 + +现有索引链路会在 marker 仍保留 `noteId` 时继续把该 noteId 当作候选同步身份。 + +风险: + +1. 如果 reset 命令已经删掉 Anki note +2. 但 marker 写回失败,文件里仍保留旧 `noteId` +3. 又把本地 card state 全删掉 + +那么下次普通同步可能把这个已删除 note 当成可 update 对象,产生错误。 + +这部分需要一个最小兼容修正,确保失败文件不会伪装成“还在同步中”。 + +## 范围控制结论 + +本任务必须新增两条显式管理命令,但不能改动以下默认语义: + +1. `sync current file` 不自动删 note +2. `sync vault` 不自动删 note +3. `rebuild index` 不做 note 删除 +4. orphan 仍只标记,不自动删除 +5. 空 deck 清理只能显式触发,且必须先列候选再手选删除 + +## 需要补齐的实现块 + +1. `AnkiGateway` 删除/查询/删除 deck 接口 +2. `AnkiConnectGateway` 对应 action 封装 +3. 独立 marker removal service +4. `ClearCurrentFileSyncedCardsUseCase` +5. `CleanupEmptyDecksUseCase` +6. Presentation modal / 选择流程 +7. 新结果类型与 `NoticeService` 摘要 +8. 回归测试与最终验证 \ No newline at end of file diff --git a/src/application/ports/AnkiGateway.ts b/src/application/ports/AnkiGateway.ts index 5a4a93f..45eb5cb 100644 --- a/src/application/ports/AnkiGateway.ts +++ b/src/application/ports/AnkiGateway.ts @@ -25,17 +25,26 @@ export interface ChangeDeckInput { cardIds: number[]; } +export interface DeckStat { + deckName: string; + noteCount: number; +} + export interface AnkiGateway { ensureDeckExists(deckName: string): Promise; ensureDecks(deckNames: string[]): Promise; listNoteModels(): Promise; + listDeckNames(): Promise; getModelDetails(modelName: string): Promise; + getDeckStats(deckNames: string[]): Promise; getNoteSummaries(noteIds: number[]): Promise; addNote(input: AddAnkiNoteInput): Promise; addNotes(inputs: AddAnkiNoteInput[]): Promise; + deleteNotes(noteIds: number[]): Promise; updateNote(input: UpdateAnkiNoteInput): Promise; updateNotes(inputs: UpdateAnkiNoteInput[]): Promise; changeDecks(inputs: ChangeDeckInput[]): Promise; + deleteDecks(deckNames: string[]): Promise; storeMedia(asset: MediaAsset): Promise; storeMediaFiles(assets: MediaAsset[]): Promise; } \ No newline at end of file diff --git a/src/application/services/ManualSyncService.test.ts b/src/application/services/ManualSyncService.test.ts index b02f3e5..73e5c2b 100644 --- a/src/application/services/ManualSyncService.test.ts +++ b/src/application/services/ManualSyncService.test.ts @@ -26,6 +26,8 @@ describe("ManualSyncService", () => { expect(result.warnings).toEqual([]); expect(ankiGateway.addedNotes[0]?.deckName).toBe("notes"); expect(ankiGateway.ensuredDecks).toEqual([["notes"]]); + expect(ankiGateway.deletedNotes).toEqual([]); + expect(ankiGateway.deletedDecks).toEqual([]); expect(vaultGateway.getFileContent("notes/example.md")).toContain("", + "", + "#### Prompt 2", + "Answer 2", + "", + ].join("\n"); + const vaultGateway = new FakeManualSyncVaultGateway({ + [filePath]: content, + }); + const stateRepository = new InMemoryPluginStateRepository({ + files: { + [filePath]: { + filePath, + fileHash: hashString(content), + fileStamp: `1:${content.length}`, + deckRulesFingerprint: createDeckRulesFingerprint(settings), + lastIndexedAt: 1, + cardIds: ["ahs_1", "ahs_2"], + }, + }, + cards: { + ahs_1: createStoredSyncedCard(settings, { + cardId: "ahs_1", + noteId: 41, + filePath, + bodyMarkdown: "Answer", + rawBlockText: ["#### Prompt", "Answer"].join("\n"), + contentEndLine: 2, + blockEndLine: 4, + markerLine: 3, + }), + ahs_2: createStoredSyncedCard(settings, { + cardId: "ahs_2", + noteId: undefined, + filePath, + heading: "Prompt 2", + bodyMarkdown: "Answer 2", + rawBlockText: ["#### Prompt 2", "Answer 2"].join("\n"), + blockStartLine: 5, + bodyStartLine: 6, + contentEndLine: 6, + blockEndLine: 7, + markerLine: 7, + }), + }, + pendingWriteBack: [ + { + filePath, + cardId: "ahs_1", + noteId: 41, + expectedFileHash: hashString(content), + targetMarker: "", + rawBlockHash: hashString(["#### Prompt", "Answer"].join("\n")), + }, + ], + }); + const ankiGateway = new FakeManualSyncAnkiGateway(); + const useCase = new ClearCurrentFileSyncedCardsUseCase(stateRepository, ankiGateway, vaultGateway); + + const result = await useCase.execute(filePath); + + expect(result).toMatchObject({ + trackedCards: 2, + deletedNotes: 1, + removedMarkers: 2, + deletedLocalRecords: 2, + conflictFiles: [], + failureFiles: [], + }); + expect(ankiGateway.deletedNotes).toEqual([[41]]); + expect(vaultGateway.getFileContent(filePath)).toBe([ + "#### Prompt", + "Answer", + "", + "#### Prompt 2", + "Answer 2", + ].join("\n")); + expect(stateRepository.savedState?.files[filePath]).toBeUndefined(); + expect(stateRepository.savedState?.cards.ahs_1).toBeUndefined(); + expect(stateRepository.savedState?.cards.ahs_2).toBeUndefined(); + expect(stateRepository.savedState?.pendingWriteBack).toEqual([]); + + const manualSyncService = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 2000); + const syncResult = await manualSyncService.syncFile(filePath, settings); + + expect(syncResult.created).toBe(2); + expect(ankiGateway.addedNotes).toHaveLength(2); + expect(vaultGateway.getFileContent(filePath)).toContain("note=9001"); + expect(vaultGateway.getFileContent(filePath)).toContain("note=9002"); + }); + + it("cleans local records and markers without deleting Anki notes when noteId is missing", async () => { + const settings = createModule3Settings(); + const filePath = "notes/local-only.md"; + const content = [ + "#### Prompt", + "Answer", + "", + ].join("\n"); + const vaultGateway = new FakeManualSyncVaultGateway({ + [filePath]: content, + }); + const stateRepository = new InMemoryPluginStateRepository({ + files: { + [filePath]: { + filePath, + fileHash: hashString(content), + fileStamp: `1:${content.length}`, + deckRulesFingerprint: createDeckRulesFingerprint(settings), + lastIndexedAt: 1, + cardIds: ["ahs_local"], + }, + }, + cards: { + ahs_local: createStoredSyncedCard(settings, { + cardId: "ahs_local", + noteId: undefined, + filePath, + markerLine: 3, + }), + }, + pendingWriteBack: [], + }); + const ankiGateway = new FakeManualSyncAnkiGateway(); + const useCase = new ClearCurrentFileSyncedCardsUseCase(stateRepository, ankiGateway, vaultGateway); + + const result = await useCase.execute(filePath); + + expect(result.deletedNotes).toBe(0); + expect(result.removedMarkers).toBe(1); + expect(result.deletedLocalRecords).toBe(1); + expect(ankiGateway.deletedNotes).toEqual([]); + expect(vaultGateway.getFileContent(filePath)).toBe(["#### Prompt", "Answer"].join("\n")); + }); + + it("returns an empty result when the file has no tracked synced cards", async () => { + const stateRepository = new InMemoryPluginStateRepository(); + const ankiGateway = new FakeManualSyncAnkiGateway(); + const vaultGateway = new FakeManualSyncVaultGateway(); + const useCase = new ClearCurrentFileSyncedCardsUseCase(stateRepository, ankiGateway, vaultGateway); + + const result = await useCase.execute("notes/missing.md"); + + expect(result).toEqual({ + trackedCards: 0, + deletedNotes: 0, + removedMarkers: 0, + deletedLocalRecords: 0, + conflictFiles: [], + failureFiles: [], + }); + expect(ankiGateway.deletedNotes).toEqual([]); + }); + + it("reports marker write conflicts, clears file state, and keeps sanitized local cards so a later sync can recreate them", async () => { + const settings = createModule3Settings(); + const filePath = "notes/conflict.md"; + const content = [ + "#### Prompt", + "Answer", + "", + ].join("\n"); + const vaultGateway = new FakeManualSyncVaultGateway({ + [filePath]: content, + }); + vaultGateway.conflictPaths.add(filePath); + const stateRepository = new InMemoryPluginStateRepository({ + files: { + [filePath]: { + filePath, + fileHash: hashString(content), + fileStamp: `1:${content.length}`, + deckRulesFingerprint: createDeckRulesFingerprint(settings), + lastIndexedAt: 1, + cardIds: ["ahs_conflict"], + }, + }, + cards: { + ahs_conflict: createStoredSyncedCard(settings, { + cardId: "ahs_conflict", + noteId: 42, + filePath, + markerLine: 3, + }), + }, + pendingWriteBack: [ + { + filePath, + cardId: "ahs_conflict", + noteId: 42, + expectedFileHash: hashString(content), + targetMarker: "", + rawBlockHash: hashString(["#### Prompt", "Answer"].join("\n")), + }, + ], + }); + const ankiGateway = new FakeManualSyncAnkiGateway(); + const useCase = new ClearCurrentFileSyncedCardsUseCase(stateRepository, ankiGateway, vaultGateway); + + const result = await useCase.execute(filePath); + + expect(result.deletedNotes).toBe(1); + expect(result.removedMarkers).toBe(0); + expect(result.deletedLocalRecords).toBe(0); + expect(result.conflictFiles).toEqual([filePath]); + expect(ankiGateway.deletedNotes).toEqual([[42]]); + expect(stateRepository.savedState?.files[filePath]).toBeUndefined(); + expect(stateRepository.savedState?.cards.ahs_conflict?.noteId).toBeUndefined(); + expect(stateRepository.savedState?.pendingWriteBack).toEqual([]); + expect(vaultGateway.getFileContent(filePath)).toContain("note=42"); + + vaultGateway.conflictPaths.delete(filePath); + const manualSyncService = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 3000); + const syncResult = await manualSyncService.syncFile(filePath, settings); + + expect(syncResult.created).toBe(1); + expect(ankiGateway.addedNotes).toHaveLength(1); + expect(vaultGateway.getFileContent(filePath)).toContain("note=9001"); + expect(vaultGateway.getFileContent(filePath)).not.toContain("note=42"); + }); +}); + +function createStoredSyncedCard(settings: PluginSettings, overrides: Partial = {}): CardState { + const heading = overrides.heading ?? "Prompt"; + const bodyMarkdown = overrides.bodyMarkdown ?? "Answer"; + const rawBlockText = overrides.rawBlockText ?? [`#### ${heading}`, bodyMarkdown].join("\n"); + const rawBlockHash = overrides.rawBlockHash ?? hashString(rawBlockText); + const indexedCard = { + cardId: overrides.cardId ?? "ahs_known", + noteId: overrides.noteId, + markerNoteId: overrides.noteId, + filePath: overrides.filePath ?? "notes/example.md", + cardType: overrides.cardType ?? "basic", + heading, + headingLevel: overrides.headingLevel ?? 4, + bodyMarkdown, + blockStartOffset: overrides.blockStartOffset ?? 0, + blockEndOffset: overrides.blockEndOffset ?? rawBlockText.length, + blockStartLine: overrides.blockStartLine ?? 1, + bodyStartLine: overrides.bodyStartLine ?? 2, + blockEndLine: overrides.blockEndLine ?? 3, + contentEndLine: overrides.contentEndLine ?? 2, + markerLine: overrides.markerLine, + rawBlockText, + rawBlockHash, + deckHint: overrides.deckHint, + deckHintSource: overrides.deckHintSource, + deckWarnings: overrides.deckWarnings ?? [], + tagsHint: overrides.tagsHint ?? [], + markerState: overrides.noteId ? "card-and-note" as const : "card-only" as const, + }; + const renderPlan = new RenderConfigService().resolve(indexedCard, settings); + + return { + cardId: indexedCard.cardId, + noteId: indexedCard.noteId, + filePath: indexedCard.filePath, + heading: indexedCard.heading, + headingLevel: indexedCard.headingLevel, + bodyMarkdown: indexedCard.bodyMarkdown, + cardType: indexedCard.cardType, + blockStartOffset: indexedCard.blockStartOffset, + blockEndOffset: indexedCard.blockEndOffset, + blockStartLine: indexedCard.blockStartLine, + bodyStartLine: indexedCard.bodyStartLine, + blockEndLine: indexedCard.blockEndLine, + contentEndLine: indexedCard.contentEndLine, + markerLine: indexedCard.markerLine, + rawBlockText: indexedCard.rawBlockText, + rawBlockHash: indexedCard.rawBlockHash, + renderConfigHash: overrides.renderConfigHash ?? renderPlan.renderConfigHash, + deck: overrides.deck ?? renderPlan.deck, + deckHint: indexedCard.deckHint, + deckHintSource: indexedCard.deckHintSource, + deckWarnings: [...indexedCard.deckWarnings], + tagsHint: indexedCard.tagsHint, + lastSyncedAt: overrides.lastSyncedAt ?? 1, + orphan: overrides.orphan ?? false, + }; +} \ No newline at end of file diff --git a/src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.ts b/src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.ts new file mode 100644 index 0000000..98e658d --- /dev/null +++ b/src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.ts @@ -0,0 +1,105 @@ +import type { AnkiGateway } from "@/application/ports/AnkiGateway"; +import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway"; +import type { PluginStateRepository } from "@/application/ports/PluginStateRepository"; +import { MarkdownMarkerRemovalService } from "@/application/services/MarkdownMarkerRemovalService"; +import type { CardState, PluginState } from "@/domain/manual-sync/entities/PluginState"; + +import type { ClearCurrentFileSyncedCardsResult } from "./cleanupResetTypes"; + +export class ClearCurrentFileSyncedCardsUseCase { + constructor( + private readonly pluginStateRepository: PluginStateRepository, + private readonly ankiGateway: AnkiGateway, + vaultGateway: ManualSyncVaultGateway, + private readonly markdownMarkerRemovalService = new MarkdownMarkerRemovalService(vaultGateway), + ) {} + + async hasTrackedCards(filePath: string): Promise { + const state = await this.pluginStateRepository.load(); + return collectTrackedCards(state, filePath).length > 0; + } + + async execute(filePath: string): Promise { + const state = await this.pluginStateRepository.load(); + const trackedCards = collectTrackedCards(state, filePath); + + if (trackedCards.length === 0) { + return { + trackedCards: 0, + deletedNotes: 0, + removedMarkers: 0, + deletedLocalRecords: 0, + conflictFiles: [], + failureFiles: [], + }; + } + + const noteIds = Array.from(new Set(trackedCards.flatMap((card) => typeof card.noteId === "number" ? [card.noteId] : []))); + if (noteIds.length > 0) { + await this.ankiGateway.deleteNotes(noteIds); + } + + const markerRemovalResult = await this.markdownMarkerRemovalService.remove( + filePath, + trackedCards.map((card) => ({ cardId: card.cardId })), + ); + + const nextState = buildNextState(state, filePath, trackedCards, markerRemovalResult.conflictFiles.length > 0 || markerRemovalResult.failureFiles.length > 0); + await this.pluginStateRepository.save(nextState); + + return { + trackedCards: trackedCards.length, + deletedNotes: noteIds.length, + removedMarkers: markerRemovalResult.removedMarkers, + deletedLocalRecords: markerRemovalResult.conflictFiles.length === 0 && markerRemovalResult.failureFiles.length === 0 ? trackedCards.length : 0, + conflictFiles: markerRemovalResult.conflictFiles, + failureFiles: markerRemovalResult.failureFiles, + }; + } +} + +function collectTrackedCards(state: PluginState, filePath: string): CardState[] { + const trackedCardIds = new Set(); + + for (const cardId of state.files[filePath]?.cardIds ?? []) { + if (state.cards[cardId]) { + trackedCardIds.add(cardId); + } + } + + for (const card of Object.values(state.cards)) { + if (card.filePath === filePath) { + trackedCardIds.add(card.cardId); + } + } + + return Array.from(trackedCardIds) + .map((cardId) => state.cards[cardId]) + .filter((card): card is CardState => Boolean(card)); +} + +function buildNextState(state: PluginState, filePath: string, trackedCards: CardState[], keepSanitizedCards: boolean): PluginState { + const trackedCardIds = new Set(trackedCards.map((card) => card.cardId)); + const nextCards = { ...state.cards }; + + for (const trackedCard of trackedCards) { + if (!keepSanitizedCards) { + delete nextCards[trackedCard.cardId]; + continue; + } + + nextCards[trackedCard.cardId] = { + ...trackedCard, + noteId: undefined, + }; + } + + const nextFiles = { ...state.files }; + delete nextFiles[filePath]; + + return { + files: nextFiles, + cards: nextCards, + pendingWriteBack: state.pendingWriteBack.filter((pending) => pending.filePath !== filePath && !trackedCardIds.has(pending.cardId)), + }; +} \ No newline at end of file diff --git a/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts b/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts index 255458c..b5d4f5c 100644 --- a/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts +++ b/src/application/use-cases/ExecuteSyncPlanUseCase.test.ts @@ -117,10 +117,18 @@ class FakeAnkiGateway implements AnkiGateway { return Object.keys(this.modelDetailsByName); } + async listDeckNames(): Promise { + return []; + } + async getModelDetails(modelName: string) { return this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"], isCloze: false }; } + async getDeckStats(): Promise> { + return []; + } + async getNoteSummaries(noteIds: number[]): Promise { return noteIds.flatMap((noteId) => { const summary = this.noteSummariesById.get(noteId); @@ -138,6 +146,8 @@ class FakeAnkiGateway implements AnkiGateway { return Promise.all(inputs.map((input) => this.addNote(input))); } + async deleteNotes(): Promise {} + async updateNote(input: { noteId: number; deckName: string; fields: Record }): Promise { this.updatedNotes.push(input); } @@ -150,6 +160,8 @@ class FakeAnkiGateway implements AnkiGateway { async changeDecks(): Promise {} + async deleteDecks(): Promise {} + async storeMedia(asset: { fileName: string }): Promise { this.storedMedia.push(asset.fileName); } diff --git a/src/application/use-cases/SyncCurrentFileUseCase.test.ts b/src/application/use-cases/SyncCurrentFileUseCase.test.ts index 589a18b..1d8b770 100644 --- a/src/application/use-cases/SyncCurrentFileUseCase.test.ts +++ b/src/application/use-cases/SyncCurrentFileUseCase.test.ts @@ -100,6 +100,10 @@ class FakeAnkiGateway implements AnkiGateway { return ["Basic", "Cloze"]; } + async listDeckNames(): Promise { + return []; + } + async getModelDetails(modelName: string) { return modelName === "Cloze" ? { fieldNames: ["Text", "Extra"], isCloze: true } @@ -110,6 +114,10 @@ class FakeAnkiGateway implements AnkiGateway { return []; } + async getDeckStats(): Promise> { + return []; + } + async addNote(input: { deckName: string; modelName: string; fields: Record }): Promise { this.addCalls.push(input); return 500 + this.addCalls.length; @@ -119,6 +127,8 @@ class FakeAnkiGateway implements AnkiGateway { return Promise.all(inputs.map((input) => this.addNote(input))); } + async deleteNotes(): Promise {} + async updateNote(input: { noteId: number; deckName: string; fields: Record }): Promise { this.updateCalls.push(input); } @@ -131,6 +141,8 @@ class FakeAnkiGateway implements AnkiGateway { async changeDecks(): Promise {} + async deleteDecks(): Promise {} + async storeMedia(asset: { fileName: string }): Promise { this.storedMedia.push(asset.fileName); } diff --git a/src/application/use-cases/SyncVaultUseCase.test.ts b/src/application/use-cases/SyncVaultUseCase.test.ts index fb2bbed..0e27f30 100644 --- a/src/application/use-cases/SyncVaultUseCase.test.ts +++ b/src/application/use-cases/SyncVaultUseCase.test.ts @@ -92,6 +92,10 @@ class FakeAnkiGateway implements AnkiGateway { return ["Basic", "Cloze"]; } + async listDeckNames(): Promise { + return []; + } + async getModelDetails(modelName: string) { return modelName === "Cloze" ? { fieldNames: ["Text", "Extra"], isCloze: true } @@ -102,6 +106,10 @@ class FakeAnkiGateway implements AnkiGateway { return []; } + async getDeckStats(): Promise> { + return []; + } + async addNote(input: { deckName: string; modelName: string; fields: Record }): Promise { this.addCalls.push(input); return 200 + this.addCalls.length; @@ -111,6 +119,8 @@ class FakeAnkiGateway implements AnkiGateway { return Promise.all(inputs.map((input) => this.addNote(input))); } + async deleteNotes(): Promise {} + async updateNote(input: { noteId: number; deckName: string; fields: Record }): Promise { this.updateCalls.push(input); } @@ -123,6 +133,8 @@ class FakeAnkiGateway implements AnkiGateway { async changeDecks(): Promise {} + async deleteDecks(): Promise {} + async storeMedia(): Promise {} async storeMediaFiles(): Promise {} diff --git a/src/application/use-cases/cleanupResetTypes.ts b/src/application/use-cases/cleanupResetTypes.ts new file mode 100644 index 0000000..27a1123 --- /dev/null +++ b/src/application/use-cases/cleanupResetTypes.ts @@ -0,0 +1,16 @@ +export interface ClearCurrentFileSyncedCardsResult { + trackedCards: number; + deletedNotes: number; + removedMarkers: number; + deletedLocalRecords: number; + conflictFiles: string[]; + failureFiles: Array<{ filePath: string; message: string }>; +} + +export interface CleanupEmptyDecksResult { + candidateCount: number; + selectedCount: number; + deletedCount: number; + deletedDeckNames: string[]; + skippedDeckNames: string[]; +} \ No newline at end of file diff --git a/src/domain/manual-sync/services/CardIndexingService.test.ts b/src/domain/manual-sync/services/CardIndexingService.test.ts index bef436f..d282d5a 100644 --- a/src/domain/manual-sync/services/CardIndexingService.test.ts +++ b/src/domain/manual-sync/services/CardIndexingService.test.ts @@ -131,6 +131,31 @@ describe("CardIndexingService", () => { }); }); + it("prefers a known card with cleared noteId over a stale marker noteId", () => { + const service = new CardIndexingService(); + const indexedFile = service.index( + { + path: "notes/example.md", + basename: "example", + content: ["#### Prompt", "Answer", ""].join("\n"), + }, + { + qaHeadingLevel: 4, + clozeHeadingLevel: 5, + fileStamp: "1:1", + knownCards: [createKnownCardState({ cardId: "ahs_known", noteId: undefined })], + pendingWriteBack: [], + }, + ); + + expect(indexedFile.cards[0]).toMatchObject({ + cardId: "ahs_known", + noteId: undefined, + markerNoteId: 42, + markerState: "card-and-note", + }); + }); + it("rejects misplaced or multiple markers inside a single heading block", () => { const service = new CardIndexingService(); @@ -256,7 +281,7 @@ function createKnownCardState(overrides: Partial = {}): CardState { return { cardId: overrides.cardId ?? "ahs_known", - noteId: overrides.noteId ?? 42, + noteId: Object.prototype.hasOwnProperty.call(overrides, "noteId") ? overrides.noteId : 42, filePath: overrides.filePath ?? "notes/example.md", heading: overrides.heading ?? "Prompt", headingLevel: overrides.headingLevel ?? 4, diff --git a/src/domain/manual-sync/services/CardIndexingService.ts b/src/domain/manual-sync/services/CardIndexingService.ts index 962b9d9..5bfb05f 100644 --- a/src/domain/manual-sync/services/CardIndexingService.ts +++ b/src/domain/manual-sync/services/CardIndexingService.ts @@ -131,9 +131,23 @@ export class CardIndexingService { const pending = pendingByCardId.get(markerCardId); const known = knownCardsById.get(markerCardId); + if (pending && pending.noteId !== undefined) { + return { + cardId: markerCardId, + noteId: pending.noteId, + }; + } + + if (known) { + return { + cardId: markerCardId, + noteId: known.noteId, + }; + } + return { cardId: markerCardId, - noteId: pending?.noteId ?? known?.noteId ?? markerNoteId, + noteId: markerNoteId, }; } diff --git a/src/domain/manual-sync/services/CardMarkerRemovalService.ts b/src/domain/manual-sync/services/CardMarkerRemovalService.ts new file mode 100644 index 0000000..685d53c --- /dev/null +++ b/src/domain/manual-sync/services/CardMarkerRemovalService.ts @@ -0,0 +1,50 @@ +import { CardMarkerService } from "@/domain/manual-sync/services/CardMarkerService"; + +export interface MarkerRemovalTarget { + cardId: string; +} + +export interface MarkerRemovalApplyResult { + nextContent: string; + removedMarkers: number; +} + +export class CardMarkerRemovalService { + constructor(private readonly markerService = new CardMarkerService()) {} + + apply(sourceContent: string, targets: MarkerRemovalTarget[]): MarkerRemovalApplyResult { + if (targets.length === 0) { + return { + nextContent: sourceContent, + removedMarkers: 0, + }; + } + + const targetCardIds = new Set(targets.map((target) => target.cardId)); + const lineEnding = sourceContent.includes("\r\n") ? "\r\n" : "\n"; + const lines = sourceContent.split(/\r?\n/); + const nextLines: string[] = []; + let removedMarkers = 0; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (!this.markerService.isCandidate(line)) { + nextLines.push(line); + continue; + } + + const marker = this.markerService.parse(line, index + 1); + if (!marker || !targetCardIds.has(marker.cardId)) { + nextLines.push(line); + continue; + } + + removedMarkers += 1; + } + + return { + nextContent: nextLines.join(lineEnding), + removedMarkers, + }; + } +} \ No newline at end of file diff --git a/src/infrastructure/anki/AnkiConnectGateway.test.ts b/src/infrastructure/anki/AnkiConnectGateway.test.ts index 6a246f8..bc62661 100644 --- a/src/infrastructure/anki/AnkiConnectGateway.test.ts +++ b/src/infrastructure/anki/AnkiConnectGateway.test.ts @@ -34,6 +34,25 @@ describe("AnkiConnectGateway", () => { }); }); + it("loads deck names from AnkiConnect", async () => { + requestUrlMock.mockResolvedValue({ + json: { + error: null, + result: ["Default", "Scoped::Deck"], + }, + }); + + const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765"); + const deckNames = await gateway.listDeckNames(); + + expect(deckNames).toEqual(["Default", "Scoped::Deck"]); + expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ + action: "deckNames", + version: 6, + params: {}, + }); + }); + it("loads model fields and detects cloze templates", async () => { requestUrlMock .mockResolvedValueOnce({ @@ -88,6 +107,33 @@ describe("AnkiConnectGateway", () => { }); }); + it("maps deck stats into empty-deck detection shape", async () => { + requestUrlMock.mockResolvedValue({ + json: { + error: null, + result: { + Empty: { total_in_deck: 0 }, + Busy: { total_in_deck: 3 }, + }, + }, + }); + + const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765"); + const stats = await gateway.getDeckStats(["Empty", "Busy"]); + + expect(stats).toEqual([ + { deckName: "Empty", noteCount: 0 }, + { deckName: "Busy", noteCount: 3 }, + ]); + expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ + action: "getDeckStats", + version: 6, + params: { + decks: ["Empty", "Busy"], + }, + }); + }); + it("batches add note calls through AnkiConnect multi", async () => { requestUrlMock.mockResolvedValue({ json: { @@ -231,4 +277,40 @@ describe("AnkiConnectGateway", () => { }, }); }); + + it("deletes notes and empty decks through direct actions", async () => { + requestUrlMock + .mockResolvedValueOnce({ + json: { + error: null, + result: null, + }, + }) + .mockResolvedValueOnce({ + json: { + error: null, + result: null, + }, + }); + + const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765"); + await gateway.deleteNotes([1, 2]); + await gateway.deleteDecks(["Empty", "Empty", "Scoped::Deck"]); + + expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ + action: "deleteNotes", + version: 6, + params: { + notes: [1, 2], + }, + }); + expect(JSON.parse(requestUrlMock.mock.calls[1][0].body)).toEqual({ + action: "deleteDecks", + version: 6, + params: { + decks: ["Empty", "Scoped::Deck"], + cardsToo: false, + }, + }); + }); }); \ No newline at end of file diff --git a/src/infrastructure/anki/AnkiConnectGateway.ts b/src/infrastructure/anki/AnkiConnectGateway.ts index 41a03f3..a851871 100644 --- a/src/infrastructure/anki/AnkiConnectGateway.ts +++ b/src/infrastructure/anki/AnkiConnectGateway.ts @@ -1,7 +1,7 @@ import { requestUrl } from "obsidian"; import type { NoteModelDetails } from "@/application/dto/NoteModelDetails"; -import type { AddAnkiNoteInput, AnkiGateway, AnkiNoteSummary, ChangeDeckInput, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway"; +import type { AddAnkiNoteInput, AnkiGateway, AnkiNoteSummary, ChangeDeckInput, DeckStat, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway"; import type { MediaAsset } from "@/domain/card/entities/RenderedFields"; interface AnkiResponse { @@ -15,6 +15,10 @@ interface NoteInfo { noteId?: number; } +interface RawDeckStats { + total_in_deck?: number; +} + type ModelTemplates = Record; export class AnkiConnectGateway implements AnkiGateway { @@ -36,6 +40,10 @@ export class AnkiConnectGateway implements AnkiGateway { return this.invoke("modelNames", {}); } + async listDeckNames(): Promise { + return this.invoke("deckNames", {}); + } + async getModelDetails(modelName: string): Promise { const fieldNames = await this.invoke("modelFieldNames", { modelName }); let isCloze = modelName.toLowerCase().includes("cloze"); @@ -53,6 +61,21 @@ export class AnkiConnectGateway implements AnkiGateway { }; } + async getDeckStats(deckNames: string[]): Promise { + if (deckNames.length === 0) { + return []; + } + + const rawStats = await this.invoke>("getDeckStats", { + decks: deckNames, + }); + + return deckNames.map((deckName) => ({ + deckName, + noteCount: rawStats[deckName]?.total_in_deck ?? 0, + })); + } + async getNoteSummaries(noteIds: number[]): Promise { if (noteIds.length === 0) { return []; @@ -108,6 +131,16 @@ export class AnkiConnectGateway implements AnkiGateway { }))); } + async deleteNotes(noteIds: number[]): Promise { + if (noteIds.length === 0) { + return; + } + + await this.invoke("deleteNotes", { + notes: noteIds, + }); + } + async updateNote(input: UpdateAnkiNoteInput): Promise { await this.invoke("updateNoteFields", { note: { @@ -151,6 +184,18 @@ export class AnkiConnectGateway implements AnkiGateway { }))); } + async deleteDecks(deckNames: string[]): Promise { + const uniqueDeckNames = Array.from(new Set(deckNames)); + if (uniqueDeckNames.length === 0) { + return; + } + + await this.invoke("deleteDecks", { + decks: uniqueDeckNames, + cardsToo: false, + }); + } + async storeMedia(asset: MediaAsset): Promise { await this.invoke("storeMediaFile", { filename: asset.fileName, diff --git a/src/presentation/AnkiHeadingSyncPlugin.ts b/src/presentation/AnkiHeadingSyncPlugin.ts index 366a42b..12c1297 100644 --- a/src/presentation/AnkiHeadingSyncPlugin.ts +++ b/src/presentation/AnkiHeadingSyncPlugin.ts @@ -3,6 +3,8 @@ import { Plugin } from "obsidian"; import { DEFAULT_SETTINGS, type PluginSettings } from "@/application/config/PluginSettings"; import type { NoteModelDetails } from "@/application/dto/NoteModelDetails"; import { DeckTemplateInsertionService } from "@/application/services/DeckTemplateInsertionService"; +import { CleanupEmptyDecksUseCase } from "@/application/use-cases/CleanupEmptyDecksUseCase"; +import { ClearCurrentFileSyncedCardsUseCase } from "@/application/use-cases/ClearCurrentFileSyncedCardsUseCase"; import { ManualSyncCurrentFileUseCase } from "@/application/use-cases/ManualSyncCurrentFileUseCase"; import { RebuildCardIndexUseCase } from "@/application/use-cases/RebuildCardIndexUseCase"; import { ManualSyncVaultUseCase } from "@/application/use-cases/ManualSyncVaultUseCase"; @@ -12,6 +14,7 @@ import { ObsidianVaultGateway } from "@/infrastructure/obsidian/ObsidianVaultGat import { DataJsonPluginConfigRepository, type PluginDataSnapshot } from "@/infrastructure/persistence/DataJsonPluginConfigRepository"; import { DataJsonPluginStateRepository } from "@/infrastructure/persistence/DataJsonPluginStateRepository"; import { registerCommands } from "@/presentation/commands/registerCommands"; +import { EmptyDeckSelectionModal } from "@/presentation/modals/EmptyDeckSelectionModal"; import { NoticeService } from "@/presentation/notices/NoticeService"; import { AnkiHeadingSyncSettingTab } from "@/presentation/settings/PluginSettingTab"; import { CurrentFileOutOfScopeError, ManualSyncService } from "@/application/services/ManualSyncService"; @@ -28,6 +31,8 @@ export default class AnkiHeadingSyncPlugin extends Plugin { private syncCurrentFileUseCase?: ManualSyncCurrentFileUseCase; private syncVaultUseCase?: ManualSyncVaultUseCase; private rebuildCardIndexUseCase?: RebuildCardIndexUseCase; + private clearCurrentFileSyncedCardsUseCase?: ClearCurrentFileSyncedCardsUseCase; + private cleanupEmptyDecksUseCase?: CleanupEmptyDecksUseCase; private pluginConfigRepository?: DataJsonPluginConfigRepository; private vaultGateway?: ManualSyncVaultGateway; @@ -50,6 +55,8 @@ export default class AnkiHeadingSyncPlugin extends Plugin { this.syncCurrentFileUseCase = new ManualSyncCurrentFileUseCase(manualSyncService); this.syncVaultUseCase = new ManualSyncVaultUseCase(manualSyncService); this.rebuildCardIndexUseCase = new RebuildCardIndexUseCase(manualSyncService); + this.clearCurrentFileSyncedCardsUseCase = new ClearCurrentFileSyncedCardsUseCase(pluginStateRepository, this.ankiGateway, vaultGateway); + this.cleanupEmptyDecksUseCase = new CleanupEmptyDecksUseCase(this.ankiGateway); registerCommands(this); this.addSettingTab(new AnkiHeadingSyncSettingTab(this)); @@ -185,4 +192,59 @@ export default class AnkiHeadingSyncPlugin extends Plugin { this.noticeService.error(error instanceof Error ? error.message : "Deck template insertion failed."); } } + + async runClearCurrentFileSyncedCards(): Promise { + const activeFile = this.app.workspace.getActiveFile(); + + if (!activeFile || activeFile.extension.toLowerCase() !== "md") { + this.noticeService.error("No active Markdown file is available for reset."); + return; + } + + if (!this.clearCurrentFileSyncedCardsUseCase) { + this.noticeService.error("Clear-current-file use case is not initialized."); + return; + } + + try { + const hasTrackedCards = await this.clearCurrentFileSyncedCardsUseCase.hasTrackedCards(activeFile.path); + if (!hasTrackedCards) { + this.noticeService.info("当前文件没有已同步卡片"); + return; + } + + const result = await this.clearCurrentFileSyncedCardsUseCase.execute(activeFile.path); + this.noticeService.showClearCurrentFileSummary("当前文件已同步卡片清空完成", result); + } catch (error) { + console.error("Clear current file synced cards failed.", error); + this.noticeService.error(error instanceof Error ? error.message : "Clear current file synced cards failed."); + } + } + + async runCleanupEmptyDecks(): Promise { + if (!this.cleanupEmptyDecksUseCase) { + this.noticeService.error("Empty-deck cleanup use case is not initialized."); + return; + } + + try { + const candidateDeckNames = await this.cleanupEmptyDecksUseCase.listCandidates(); + if (candidateDeckNames.length === 0) { + this.noticeService.info("未发现可清理的空牌组"); + return; + } + + const selectedDeckNames = await new EmptyDeckSelectionModal(this.app, candidateDeckNames).openAndGetSelection(); + if (selectedDeckNames === null) { + this.noticeService.info("已取消空牌组清理"); + return; + } + + const result = await this.cleanupEmptyDecksUseCase.execute(selectedDeckNames, candidateDeckNames); + this.noticeService.showCleanupEmptyDecksSummary("空牌组清理完成", result); + } catch (error) { + console.error("Cleanup empty decks failed.", error); + this.noticeService.error(error instanceof Error ? error.message : "Cleanup empty decks failed."); + } + } } \ No newline at end of file diff --git a/src/presentation/commands/registerCommands.ts b/src/presentation/commands/registerCommands.ts index 4e47939..ef8add1 100644 --- a/src/presentation/commands/registerCommands.ts +++ b/src/presentation/commands/registerCommands.ts @@ -24,4 +24,20 @@ export function registerCommands(plugin: AnkiHeadingSyncPlugin): void { void plugin.runRebuildCardIndex(); }, }); + + plugin.addCommand({ + id: "clear-current-file-synced-cards", + name: "清空当前文件已同步卡片", + callback: () => { + void plugin.runClearCurrentFileSyncedCards(); + }, + }); + + plugin.addCommand({ + id: "cleanup-empty-decks", + name: "清理空牌组", + callback: () => { + void plugin.runCleanupEmptyDecks(); + }, + }); } \ No newline at end of file diff --git a/src/presentation/modals/EmptyDeckSelectionModal.ts b/src/presentation/modals/EmptyDeckSelectionModal.ts new file mode 100644 index 0000000..2c28e44 --- /dev/null +++ b/src/presentation/modals/EmptyDeckSelectionModal.ts @@ -0,0 +1,69 @@ +import { Modal, Setting, type App } from "obsidian"; + +export class EmptyDeckSelectionModal extends Modal { + private readonly selectedDeckNames = new Set(); + private resolver?: (value: string[] | null) => void; + private resolvedValue: string[] | null = null; + + constructor(app: App, private readonly candidateDeckNames: string[]) { + super(app); + } + + openAndGetSelection(): Promise { + return new Promise((resolve) => { + this.resolver = resolve; + this.open(); + }); + } + + override onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.createEl("h2", { text: "清理空牌组" }); + contentEl.createEl("p", { text: "勾选要删除的空牌组。只有删除时仍为空的牌组会被真正删除。" }); + + for (const deckName of this.candidateDeckNames) { + new Setting(contentEl) + .setName(deckName) + .addToggle((toggle) => { + toggle.setValue(false).onChange((value) => { + if (value) { + this.selectedDeckNames.add(deckName); + return; + } + + this.selectedDeckNames.delete(deckName); + }); + }); + } + + new Setting(contentEl) + .addButton((button) => { + button.setButtonText("取消").onClick(() => { + this.finish(null); + }); + }) + .addButton((button) => { + button.setButtonText("删除所选空牌组").setCta().onClick(() => { + this.finish(Array.from(this.selectedDeckNames)); + }); + }); + } + + override onClose(): void { + const resolve = this.resolver; + const resolvedValue = this.resolvedValue; + + this.contentEl.empty(); + this.resolver = undefined; + this.resolvedValue = null; + this.selectedDeckNames.clear(); + + resolve?.(resolvedValue); + } + + private finish(value: string[] | null): void { + this.resolvedValue = value; + this.close(); + } +} \ No newline at end of file diff --git a/src/presentation/notices/NoticeService.ts b/src/presentation/notices/NoticeService.ts index 1f9bdb5..38a8932 100644 --- a/src/presentation/notices/NoticeService.ts +++ b/src/presentation/notices/NoticeService.ts @@ -1,5 +1,6 @@ import { Notice } from "obsidian"; +import type { ClearCurrentFileSyncedCardsResult, CleanupEmptyDecksResult } from "@/application/use-cases/cleanupResetTypes"; import type { ManualSyncResult } from "@/application/use-cases/manualSyncTypes"; export class NoticeService { @@ -36,4 +37,25 @@ export class NoticeService { this.info(warning.message); } } + + showClearCurrentFileSummary(prefix: string, result: ClearCurrentFileSyncedCardsResult): void { + const summary = `${prefix}: tracked ${result.trackedCards}, deleted notes ${result.deletedNotes}, removed markers ${result.removedMarkers}, deleted local records ${result.deletedLocalRecords}.`; + const conflicts = result.conflictFiles.length > 0 + ? ` Marker removal conflicts: ${result.conflictFiles.join(", ")}.` + : ""; + const failures = result.failureFiles.length > 0 + ? ` Failures: ${result.failureFiles.map((entry) => `${entry.filePath} (${entry.message})`).join(", ")}.` + : ""; + + this.info(`${summary}${conflicts}${failures}`); + } + + showCleanupEmptyDecksSummary(prefix: string, result: CleanupEmptyDecksResult): void { + const summary = `${prefix}: candidates ${result.candidateCount}, selected ${result.selectedCount}, deleted ${result.deletedCount}, skipped ${result.skippedDeckNames.length}.`; + const skipped = result.skippedDeckNames.length > 0 + ? ` Skipped decks: ${result.skippedDeckNames.join(", ")}.` + : ""; + + this.info(`${summary}${skipped}`); + } } \ No newline at end of file diff --git a/src/test-support/manualSyncFakes.ts b/src/test-support/manualSyncFakes.ts index 0ebf08d..6dbe0ec 100644 --- a/src/test-support/manualSyncFakes.ts +++ b/src/test-support/manualSyncFakes.ts @@ -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, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway"; +import type { AddAnkiNoteInput, AnkiGateway, AnkiNoteSummary, ChangeDeckInput, 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"; @@ -137,10 +137,13 @@ export class FakeManualSyncVaultGateway implements ManualSyncVaultGateway { export class FakeManualSyncAnkiGateway implements AnkiGateway { public ensuredDecks: string[][] = []; public addedNotes: AddAnkiNoteInput[] = []; + public deletedNotes: number[][] = []; public updatedNotes: UpdateAnkiNoteInput[] = []; public changedDecks: ChangeDeckInput[] = []; + public deletedDecks: string[][] = []; public storedMedia: MediaAsset[] = []; public noteSummariesById = new Map(); + public deckStatsByName = new Map(); public modelDetailsByName: Record = { Basic: { fieldNames: ["Front", "Back"], isCloze: false }, Cloze: { fieldNames: ["Text", "Extra"], isCloze: true }, @@ -160,10 +163,18 @@ export class FakeManualSyncAnkiGateway implements AnkiGateway { return Object.keys(this.modelDetailsByName); } + async listDeckNames(): Promise { + return Array.from(this.deckStatsByName.keys()); + } + async getModelDetails(modelName: string): Promise { return this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"], isCloze: false }; } + async getDeckStats(deckNames: string[]): Promise { + return deckNames.map((deckName) => this.deckStatsByName.get(deckName) ?? { deckName, noteCount: 0 }); + } + async getNoteSummaries(noteIds: number[]): Promise { return noteIds.flatMap((noteId) => { const summary = this.noteSummariesById.get(noteId); @@ -184,6 +195,10 @@ export class FakeManualSyncAnkiGateway implements AnkiGateway { }); } + async deleteNotes(noteIds: number[]): Promise { + this.deletedNotes.push(noteIds); + } + async updateNote(input: UpdateAnkiNoteInput): Promise { await this.updateNotes([input]); } @@ -196,6 +211,10 @@ export class FakeManualSyncAnkiGateway implements AnkiGateway { this.changedDecks.push(...inputs); } + async deleteDecks(deckNames: string[]): Promise { + this.deletedDecks.push(deckNames); + } + async storeMedia(asset: MediaAsset): Promise { await this.storeMediaFiles([asset]); }