fix: clear current-file synced cards for semantic QA and QA groups / 修复当前文件清空已同步卡片覆盖 semantic QA 与 QA Group

This commit is contained in:
Dusk 2026-04-21 11:56:29 +08:00
parent fbb289850a
commit a3c540a493
8 changed files with 873 additions and 66 deletions

View file

@ -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 `<!--ID: ...-->`
- QA Group blocks written with `<!--GI: ...-->`
## 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.

View file

@ -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 `<!--ID: ...-->` markers by `noteId`.
- There is no application-layer remover that also consumes `GroupMarkerService` and strips `<!--GI: ...-->`.
### 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.

View file

@ -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",
"<!--ID: 41-->",
"",
"#### Concepts #anki-list",
"- Alpha",
" - First answer",
"<!--GI:n=42;i=item_a:1;f=2,3,4,5,6,7,8,9,10,11,12-->",
"",
"#### Next",
"Body",
"<!--ID: 99-->",
].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",
"<!--ID: 99-->",
].join("\n"));
});
it("reports conflicts without claiming removed markers", async () => {
const filePath = "notes/conflict.md";
const content = [
"#### Prompt",
"Answer",
"<!--ID: 41-->",
"<!--GI:n=42;i=item_a:1;f=2,3,4,5,6,7,8,9,10,11,12-->",
].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);
});
});

View file

@ -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<MarkdownSyncedMarkerRemovalResult> {
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<number>,
groupNoteIds: Set<number>,
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: [],
};
}

View file

@ -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",
" <!--ID: 41-->",
"- Beta",
" Second answer",
" <!--ID: 42-->",
].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<br>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<br>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("<!--ID: 9001-->");
expect(vaultGateway.getFileContent(filePath)).toContain("<!--ID: 9002-->");
expect(vaultGateway.getFileContent(filePath)).not.toContain("<!--ID: 41-->");
expect(vaultGateway.getFileContent(filePath)).not.toContain("<!--ID: 42-->");
});
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",
"<!--GI:n=42;i=item_a:1,item_b:2;f=3,4,5,6,7,8,9,10,11,12-->",
].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(/<!--GI:n=9001;i=[^;]+;f=3,4,5,6,7,8,9,10,11,12-->/);
expect(vaultGateway.getFileContent(filePath)).not.toContain("<!--GI:n=42;");
});
it("clears mixed card and group routes in one pass", async () => {
const settings = createModule3Settings();
const filePath = "notes/mixed.md";
const content = [
"#### Prompt",
"Answer",
"<!--ID: 42-->",
"<!--ID: 41-->",
"",
"#### Concepts #anki-list",
"- Alpha",
" - First answer",
"<!--GI:n=42;i=item_a:1;f=2,3,4,5,6,7,8,9,10,11,12-->",
].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",
"<!--GI:n=42;i=item_a:1;f=2,3,4,5,6,7,8,9,10,11,12-->",
].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: "<!--ID: 42-->",
rawBlockHash: hashString(["#### Prompt", "Answer"].join("\n")),
targetNoteId: 42,
},
targetMarker: "<!--GI:n=42;i=item_a:1;f=2,3,4,5,6,7,8,9,10,11,12-->",
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("<!--ID: 42-->");
expect(vaultGateway.getFileContent(filePath)).toContain("<!--GI:n=42;");
vaultGateway.conflictPaths.delete(filePath);
const manualSyncService = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 3000);
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(ankiGateway.addedNotes).toHaveLength(1);
expect(vaultGateway.getFileContent(filePath)).toContain("<!--ID: 9001-->");
expect(vaultGateway.getFileContent(filePath)).not.toContain("<!--ID: 42-->");
expect(syncResult.rewrittenMarkers).toBe(1);
expect(vaultGateway.getFileContent(filePath)).toContain("<!--GI:n=9001;");
expect(vaultGateway.getFileContent(filePath)).not.toContain("<!--GI:n=42;");
});
it("clears all pending write-back entries for the current file regardless of marker kind", async () => {
const settings = createModule3Settings();
const filePath = "notes/pending.md";
const otherFilePath = "notes/other.md";
const content = [
"#### Prompt",
"Answer",
"<!--ID: 41-->",
].join("\n");
const vaultGateway = new FakeManualSyncVaultGateway({
[filePath]: content,
[otherFilePath]: ["#### Other", "Body", "<!--ID: 77-->"] .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: "<!--ID: 41-->",
markerKind: "card-id",
}),
createPendingWriteBack(filePath, hashString(content), 99, {
blockStartLine: 5,
targetMarker: "<!--GI:n=99;i=item_a:1;f=2,3,4,5,6,7,8,9,10,11,12-->",
markerKind: "group-gi",
targetGroupId: "group-stale",
}),
createPendingWriteBack(otherFilePath, hashString(["#### Other", "Body"].join("\n")), 77, {
blockStartLine: 1,
targetMarker: "<!--ID: 77-->",
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: "<!--ID: 77-->",
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> = {},
): 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> = {}): 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> = {},
): PendingWriteBackState {
return {
filePath,
blockStartLine: overrides.blockStartLine ?? 1,
expectedFileHash,
targetMarker: overrides.targetMarker ?? `<!--ID: ${targetNoteId}-->`,
rawBlockHash: overrides.rawBlockHash ?? hashString(`${filePath}:${targetNoteId}:${overrides.blockStartLine ?? 1}`),
targetNoteId,
markerKind: overrides.markerKind,
targetGroupId: overrides.targetGroupId,
};
}
function createStoredSyncedCard(settings: PluginSettings, overrides: Partial<CardState> = {}): CardState {
const heading = overrides.heading ?? "Prompt";
const bodyMarkdown = overrides.bodyMarkdown ?? "Answer";

View file

@ -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<boolean> {
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<ClearCurrentFileSyncedCardsResult> {
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<string>();
const trackedGroupIds = new Set<string>();
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: [],
};
}

View file

@ -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 }>;

View file

@ -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(", ")}.`
: "";