From a3c540a493243e107f4d23bd9d6da6da94b96b9a Mon Sep 17 00:00:00 2001 From: Dusk Date: Tue, 21 Apr 2026 11:56:29 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20clear=20current-file=20synced=20cards=20?= =?UTF-8?q?for=20semantic=20QA=20and=20QA=20groups=20/=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=BD=93=E5=89=8D=E6=96=87=E4=BB=B6=E6=B8=85=E7=A9=BA?= =?UTF-8?q?=E5=B7=B2=E5=90=8C=E6=AD=A5=E5=8D=A1=E7=89=87=E8=A6=86=E7=9B=96?= =?UTF-8?q?=20semantic=20QA=20=E4=B8=8E=20QA=20Group?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ar-current-file-full-coverage-decisions.md | 56 +++ ...r-current-file-full-coverage-gap-report.md | 70 +++ ...MarkdownSyncedMarkerRemovalService.test.ts | 86 ++++ .../MarkdownSyncedMarkerRemovalService.ts | 143 ++++++ ...ClearCurrentFileSyncedCardsUseCase.test.ts | 475 ++++++++++++++++-- .../ClearCurrentFileSyncedCardsUseCase.ts | 104 +++- .../use-cases/cleanupResetTypes.ts | 3 + src/presentation/notices/NoticeService.ts | 2 +- 8 files changed, 873 insertions(+), 66 deletions(-) create mode 100644 docs/clear-current-file-full-coverage-decisions.md create mode 100644 docs/clear-current-file-full-coverage-gap-report.md create mode 100644 src/application/services/MarkdownSyncedMarkerRemovalService.test.ts create mode 100644 src/application/services/MarkdownSyncedMarkerRemovalService.ts diff --git a/docs/clear-current-file-full-coverage-decisions.md b/docs/clear-current-file-full-coverage-decisions.md new file mode 100644 index 0000000..b273299 --- /dev/null +++ b/docs/clear-current-file-full-coverage-decisions.md @@ -0,0 +1,56 @@ +# Clear Current File Full-Coverage Decisions + +## Command semantic + +- Keep the command semantic fixed as: clear all synced entries in the current file. +- Covered routes: + - normal QA / cloze cards + - semantic QA child cards written with `` + - QA Group blocks written with `` + +## Collection strategy + +- `ClearCurrentFileSyncedCardsUseCase` will collect two separate tracked sets: + - `trackedCards: CardState[]` + - `trackedGroupBlocks: GroupBlockState[]` +- Collection sources: + - cards from `files[filePath].noteIds` plus `state.cards` fallback by `filePath` + - groups from `files[filePath].groupIds` plus `state.groupBlocks` fallback by `filePath` +- `hasTrackedCards(filePath)` will mean any tracked card or tracked group exists for the file. +- Anki note deletion will use one deduplicated `noteIds` array across both sets. + +## Marker removal strategy + +- Add a new application-layer service named `MarkdownSyncedMarkerRemovalService`. +- The service will: + - read the Markdown file once + - remove matching `ID` markers by `noteId` + - remove matching `GI` markers by `noteId` + - call `replaceMarkdownFile(...)` at most once per file +- It will reuse `CardMarkerRemovalService` and `GroupMarkerService` for marker recognition and low-level line operations instead of replacing their existing responsibilities. +- Removal will match by `noteId`; `groupId` and `blockStartLine` stay in the input/result surface for traceability but not as the primary deletion key. + +## Local state cleanup strategy + +- The clear path will remove from local state: + - tracked card entries in `state.cards` + - tracked group entries in `state.groupBlocks` + - `state.files[filePath]` + - all `pendingWriteBack` entries where `pending.filePath === filePath` +- Pending cleanup is file-scoped by design and will not rely on `targetNoteId` matching. + +## Result and notice shape + +- Extend `ClearCurrentFileSyncedCardsResult` with split stats for cards, groups, ID markers, GI markers, and local-record deletions. +- `NoticeService.showClearCurrentFileSummary(...)` will render total counts plus group/card breakdown instead of collapsing everything into `trackedCards`. + +## Testing scope + +- Keep existing normal-card clear behavior passing. +- Add coverage for: + - semantic QA clear + - QA Group clear + - mixed-file clear with deduplicated note deletion + - GI-marker conflict handling + - file-scoped pending cleanup for both `card-id` and `group-gi` +- No changes will be made to normal sync, rebuild, `QaGroupSyncService`, or semantic/group routing beyond what is needed to support clear-current-file. \ No newline at end of file diff --git a/docs/clear-current-file-full-coverage-gap-report.md b/docs/clear-current-file-full-coverage-gap-report.md new file mode 100644 index 0000000..9f2afa5 --- /dev/null +++ b/docs/clear-current-file-full-coverage-gap-report.md @@ -0,0 +1,70 @@ +# Clear Current File Full-Coverage Gap Report + +## Scope reviewed + +- `src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.ts` +- `src/application/services/MarkdownMarkerRemovalService.ts` +- `src/domain/manual-sync/services/CardMarkerRemovalService.ts` +- `src/domain/manual-sync/services/GroupMarkerService.ts` +- `src/domain/manual-sync/entities/PluginState.ts` +- `src/application/use-cases/cleanupResetTypes.ts` +- `src/presentation/notices/NoticeService.ts` +- `src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.test.ts` +- related write-back/state flow in `MarkdownWriteBackService` and `QaGroupSyncService` + +## Confirmed current behavior + +### 1. Current clear command only tracks normal cards + +- `ClearCurrentFileSyncedCardsUseCase` only calls `collectTrackedCards(state, filePath)`. +- `collectTrackedCards(...)` only reads `state.files[filePath]?.noteIds` and `state.cards` fallback. +- `state.files[filePath]?.groupIds` and `state.groupBlocks` are ignored entirely. + +### 2. Current marker removal is ID-only + +- `MarkdownMarkerRemovalService` delegates only to `CardMarkerRemovalService`. +- `CardMarkerRemovalService` only removes `` markers by `noteId`. +- There is no application-layer remover that also consumes `GroupMarkerService` and strips ``. + +### 3. Current local cleanup only clears card records + +- `buildNextState(...)` deletes matching `state.cards` entries and the file entry in `state.files`. +- It does not delete `state.groupBlocks` for the same file. +- `pendingWriteBack` cleanup currently filters by both `pending.filePath !== filePath` and tracked note keys, which is narrower than the required file-scoped clear semantics. +- Because `group-gi` pending entries are file-scoped and may not map cleanly through the tracked-note filter, current cleanup is incomplete for group writes. + +### 4. Current result type and notice summary assume only normal cards + +- `ClearCurrentFileSyncedCardsResult` only exposes `trackedCards`, `deletedNotes`, `removedMarkers`, `deletedLocalRecords`, `conflictFiles`, and `failureFiles`. +- `NoticeService.showClearCurrentFileSummary(...)` renders only one tracked-card count and one removed-marker total. +- A file containing only QA Group state would currently produce misleading summary output. + +### 5. State model already supports the missing routes + +- `PluginState` already contains `groupBlocks` and `FileState.groupIds`. +- `PendingWriteBackState` already has `markerKind?: "card-id" | "group-gi"` and `targetGroupId`. +- `MarkdownWriteBackService` already persists `group-gi` pending entries and `QaGroupSyncService` already writes GI markers. +- This confirms the bug is in the clear-current-file command path, not in the underlying state model. + +### 6. Current tests only cover normal cards + +- `ClearCurrentFileSyncedCardsUseCase.test.ts` covers: + - normal-card clear success + - empty-file result + - ID-marker conflict handling +- It does not cover: + - semantic QA child cards + - QA Group GI markers + - mixed files + - group conflict behavior + - `group-gi` pending cleanup + +## Gap versus required behavior + +- Missing tracked group collection from `groupIds` and `groupBlocks` fallback. +- Missing unified marker removal for both `ID` and `GI` markers. +- Missing group-block deletion from local state. +- Missing file-scoped pending cleanup for all entries in the cleared file. +- Missing result fields for group-specific stats. +- Missing notice summary split for card markers vs group markers. +- Missing regression coverage for semantic QA, QA Group, mixed files, group conflicts, and pending cleanup. \ No newline at end of file diff --git a/src/application/services/MarkdownSyncedMarkerRemovalService.test.ts b/src/application/services/MarkdownSyncedMarkerRemovalService.test.ts new file mode 100644 index 0000000..ce63142 --- /dev/null +++ b/src/application/services/MarkdownSyncedMarkerRemovalService.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { FakeManualSyncVaultGateway } from "@/test-support/manualSyncFakes"; + +import { MarkdownSyncedMarkerRemovalService } from "./MarkdownSyncedMarkerRemovalService"; + +describe("MarkdownSyncedMarkerRemovalService", () => { + it("removes matching ID and GI markers in one write while preserving body content", async () => { + const filePath = "notes/example.md"; + const content = [ + "#### Prompt", + "Answer", + "", + "", + "#### Concepts #anki-list", + "- Alpha", + " - First answer", + "", + "", + "#### Next", + "Body", + "", + ].join("\n"); + const vaultGateway = new FakeManualSyncVaultGateway({ + [filePath]: content, + }); + const service = new MarkdownSyncedMarkerRemovalService(vaultGateway); + + const result = await service.remove( + filePath, + [{ noteId: 41 }], + [{ noteId: 42, groupId: "group-1", blockStartLine: 5 }], + ); + + expect(result).toEqual({ + removedCardMarkers: 1, + removedGroupMarkers: 1, + removedMarkers: 2, + conflictFiles: [], + failureFiles: [], + }); + expect(vaultGateway.replaceCalls).toHaveLength(1); + expect(vaultGateway.getFileContent(filePath)).toBe([ + "#### Prompt", + "Answer", + "", + "#### Concepts #anki-list", + "- Alpha", + " - First answer", + "", + "#### Next", + "Body", + "", + ].join("\n")); + }); + + it("reports conflicts without claiming removed markers", async () => { + const filePath = "notes/conflict.md"; + const content = [ + "#### Prompt", + "Answer", + "", + "", + ].join("\n"); + const vaultGateway = new FakeManualSyncVaultGateway({ + [filePath]: content, + }); + vaultGateway.conflictPaths.add(filePath); + const service = new MarkdownSyncedMarkerRemovalService(vaultGateway); + + const result = await service.remove( + filePath, + [{ noteId: 41 }], + [{ noteId: 42, groupId: "group-1", blockStartLine: 1 }], + ); + + expect(result).toEqual({ + removedCardMarkers: 0, + removedGroupMarkers: 0, + removedMarkers: 0, + conflictFiles: [filePath], + failureFiles: [], + }); + expect(vaultGateway.getFileContent(filePath)).toBe(content); + }); +}); \ No newline at end of file diff --git a/src/application/services/MarkdownSyncedMarkerRemovalService.ts b/src/application/services/MarkdownSyncedMarkerRemovalService.ts new file mode 100644 index 0000000..c545d11 --- /dev/null +++ b/src/application/services/MarkdownSyncedMarkerRemovalService.ts @@ -0,0 +1,143 @@ +import { MarkdownWriteConflictError } from "@/application/ports/VaultGateway"; +import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway"; +import { CardMarkerService } from "@/domain/manual-sync/services/CardMarkerService"; +import { GroupMarkerService } from "@/domain/manual-sync/services/GroupMarkerService"; + +export interface SyncedCardMarkerRemovalTarget { + noteId: number; +} + +export interface SyncedGroupMarkerRemovalTarget { + noteId: number; + groupId: string; + blockStartLine: number; +} + +export interface MarkdownSyncedMarkerRemovalResult { + removedCardMarkers: number; + removedGroupMarkers: number; + removedMarkers: number; + conflictFiles: string[]; + failureFiles: Array<{ filePath: string; message: string }>; +} + +export class MarkdownSyncedMarkerRemovalService { + constructor( + private readonly vaultGateway: ManualSyncVaultGateway, + private readonly cardMarkerService = new CardMarkerService(), + private readonly groupMarkerService = new GroupMarkerService(), + ) {} + + async remove( + filePath: string, + cardTargets: SyncedCardMarkerRemovalTarget[], + groupTargets: SyncedGroupMarkerRemovalTarget[], + ): Promise { + if (cardTargets.length === 0 && groupTargets.length === 0) { + return createEmptyRemovalResult(); + } + + const sourceFile = await this.vaultGateway.readMarkdownFile(filePath); + if (!sourceFile) { + return { + ...createEmptyRemovalResult(), + failureFiles: [{ filePath, message: `Markdown file not found: ${filePath}` }], + }; + } + + const removal = applyMarkerRemoval( + sourceFile.content, + new Set(cardTargets.map((target) => target.noteId)), + new Set(groupTargets.map((target) => target.noteId)), + this.cardMarkerService, + this.groupMarkerService, + ); + + try { + if (removal.nextContent !== sourceFile.content) { + await this.vaultGateway.replaceMarkdownFile(filePath, sourceFile.content, removal.nextContent); + } + + return { + removedCardMarkers: removal.removedCardMarkers, + removedGroupMarkers: removal.removedGroupMarkers, + removedMarkers: removal.removedCardMarkers + removal.removedGroupMarkers, + conflictFiles: [], + failureFiles: [], + }; + } catch (error) { + if (error instanceof MarkdownWriteConflictError) { + return { + ...createEmptyRemovalResult(), + conflictFiles: [filePath], + }; + } + + return { + ...createEmptyRemovalResult(), + failureFiles: [{ + filePath, + message: error instanceof Error ? error.message : String(error), + }], + }; + } + } +} + +interface AppliedMarkerRemoval { + nextContent: string; + removedCardMarkers: number; + removedGroupMarkers: number; +} + +function applyMarkerRemoval( + sourceContent: string, + cardNoteIds: Set, + groupNoteIds: Set, + cardMarkerService: CardMarkerService, + groupMarkerService: GroupMarkerService, +): AppliedMarkerRemoval { + const lineEnding = sourceContent.includes("\r\n") ? "\r\n" : "\n"; + const lines = sourceContent.split(/\r?\n/); + const nextLines: string[] = []; + let removedCardMarkers = 0; + let removedGroupMarkers = 0; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + + if (cardMarkerService.isCandidate(line)) { + const marker = cardMarkerService.parse(line, index + 1); + if (marker && cardNoteIds.has(marker.noteId)) { + removedCardMarkers += 1; + continue; + } + } + + if (groupMarkerService.isCandidate(line)) { + const marker = groupMarkerService.parse(line, index + 1); + if (marker?.noteId !== undefined && groupNoteIds.has(marker.noteId)) { + removedGroupMarkers += 1; + continue; + } + } + + nextLines.push(line); + } + + return { + nextContent: nextLines.join(lineEnding), + removedCardMarkers, + removedGroupMarkers, + }; +} + +function createEmptyRemovalResult(): MarkdownSyncedMarkerRemovalResult { + return { + removedCardMarkers: 0, + removedGroupMarkers: 0, + removedMarkers: 0, + conflictFiles: [], + failureFiles: [], + }; +} \ No newline at end of file diff --git a/src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.test.ts b/src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.test.ts index 56b8857..03748a7 100644 --- a/src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.test.ts +++ b/src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; +import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping"; import type { PluginSettings } from "@/application/config/PluginSettings"; import { createDeckRulesFingerprint } from "@/application/services/FileIndexerService"; import { ManualSyncService } from "@/application/services/ManualSyncService"; import { RenderConfigService } from "@/application/services/RenderConfigService"; -import type { CardState } from "@/domain/manual-sync/entities/PluginState"; +import { buildGroupSrc } from "@/domain/manual-sync/entities/IndexedGroupCardBlock"; +import type { CardState, FileState, GroupBlockState, PendingWriteBackState } from "@/domain/manual-sync/entities/PluginState"; import { hashString } from "@/domain/shared/hash"; import { createModule3Settings, FakeManualSyncAnkiGateway, FakeManualSyncVaultGateway, InMemoryPluginStateRepository } from "@/test-support/manualSyncFakes"; @@ -28,14 +30,7 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => { }); const stateRepository = new InMemoryPluginStateRepository({ files: { - [filePath]: { - filePath, - fileHash: hashString(content), - fileStamp: `1:${content.length}`, - deckRulesFingerprint: createDeckRulesFingerprint(settings), - lastIndexedAt: 1, - noteIds: [41, 42], - }, + [filePath]: createStoredFileState(settings, filePath, content, { noteIds: [41, 42] }), }, cards: { "41": createStoredSyncedCard(settings, { @@ -78,8 +73,11 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => { expect(result).toMatchObject({ trackedCards: 2, + trackedGroups: 0, deletedNotes: 2, removedMarkers: 2, + removedCardMarkers: 2, + removedGroupMarkers: 0, deletedLocalRecords: 2, conflictFiles: [], failureFiles: [], @@ -112,12 +110,17 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => { const vaultGateway = new FakeManualSyncVaultGateway(); const useCase = new ClearCurrentFileSyncedCardsUseCase(stateRepository, ankiGateway, vaultGateway); + await expect(useCase.hasTrackedCards("notes/missing.md")).resolves.toBe(false); + const result = await useCase.execute("notes/missing.md"); expect(result).toEqual({ trackedCards: 0, + trackedGroups: 0, deletedNotes: 0, removedMarkers: 0, + removedCardMarkers: 0, + removedGroupMarkers: 0, deletedLocalRecords: 0, conflictFiles: [], failureFiles: [], @@ -125,13 +128,249 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => { 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 () => { + it("clears semantic QA child cards and allows a later sync to recreate them", async () => { + const settings = createSemanticQaSettings(); + const filePath = "notes/semantic.md"; + const content = [ + "#### Concepts #anki-list-qa", + "- Alpha", + " First answer", + " ", + "- Beta", + " Second answer", + " ", + ].join("\n"); + const vaultGateway = new FakeManualSyncVaultGateway({ + [filePath]: content, + }); + const stateRepository = new InMemoryPluginStateRepository({ + files: { + [filePath]: createStoredFileState(settings, filePath, content, { noteIds: [41, 42] }), + }, + cards: { + "41": createStoredSyncedCard(settings, { + noteId: 41, + filePath, + cardType: "semantic-qa", + heading: "Concepts
Alpha", + backlinkHeadingText: "Concepts #anki-list-qa", + bodyMarkdown: "First answer", + rawBlockText: ["semantic-qa:Concepts::1", "Concepts", "Alpha", "First answer"].join("\n"), + bodyStartLine: 3, + contentEndLine: 3, + blockEndLine: 4, + markerLine: 4, + }), + "42": createStoredSyncedCard(settings, { + noteId: 42, + filePath, + cardType: "semantic-qa", + heading: "Concepts
Beta", + backlinkHeadingText: "Concepts #anki-list-qa", + bodyMarkdown: "Second answer", + rawBlockText: ["semantic-qa:Concepts::2", "Concepts", "Beta", "Second answer"].join("\n"), + blockStartLine: 5, + bodyStartLine: 6, + contentEndLine: 6, + blockEndLine: 7, + markerLine: 7, + }), + }, + pendingWriteBack: [], + }); + const ankiGateway = new FakeManualSyncAnkiGateway(); + const useCase = new ClearCurrentFileSyncedCardsUseCase(stateRepository, ankiGateway, vaultGateway); + + await expect(useCase.hasTrackedCards(filePath)).resolves.toBe(true); + + const result = await useCase.execute(filePath); + + expect(result).toMatchObject({ + trackedCards: 2, + trackedGroups: 0, + deletedNotes: 2, + removedMarkers: 2, + removedCardMarkers: 2, + removedGroupMarkers: 0, + deletedLocalRecords: 2, + conflictFiles: [], + failureFiles: [], + }); + expect(ankiGateway.deletedNotes).toEqual([[41, 42]]); + expect(vaultGateway.getFileContent(filePath)).toBe([ + "#### Concepts #anki-list-qa", + "- Alpha", + " First answer", + "- Beta", + " Second answer", + ].join("\n")); + expect(stateRepository.savedState?.files[filePath]).toBeUndefined(); + expect(stateRepository.savedState?.cards["41"]).toBeUndefined(); + expect(stateRepository.savedState?.cards["42"]).toBeUndefined(); + + 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(2); + expect(ankiGateway.addedNotes).toHaveLength(2); + expect(vaultGateway.getFileContent(filePath)).toContain(""); + expect(vaultGateway.getFileContent(filePath)).toContain(""); + expect(vaultGateway.getFileContent(filePath)).not.toContain(""); + expect(vaultGateway.getFileContent(filePath)).not.toContain(""); + }); + + it("clears QA Group records and allows a later sync to recreate the GI marker", async () => { const settings = createModule3Settings(); - const filePath = "notes/conflict.md"; + const filePath = "notes/group.md"; + const content = [ + "#### Concepts #anki-list", + "- Alpha", + " - First answer", + "- Beta", + " - Second answer", + "", + ].join("\n"); + const vaultGateway = new FakeManualSyncVaultGateway({ + [filePath]: content, + }); + const stateRepository = new InMemoryPluginStateRepository({ + files: { + [filePath]: createStoredFileState(settings, filePath, content, { groupIds: ["group-1"] }), + }, + cards: {}, + groupBlocks: { + "group-1": createStoredGroupBlockState({ + noteId: 42, + groupId: "group-1", + filePath, + markerLine: 6, + blockEndLine: 6, + contentEndLine: 5, + }), + }, + pendingWriteBack: [], + }); + const ankiGateway = new FakeManualSyncAnkiGateway(); + const useCase = new ClearCurrentFileSyncedCardsUseCase(stateRepository, ankiGateway, vaultGateway); + + await expect(useCase.hasTrackedCards(filePath)).resolves.toBe(true); + + const result = await useCase.execute(filePath); + + expect(result).toMatchObject({ + trackedCards: 0, + trackedGroups: 1, + deletedNotes: 1, + removedMarkers: 1, + removedCardMarkers: 0, + removedGroupMarkers: 1, + deletedLocalRecords: 1, + conflictFiles: [], + failureFiles: [], + }); + expect(ankiGateway.deletedNotes).toEqual([[42]]); + expect(vaultGateway.getFileContent(filePath)).toBe([ + "#### Concepts #anki-list", + "- Alpha", + " - First answer", + "- Beta", + " - Second answer", + ].join("\n")); + expect(stateRepository.savedState?.files[filePath]).toBeUndefined(); + expect(stateRepository.savedState?.groupBlocks?.["group-1"]).toBeUndefined(); + + const manualSyncService = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, () => 3000); + const syncResult = await manualSyncService.syncFile(filePath, settings); + + expect(syncResult.created).toBe(1); + expect(syncResult.rewrittenMarkers).toBe(1); + expect(ankiGateway.addedNotes).toHaveLength(1); + expect(vaultGateway.getFileContent(filePath)).toMatch(//); + expect(vaultGateway.getFileContent(filePath)).not.toContain("", + "", + "", + "#### Concepts #anki-list", + "- Alpha", + " - First answer", + "", + ].join("\n"); + const vaultGateway = new FakeManualSyncVaultGateway({ + [filePath]: content, + }); + const stateRepository = new InMemoryPluginStateRepository({ + files: { + [filePath]: createStoredFileState(settings, filePath, content, { + noteIds: [41], + groupIds: ["group-1"], + }), + }, + cards: { + "41": createStoredSyncedCard(settings, { + noteId: 41, + filePath, + markerLine: 3, + blockEndLine: 4, + }), + }, + groupBlocks: { + "group-1": createStoredGroupBlockState({ + noteId: 42, + groupId: "group-1", + filePath, + blockStartLine: 5, + bodyStartLine: 6, + contentEndLine: 7, + blockEndLine: 8, + markerLine: 8, + }), + }, + pendingWriteBack: [], + }); + const ankiGateway = new FakeManualSyncAnkiGateway(); + const useCase = new ClearCurrentFileSyncedCardsUseCase(stateRepository, ankiGateway, vaultGateway); + + const result = await useCase.execute(filePath); + + expect(result).toMatchObject({ + trackedCards: 1, + trackedGroups: 1, + deletedNotes: 2, + removedMarkers: 2, + removedCardMarkers: 1, + removedGroupMarkers: 1, + deletedLocalRecords: 2, + conflictFiles: [], + failureFiles: [], + }); + expect(ankiGateway.deletedNotes).toEqual([[41, 42]]); + expect(vaultGateway.getFileContent(filePath)).toBe([ + "#### Prompt", + "Answer", + "", + "#### Concepts #anki-list", + "- Alpha", + " - First answer", + ].join("\n")); + expect(stateRepository.savedState?.cards["41"]).toBeUndefined(); + expect(stateRepository.savedState?.groupBlocks?.["group-1"]).toBeUndefined(); + }); + + it("reports marker write conflicts for QA Group clears but still removes local state so a later sync can recreate the note", async () => { + const settings = createModule3Settings(); + const filePath = "notes/group-conflict.md"; + const content = [ + "#### Concepts #anki-list", + "- Alpha", + " - First answer", + "", ].join("\n"); const vaultGateway = new FakeManualSyncVaultGateway({ [filePath]: content, @@ -139,31 +378,26 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => { vaultGateway.conflictPaths.add(filePath); const stateRepository = new InMemoryPluginStateRepository({ files: { - [filePath]: { - filePath, - fileHash: hashString(content), - fileStamp: `1:${content.length}`, - deckRulesFingerprint: createDeckRulesFingerprint(settings), - lastIndexedAt: 1, - noteIds: [42], - }, + [filePath]: createStoredFileState(settings, filePath, content, { groupIds: ["group-1"] }), }, - cards: { - "42": createStoredSyncedCard(settings, { + cards: {}, + groupBlocks: { + "group-1": createStoredGroupBlockState({ noteId: 42, + groupId: "group-1", filePath, - markerLine: 3, + contentEndLine: 3, + blockEndLine: 4, + markerLine: 4, }), }, pendingWriteBack: [ - { - filePath, + createPendingWriteBack(filePath, hashString(content), 42, { blockStartLine: 1, - expectedFileHash: hashString(content), - targetMarker: "", - rawBlockHash: hashString(["#### Prompt", "Answer"].join("\n")), - targetNoteId: 42, - }, + targetMarker: "", + markerKind: "group-gi", + targetGroupId: "group-1", + }), ], }); const ankiGateway = new FakeManualSyncAnkiGateway(); @@ -171,27 +405,188 @@ describe("ClearCurrentFileSyncedCardsUseCase", () => { const result = await useCase.execute(filePath); - expect(result.deletedNotes).toBe(1); - expect(result.removedMarkers).toBe(0); - expect(result.deletedLocalRecords).toBe(1); - expect(result.conflictFiles).toEqual([filePath]); + expect(result).toMatchObject({ + trackedCards: 0, + trackedGroups: 1, + deletedNotes: 1, + removedMarkers: 0, + removedCardMarkers: 0, + removedGroupMarkers: 0, + deletedLocalRecords: 1, + conflictFiles: [filePath], + failureFiles: [], + }); expect(ankiGateway.deletedNotes).toEqual([[42]]); expect(stateRepository.savedState?.files[filePath]).toBeUndefined(); - expect(stateRepository.savedState?.cards["42"]).toBeUndefined(); + expect(stateRepository.savedState?.groupBlocks?.["group-1"]).toBeUndefined(); expect(stateRepository.savedState?.pendingWriteBack).toEqual([]); - expect(vaultGateway.getFileContent(filePath)).toContain(""); + expect(vaultGateway.getFileContent(filePath)).toContain(""); - expect(vaultGateway.getFileContent(filePath)).not.toContain(""); + expect(syncResult.rewrittenMarkers).toBe(1); + expect(vaultGateway.getFileContent(filePath)).toContain("", + ].join("\n"); + const vaultGateway = new FakeManualSyncVaultGateway({ + [filePath]: content, + [otherFilePath]: ["#### Other", "Body", ""] .join("\n"), + }); + const stateRepository = new InMemoryPluginStateRepository({ + files: { + [filePath]: createStoredFileState(settings, filePath, content, { noteIds: [41] }), + }, + cards: { + "41": createStoredSyncedCard(settings, { + noteId: 41, + filePath, + markerLine: 3, + }), + }, + groupBlocks: {}, + pendingWriteBack: [ + createPendingWriteBack(filePath, hashString(content), 41, { + blockStartLine: 1, + targetMarker: "", + markerKind: "card-id", + }), + createPendingWriteBack(filePath, hashString(content), 99, { + blockStartLine: 5, + targetMarker: "", + markerKind: "group-gi", + targetGroupId: "group-stale", + }), + createPendingWriteBack(otherFilePath, hashString(["#### Other", "Body"].join("\n")), 77, { + blockStartLine: 1, + targetMarker: "", + markerKind: "card-id", + }), + ], + }); + const ankiGateway = new FakeManualSyncAnkiGateway(); + const useCase = new ClearCurrentFileSyncedCardsUseCase(stateRepository, ankiGateway, vaultGateway); + + const result = await useCase.execute(filePath); + + expect(result).toMatchObject({ + trackedCards: 1, + trackedGroups: 0, + deletedNotes: 1, + removedMarkers: 1, + removedCardMarkers: 1, + removedGroupMarkers: 0, + deletedLocalRecords: 1, + }); + expect(stateRepository.savedState?.pendingWriteBack).toEqual([ + createPendingWriteBack(otherFilePath, hashString(["#### Other", "Body"].join("\n")), 77, { + blockStartLine: 1, + targetMarker: "", + markerKind: "card-id", + }), + ]); }); }); +function createSemanticQaSettings(): PluginSettings { + const base = createModule3Settings(); + + return { + ...base, + noteFieldMappings: { + ...base.noteFieldMappings, + [createNoteFieldMappingKey("semantic-qa", base.semanticQaNoteType)]: { + cardType: "semantic-qa", + modelName: base.semanticQaNoteType, + loadedFieldNames: ["Front", "Back"], + titleField: "Front", + bodyField: "Back", + loadedAt: 1, + }, + }, + }; +} + +function createStoredFileState( + settings: PluginSettings, + filePath: string, + content: string, + overrides: Partial = {}, +): FileState { + return { + filePath, + fileHash: overrides.fileHash ?? hashString(content), + fileStamp: overrides.fileStamp ?? `1:${content.length}`, + deckRulesFingerprint: overrides.deckRulesFingerprint ?? createDeckRulesFingerprint(settings), + lastIndexedAt: overrides.lastIndexedAt ?? 1, + noteIds: overrides.noteIds ?? [], + groupIds: overrides.groupIds, + }; +} + +function createStoredGroupBlockState(overrides: Partial = {}): GroupBlockState { + return { + groupId: overrides.groupId ?? "group-1", + noteId: overrides.noteId ?? 42, + filePath: overrides.filePath ?? "notes/example.md", + headingText: overrides.headingText ?? "Concepts #anki-list", + backlinkHeadingText: overrides.backlinkHeadingText ?? "Concepts #anki-list", + headingLevel: overrides.headingLevel ?? 4, + stem: overrides.stem ?? "Concepts", + src: overrides.src ?? buildGroupSrc(overrides.filePath ?? "notes/example.md", overrides.backlinkHeadingText ?? "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 ?? [ + { itemId: "item-a", title: "Alpha", answer: "First answer", slot: 1, ordinalInMarkdown: 1 }, + ], + freeSlots: overrides.freeSlots ?? [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + lastSyncedAt: overrides.lastSyncedAt ?? 1, + orphan: overrides.orphan ?? false, + }; +} + +function createPendingWriteBack( + filePath: string, + expectedFileHash: string, + targetNoteId: number, + overrides: Partial = {}, +): PendingWriteBackState { + return { + filePath, + blockStartLine: overrides.blockStartLine ?? 1, + expectedFileHash, + targetMarker: overrides.targetMarker ?? ``, + rawBlockHash: overrides.rawBlockHash ?? hashString(`${filePath}:${targetNoteId}:${overrides.blockStartLine ?? 1}`), + targetNoteId, + markerKind: overrides.markerKind, + targetGroupId: overrides.targetGroupId, + }; +} + function createStoredSyncedCard(settings: PluginSettings, overrides: Partial = {}): CardState { const heading = overrides.heading ?? "Prompt"; const bodyMarkdown = overrides.bodyMarkdown ?? "Answer"; diff --git a/src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.ts b/src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.ts index 5c5c827..0f11657 100644 --- a/src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.ts +++ b/src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.ts @@ -1,8 +1,8 @@ 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 { toNoteIdKey, type CardState, type PluginState } from "@/domain/manual-sync/entities/PluginState"; +import { MarkdownSyncedMarkerRemovalService } from "@/application/services/MarkdownSyncedMarkerRemovalService"; +import { toNoteIdKey, type CardState, type GroupBlockState, type PluginState } from "@/domain/manual-sync/entities/PluginState"; import type { ClearCurrentFileSyncedCardsResult } from "./cleanupResetTypes"; @@ -11,55 +11,65 @@ export class ClearCurrentFileSyncedCardsUseCase { private readonly pluginStateRepository: PluginStateRepository, private readonly ankiGateway: AnkiGateway, vaultGateway: ManualSyncVaultGateway, - private readonly markdownMarkerRemovalService = new MarkdownMarkerRemovalService(vaultGateway), + private readonly markdownMarkerRemovalService = new MarkdownSyncedMarkerRemovalService(vaultGateway), ) {} async hasTrackedCards(filePath: string): Promise { const state = await this.pluginStateRepository.load(); - return collectTrackedCards(state, filePath).length > 0; + const trackedEntries = collectTrackedEntries(state, filePath); + return trackedEntries.trackedCards.length > 0 || trackedEntries.trackedGroups.length > 0; } async execute(filePath: string): Promise { const state = await this.pluginStateRepository.load(); - const trackedCards = collectTrackedCards(state, filePath); + const trackedEntries = collectTrackedEntries(state, filePath); - if (trackedCards.length === 0) { - return { - trackedCards: 0, - deletedNotes: 0, - removedMarkers: 0, - deletedLocalRecords: 0, - conflictFiles: [], - failureFiles: [], - }; + if (trackedEntries.trackedCards.length === 0 && trackedEntries.trackedGroups.length === 0) { + return createEmptyResult(); } - const noteIds = Array.from(new Set(trackedCards.flatMap((card) => typeof card.noteId === "number" ? [card.noteId] : []))); + const noteIds = trackedEntries.noteIds; if (noteIds.length > 0) { await this.ankiGateway.deleteNotes(noteIds); } const markerRemovalResult = await this.markdownMarkerRemovalService.remove( filePath, - trackedCards.map((card) => ({ noteId: card.noteId })), + trackedEntries.trackedCards.map((card) => ({ noteId: card.noteId })), + trackedEntries.trackedGroups.map((group) => ({ + noteId: group.noteId, + groupId: group.groupId, + blockStartLine: group.blockStartLine, + })), ); - const nextState = buildNextState(state, filePath, trackedCards); + const nextState = buildNextState(state, filePath, trackedEntries); await this.pluginStateRepository.save(nextState); return { - trackedCards: trackedCards.length, + trackedCards: trackedEntries.trackedCards.length, + trackedGroups: trackedEntries.trackedGroups.length, deletedNotes: noteIds.length, removedMarkers: markerRemovalResult.removedMarkers, - deletedLocalRecords: trackedCards.length, + removedCardMarkers: markerRemovalResult.removedCardMarkers, + removedGroupMarkers: markerRemovalResult.removedGroupMarkers, + deletedLocalRecords: trackedEntries.trackedCards.length + trackedEntries.trackedGroups.length, conflictFiles: markerRemovalResult.conflictFiles, failureFiles: markerRemovalResult.failureFiles, }; } } -function collectTrackedCards(state: PluginState, filePath: string): CardState[] { +interface TrackedSyncedEntries { + trackedCards: CardState[]; + trackedGroups: GroupBlockState[]; + noteIds: number[]; +} + +function collectTrackedEntries(state: PluginState, filePath: string): TrackedSyncedEntries { const trackedNoteKeys = new Set(); + const trackedGroupIds = new Set(); + const groupBlocks = state.groupBlocks ?? {}; for (const noteId of state.files[filePath]?.noteIds ?? []) { const noteKey = toNoteIdKey(noteId); @@ -68,31 +78,75 @@ function collectTrackedCards(state: PluginState, filePath: string): CardState[] } } + for (const groupId of state.files[filePath]?.groupIds ?? []) { + if (groupBlocks[groupId]) { + trackedGroupIds.add(groupId); + } + } + for (const card of Object.values(state.cards)) { if (card.filePath === filePath) { trackedNoteKeys.add(toNoteIdKey(card.noteId)); } } - return Array.from(trackedNoteKeys) + for (const [groupId, groupBlock] of Object.entries(groupBlocks)) { + if (groupBlock.filePath === filePath) { + trackedGroupIds.add(groupId); + } + } + + const trackedCards = Array.from(trackedNoteKeys) .map((noteKey) => state.cards[noteKey]) .filter((card): card is CardState => Boolean(card)); + + const trackedGroups = Array.from(trackedGroupIds) + .map((groupId) => groupBlocks[groupId]) + .filter((group): group is GroupBlockState => Boolean(group)); + + return { + trackedCards, + trackedGroups, + noteIds: Array.from(new Set([ + ...trackedCards.map((card) => card.noteId), + ...trackedGroups.map((group) => group.noteId), + ])), + }; } -function buildNextState(state: PluginState, filePath: string, trackedCards: CardState[]): PluginState { - const trackedNoteKeys = new Set(trackedCards.map((card) => toNoteIdKey(card.noteId))); +function buildNextState(state: PluginState, filePath: string, trackedEntries: TrackedSyncedEntries): PluginState { const nextCards = { ...state.cards }; + const nextGroupBlocks = { ...(state.groupBlocks ?? {}) }; - for (const trackedCard of trackedCards) { + for (const trackedCard of trackedEntries.trackedCards) { delete nextCards[toNoteIdKey(trackedCard.noteId)]; } + for (const trackedGroup of trackedEntries.trackedGroups) { + delete nextGroupBlocks[trackedGroup.groupId]; + } + const nextFiles = { ...state.files }; delete nextFiles[filePath]; return { files: nextFiles, cards: nextCards, - pendingWriteBack: state.pendingWriteBack.filter((pending) => pending.filePath !== filePath && !trackedNoteKeys.has(toNoteIdKey(pending.targetNoteId))), + groupBlocks: state.groupBlocks ? nextGroupBlocks : undefined, + pendingWriteBack: state.pendingWriteBack.filter((pending) => pending.filePath !== filePath), + }; +} + +function createEmptyResult(): ClearCurrentFileSyncedCardsResult { + return { + trackedCards: 0, + trackedGroups: 0, + deletedNotes: 0, + removedMarkers: 0, + removedCardMarkers: 0, + removedGroupMarkers: 0, + deletedLocalRecords: 0, + conflictFiles: [], + failureFiles: [], }; } \ No newline at end of file diff --git a/src/application/use-cases/cleanupResetTypes.ts b/src/application/use-cases/cleanupResetTypes.ts index 27a1123..8a5e4e2 100644 --- a/src/application/use-cases/cleanupResetTypes.ts +++ b/src/application/use-cases/cleanupResetTypes.ts @@ -1,7 +1,10 @@ export interface ClearCurrentFileSyncedCardsResult { trackedCards: number; + trackedGroups: number; deletedNotes: number; removedMarkers: number; + removedCardMarkers: number; + removedGroupMarkers: number; deletedLocalRecords: number; conflictFiles: string[]; failureFiles: Array<{ filePath: string; message: string }>; diff --git a/src/presentation/notices/NoticeService.ts b/src/presentation/notices/NoticeService.ts index 38a8932..3880d1a 100644 --- a/src/presentation/notices/NoticeService.ts +++ b/src/presentation/notices/NoticeService.ts @@ -39,7 +39,7 @@ export class NoticeService { } 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 summary = `${prefix}: tracked cards ${result.trackedCards}, tracked groups ${result.trackedGroups}, deleted notes ${result.deletedNotes}, removed markers ${result.removedMarkers}, removed ID markers ${result.removedCardMarkers}, removed GI markers ${result.removedGroupMarkers}, deleted local records ${result.deletedLocalRecords}.`; const conflicts = result.conflictFiles.length > 0 ? ` Marker removal conflicts: ${result.conflictFiles.join(", ")}.` : "";