fix(sync): rebuild cached all-cloze cards

中文: 修复全库同步复用旧缓存时漏掉 #anki-cloze-all 模式,确保旧 sequential 状态会强制重读并触发重建。

English: Force unchanged vault-sync files to be re-read when cached cloze state missed the all-cloze marker, so sequential-to-all shrink rebuilds can run and clear stale empty cards.
This commit is contained in:
Dusk 2026-04-28 13:19:01 +08:00
parent 5c1bf809b2
commit 6f7636da55
3 changed files with 102 additions and 17 deletions

30
main.js

File diff suppressed because one or more lines are too long

View file

@ -76,6 +76,69 @@ describe("FileIndexerService", () => {
expect(result.cards[0]).toMatchObject({ noteId: 10, noteIdSource: "marker", idMarkerState: "present-valid" });
});
it("re-reads unchanged files when cached cloze state missed an all-cloze marker", async () => {
const content = [
"#### Cloze #anki-cloze-all",
"{A} {B} {C}",
"<!--ID: 10-->",
].join("\n");
const settings = createIndexSettingsForPath("notes/one.md");
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/one.md": content,
});
const service = new FileIndexerService(vaultGateway);
const state = {
files: {
"notes/one.md": {
filePath: "notes/one.md",
fileHash: "hash-a",
fileStamp: `1:${content.length}`,
deckRulesFingerprint: createDeckRulesFingerprint(settings),
lastIndexedAt: 1,
noteIds: [10],
},
},
cards: {
"10": {
noteId: 10,
filePath: "notes/one.md",
heading: "Cloze #anki-cloze-all",
backlinkHeadingText: "Cloze #anki-cloze-all",
headingLevel: 4,
bodyMarkdown: "{A} {B} {C}",
cardType: "cloze" as const,
clozeMode: "sequential" as const,
blockStartOffset: 0,
blockEndOffset: content.length,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 3,
contentEndLine: 2,
markerLine: 3,
rawBlockText: content,
rawBlockHash: "hash-card",
renderConfigHash: "render-hash",
deck: "Obsidian",
deckWarnings: [],
tagsHint: [],
lastSyncedAt: 1,
orphan: false,
},
},
pendingWriteBack: [],
};
const result = await service.indexVault(settings, state);
expect(result.skippedUnchangedFiles).toBe(0);
expect(vaultGateway.readCalls).toEqual(["notes/one.md"]);
expect(result.cards[0]).toMatchObject({
noteId: 10,
cardType: "cloze",
clozeMode: "all",
});
});
it("forces re-read when deck rules fingerprint changed even if file stamp is unchanged", async () => {
const content = ["#### One", "Body"].join("\n");
const vaultGateway = new FakeManualSyncVaultGateway({
@ -252,4 +315,4 @@ function createIndexSettingsForPath(filePath: string, overrides: Parameters<type
includeFolders: [filePath.slice(0, firstSlashIndex)],
...overrides,
});
}
}

View file

@ -96,12 +96,14 @@ export class FileIndexerService {
const hasPendingWriteBack = pendingWriteBack.length > 0;
const knownCards = stateIndex.cardsByFilePath.get(ref.path) ?? [];
const hasMissingKnownCard = (existingFileState?.noteIds ?? []).some((noteId) => !state.cards[toNoteIdKey(noteId)]);
const hasStaleClozeAllMode = hasCachedClozeAllMarkerWithoutAllMode(knownCards, settings);
const shouldRead =
forceReadAll ||
hasPendingWriteBack ||
!existingFileState ||
existingFileState.fileStamp !== fileStamp ||
existingFileState.deckRulesFingerprint !== deckRulesFingerprint ||
hasStaleClozeAllMode ||
hasMissingKnownCard;
if (!shouldRead) {
@ -233,6 +235,26 @@ function restoreIndexedCard(card: CardState): IndexedCard {
};
}
function hasCachedClozeAllMarkerWithoutAllMode(cards: CardState[], settings: PluginSettings): boolean {
const clozeAllConfig = settings.cardTypeConfigs["cloze-all"];
const marker = clozeAllConfig.enabled ? clozeAllConfig.extraMarker.trim() : "";
if (marker.length === 0) {
return false;
}
return cards.some((card) => {
if (card.cardType !== "cloze" || normalizeStoredClozeMode(card.cardType, card.clozeMode) === "all") {
return false;
}
return card.heading.trimEnd().endsWith(marker) || getRawHeading(card.rawBlockText).trimEnd().endsWith(marker);
});
}
function getRawHeading(rawBlockText: string): string {
return rawBlockText.split(/\r?\n/, 1)[0] ?? "";
}
function restoreIndexedGroupBlock(groupBlock: GroupBlockState): IndexedGroupCardBlock {
return {
noteId: groupBlock.noteId,
@ -283,4 +305,4 @@ export function createDeckRulesFingerprint(settings: PluginSettings): string {
syncObsidianTagsToAnki: settings.syncObsidianTagsToAnki,
keepPureTagLinesInCardBody: settings.keepPureTagLinesInCardBody,
}));
}
}