mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
fix: clear current-file synced cards for semantic QA and QA groups / 修复当前文件清空已同步卡片覆盖 semantic QA 与 QA Group
This commit is contained in:
parent
fbb289850a
commit
a3c540a493
8 changed files with 873 additions and 66 deletions
56
docs/clear-current-file-full-coverage-decisions.md
Normal file
56
docs/clear-current-file-full-coverage-decisions.md
Normal 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.
|
||||
70
docs/clear-current-file-full-coverage-gap-report.md
Normal file
70
docs/clear-current-file-full-coverage-gap-report.md
Normal 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.
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
143
src/application/services/MarkdownSyncedMarkerRemovalService.ts
Normal file
143
src/application/services/MarkdownSyncedMarkerRemovalService.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
}
|
||||
|
|
@ -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 }>;
|
||||
|
|
|
|||
|
|
@ -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(", ")}.`
|
||||
: "";
|
||||
|
|
|
|||
Loading…
Reference in a new issue