diff --git a/query_note.js b/query_note.js new file mode 100644 index 0000000..1aa6614 --- /dev/null +++ b/query_note.js @@ -0,0 +1,39 @@ +const http = require('http'); + +const invoke = (action, params = {}) => new Promise((resolve, reject) => { + const body = JSON.stringify({ action, version: 6, params }); + const req = http.request('http://127.0.0.1:8765', {method: 'POST'}, res => { + let d = ''; res.on('data', c => d += c); + res.on('end', () => { + try { + const r = JSON.parse(d); + if (r.error) reject(new Error(r.error)); + else resolve(r.result); + } catch (e) { reject(e); } + }); + }); + req.on('error', reject); + req.write(body); req.end(); +}); + +(async () => { + try { + const notes = await invoke('notesInfo', { notes: [1755676734055] }); + if (!notes || notes.length === 0) { + console.log('No note found for ID 1755676734055'); + return; + } + const note = notes[0]; + const front = note.fields.正面 ? note.fields.正面.value : ''; + const back = note.fields.背面 ? note.fields.背面.value : ''; + + console.log('--- Front (First 300) ---'); + console.log(front.substring(0, 300)); + console.log('\n--- Back (First 500) ---'); + console.log(back.substring(0, 500)); + console.log('\n--- Check String ---'); + console.log('Contains "Open in Obsidian":', back.includes('Open in Obsidian')); + } catch (e) { + console.error('Error:', e.message); + } +})(); diff --git a/query_notes.js b/query_notes.js new file mode 100644 index 0000000..7aa1209 --- /dev/null +++ b/query_notes.js @@ -0,0 +1,57 @@ +const http = require('http'); + +const invoke = (action, params = {}) => new Promise((resolve, reject) => { + const body = JSON.stringify({ action, version: 6, params }); + const req = http.request('http://127.0.0.1:8765', { method: 'POST' }, res => { + let d = ''; + res.on('data', c => d += c); + res.on('end', () => { + try { + const r = JSON.parse(d); + if (r.error) reject(new Error(r.error)); + else resolve(r.result); + } catch (e) { + reject(e); + } + }); + }); + req.on('error', reject); + req.write(body); + req.end(); +}); + +(async () => { + try { + const noteIds = [1755676734055, 1776577443442]; + const notesInfo = await invoke('notesInfo', { notes: noteIds }); + + for (let i = 0; i < noteIds.length; i++) { + const noteId = noteIds[i]; + const info = notesInfo[i]; + + console.log(`--- Note ID: ${noteId} ---`); + if (!info || Object.keys(info).length === 0) { + console.log('Exists: No'); + continue; + } + + console.log('Exists: Yes'); + console.log('Model Name:', info.modelName); + + const cards = await invoke('cardsInfo', { cards: info.cards }); + if (cards && cards.length > 0) { + console.log('Deck Name:', cards[0].deckName); + console.log('Card IDs:', info.cards.join(', ')); + } + + const front = info.fields.Front?.value || info.fields['正面']?.value || 'N/A'; + const back = info.fields.Back?.value || info.fields['背面']?.value || 'N/A'; + + console.log('Front (First 200 chars):', front.substring(0, 200)); + console.log('Back (First 200 chars):', back.substring(0, 200)); + console.log('\n'); + } + } catch (e) { + console.error('Error:', e.message); + } +})(); diff --git a/src/application/ports/AnkiGateway.ts b/src/application/ports/AnkiGateway.ts index c8fdfbb..ab5940c 100644 --- a/src/application/ports/AnkiGateway.ts +++ b/src/application/ports/AnkiGateway.ts @@ -18,6 +18,7 @@ export interface AnkiNoteSummary { noteId: number; modelName: string; cardIds: number[]; + deckNames?: string[]; } export interface ChangeDeckInput { diff --git a/src/application/services/AnkiBatchExecutor.test.ts b/src/application/services/AnkiBatchExecutor.test.ts index 8a4e457..26a7329 100644 --- a/src/application/services/AnkiBatchExecutor.test.ts +++ b/src/application/services/AnkiBatchExecutor.test.ts @@ -38,6 +38,7 @@ describe("AnkiBatchExecutor", () => { const plan: ManualSyncPlan = { toCreate: [createOne, createTwo], toUpdate: [updateOne], + toVerifyDeck: [updateOne], toChangeDeck: [], toRewriteMarker: [], toOrphan: [], @@ -79,6 +80,7 @@ describe("AnkiBatchExecutor", () => { { toCreate: [createCard], toUpdate: [updateCard], + toVerifyDeck: [updateCard, changeDeckCard], toChangeDeck: [changeDeckCard], toRewriteMarker: [], toOrphan: [], diff --git a/src/application/services/AnkiBatchExecutor.ts b/src/application/services/AnkiBatchExecutor.ts index 2884b62..dc2d267 100644 --- a/src/application/services/AnkiBatchExecutor.ts +++ b/src/application/services/AnkiBatchExecutor.ts @@ -39,6 +39,7 @@ export class AnkiBatchExecutor { const summaryIds = Array.from(new Set([ ...plan.toUpdate, + ...plan.toVerifyDeck, ...plan.toRewriteMarker, ...plan.toChangeDeck, ].flatMap((plannedCard) => plannedCard.noteId ? [plannedCard.noteId] : []))); @@ -48,6 +49,7 @@ export class AnkiBatchExecutor { const addQueue: RenderedSyncCard[] = []; const updateQueue: Array<{ plannedCard: PlannedCard; renderedCard: RenderedSyncCard }> = []; const changeDeckQueue: PlannedCard[] = []; + const explicitDeckChangeSyncKeys = new Set(plan.toChangeDeck.map((plannedCard) => plannedCard.card.syncKey)); for (const plannedCard of plan.toCreate) { addQueue.push(await this.requireRenderedCard(plannedCard, renderedCards, renderOnDemand)); @@ -78,8 +80,17 @@ export class AnkiBatchExecutor { markerWriteMap.set(plannedCard.card.syncKey, plannedCard); } - for (const plannedCard of plan.toChangeDeck) { - if (!plannedCard.noteId || !noteSummariesById.has(plannedCard.noteId)) { + for (const plannedCard of plan.toVerifyDeck) { + if (!plannedCard.noteId) { + continue; + } + + const summary = noteSummariesById.get(plannedCard.noteId); + if (!summary) { + continue; + } + + if (!shouldChangeDeck(summary, plannedCard.deck, explicitDeckChangeSyncKeys.has(plannedCard.card.syncKey))) { continue; } @@ -257,4 +268,13 @@ export class AnkiBatchExecutor { await this.batchScheduler.runVoidBatches(Array.from(uniqueMedia.values()), 10, 3, (batch) => this.ankiGateway.storeMediaFiles(batch)); return uniqueMedia.size; } +} + +function shouldChangeDeck(summary: Awaited>[number], targetDeck: string, explicitlyPlanned: boolean): boolean { + const deckNames = summary.deckNames?.filter((deckName) => deckName.length > 0) ?? []; + if (deckNames.length === 0) { + return explicitlyPlanned; + } + + return deckNames.some((deckName) => deckName !== targetDeck); } \ No newline at end of file diff --git a/src/application/services/ManualSyncService.test.ts b/src/application/services/ManualSyncService.test.ts index 437fbc0..5e6b8f0 100644 --- a/src/application/services/ManualSyncService.test.ts +++ b/src/application/services/ManualSyncService.test.ts @@ -122,6 +122,7 @@ describe("ManualSyncService", () => { noteId: 42, modelName: "Basic", cardIds: [7001], + deckNames: ["[[anki背诵]]::[[城市更新,运营类,anki]]"], }); const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234); @@ -243,6 +244,7 @@ describe("ManualSyncService", () => { noteId: 42, modelName: "Basic", cardIds: [7001], + deckNames: ["[[anki背诵]]::[[城市更新,运营类,anki]]"], }); const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234); @@ -254,6 +256,79 @@ describe("ManualSyncService", () => { expect(ankiGateway.changedDecks).toEqual([{ deckName: "New::Deck", cardIds: [7001] }]); }); + it("aligns deck for a marker-backed existing note even without prior local state", async () => { + const settings = createModule3Settings({ folderDeckMode: "folder-and-file", defaultDeck: "Obsidian1" }); + const filePath = "999,试验卡片/城市更新,运营类,anki.md"; + const vaultGateway = new FakeManualSyncVaultGateway({ + [filePath]: ["#### 城市更新,百人会的核心产品", "Answer", ""].join("\n"), + }); + const stateRepository = new InMemoryPluginStateRepository(); + const ankiGateway = new FakeManualSyncAnkiGateway(); + ankiGateway.noteSummariesById.set(42, { + noteId: 42, + modelName: "Basic", + cardIds: [7001], + deckNames: ["[[anki背诵]]::[[城市更新,运营类,anki]]"], + }); + const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234); + + const result = await service.syncFile(filePath, settings); + + expect(result.updated).toBe(1); + expect(result.migratedDecks).toBe(1); + expect(ankiGateway.changedDecks).toEqual([{ deckName: "999,试验卡片::城市更新,运营类,anki", cardIds: [7001] }]); + expect(stateRepository.savedState?.cards["42"]?.deck).toBe("999,试验卡片::城市更新,运营类,anki"); + }); + + it("self-heals deck drift when local state already claims the target deck", async () => { + const settings = createModule3Settings({ folderDeckMode: "folder-and-file", defaultDeck: "Obsidian1" }); + const filePath = "999,试验卡片/城市更新,运营类,anki.md"; + const content = ["#### 城市更新,百人会的核心产品", "Answer", ""].join("\n"); + const vaultGateway = new FakeManualSyncVaultGateway({ + [filePath]: content, + }); + const targetDeck = "999,试验卡片::城市更新,运营类,anki"; + const storedCard = createStoredSyncedCard(settings, { + filePath, + noteId: 42, + heading: "城市更新,百人会的核心产品", + bodyMarkdown: "Answer", + rawBlockText: ["#### 城市更新,百人会的核心产品", "Answer"].join("\n"), + rawBlockHash: hashString(["#### 城市更新,百人会的核心产品", "Answer"].join("\n")), + deck: targetDeck, + }); + const stateRepository = new InMemoryPluginStateRepository({ + files: { + [filePath]: { + filePath, + fileHash: hashString(content), + fileStamp: `1:${content.length}`, + deckRulesFingerprint: createDeckRulesFingerprint(settings), + lastIndexedAt: 1, + noteIds: [42], + }, + }, + cards: { + "42": storedCard, + }, + pendingWriteBack: [], + }); + const ankiGateway = new FakeManualSyncAnkiGateway(); + ankiGateway.noteSummariesById.set(42, { + noteId: 42, + modelName: "Basic", + cardIds: [7001], + deckNames: ["[[anki背诵]]::[[城市更新,运营类,anki]]"], + } as never); + const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234); + + const result = await service.syncFile(filePath, settings); + + expect(result.updated).toBe(0); + expect(result.migratedDecks).toBe(1); + expect(ankiGateway.changedDecks).toEqual([{ deckName: targetDeck, cardIds: [7001] }]); + }); + it("ordinary sync re-evaluates unchanged files when folder deck rules changed and migrates old notes", async () => { const oldSettings = createModule3Settings({ folderDeckMode: "off", defaultDeck: "Default::Deck" }); const newSettings = createModule3Settings({ folderDeckMode: "folder", defaultDeck: "Default::Deck" }); diff --git a/src/domain/card/services/CardRenderingService.test.ts b/src/domain/card/services/CardRenderingService.test.ts index 04f3662..64b2519 100644 --- a/src/domain/card/services/CardRenderingService.test.ts +++ b/src/domain/card/services/CardRenderingService.test.ts @@ -82,6 +82,37 @@ describe("CardRenderingService", () => { expect(card.contentHash).toMatch(/^[0-9a-f]{8}$/); }); + it("keeps the raw heading text in the rendered title when the heading ends with a tag", () => { + const service = new CardRenderingService(); + const card = service.render( + createDraft({ + source: { + filePath: "notes/example.md", + headingLine: 3, + blockStartLine: 3, + bodyStartLine: 4, + blockEndLine: 8, + contentEndLine: 7, + headingLevel: 4, + headingText: "什么的会人? #3地区", + }, + heading: "什么的会人? #3地区", + }), + { + defaultDeck: "Default", + qaNoteType: "Basic", + clozeNoteType: "Cloze", + addObsidianBacklink: true, + convertHighlightsToCloze: true, + resourceResolver: resolver, + }, + ); + + expect(card.renderedFields.title).toContain("什么的会人? #3地区"); + expect(card.fields.title).toContain("什么的会人? #3地区"); + expect(card.fields.body).toContain("Open in Obsidian"); + }); + it("renders a cloze card as title/body fragments before mapping", () => { const service = new CardRenderingService(); const card = service.render( @@ -160,4 +191,4 @@ describe("CardRenderingService", () => { expect(card.fields.body).toContain("[sound:clip.mp3]"); expect(card.media).toHaveLength(2); }); -}); \ No newline at end of file +}); diff --git a/src/domain/manual-sync/services/DiffPlannerService.ts b/src/domain/manual-sync/services/DiffPlannerService.ts index b729c4e..35a796b 100644 --- a/src/domain/manual-sync/services/DiffPlannerService.ts +++ b/src/domain/manual-sync/services/DiffPlannerService.ts @@ -14,6 +14,7 @@ export class DiffPlannerService { const seenNoteKeys = new Set(); const toCreate: PlannedCard[] = []; const toUpdate: PlannedCard[] = []; + const toVerifyDeck: PlannedCard[] = []; const toChangeDeck: PlannedCard[] = []; const toRewriteMarker: PlannedCard[] = []; const warningMap = new Map(); @@ -57,6 +58,8 @@ export class DiffPlannerService { continue; } + toVerifyDeck.push(plannedCard); + if (card.idMarkerState !== "present-valid" || card.noteIdSource !== "marker" || pendingByBlockKey.has(blockKey)) { toRewriteMarker.push(plannedCard); } @@ -91,6 +94,7 @@ export class DiffPlannerService { return { toCreate, toUpdate, + toVerifyDeck, toChangeDeck, toRewriteMarker, toOrphan, diff --git a/src/domain/manual-sync/value-objects/ManualSyncPlan.ts b/src/domain/manual-sync/value-objects/ManualSyncPlan.ts index 83ce3bd..8bd9c22 100644 --- a/src/domain/manual-sync/value-objects/ManualSyncPlan.ts +++ b/src/domain/manual-sync/value-objects/ManualSyncPlan.ts @@ -13,6 +13,7 @@ export interface PlannedCard { export interface ManualSyncPlan { toCreate: PlannedCard[]; toUpdate: PlannedCard[]; + toVerifyDeck: PlannedCard[]; toChangeDeck: PlannedCard[]; toRewriteMarker: PlannedCard[]; toOrphan: CardState[]; diff --git a/src/infrastructure/anki/AnkiConnectGateway.test.ts b/src/infrastructure/anki/AnkiConnectGateway.test.ts index 2b25cd8..32ea3db 100644 --- a/src/infrastructure/anki/AnkiConnectGateway.test.ts +++ b/src/infrastructure/anki/AnkiConnectGateway.test.ts @@ -84,23 +84,33 @@ describe("AnkiConnectGateway", () => { }); it("returns note existence and model summaries", async () => { - requestUrlMock.mockResolvedValue({ - json: { - error: null, - result: [ - { noteId: 100, modelName: "Basic", cards: [1] }, - null, - { noteId: 102, modelName: "Cloze", cards: [2] }, - ], - }, - }); + requestUrlMock + .mockResolvedValueOnce({ + json: { + error: null, + result: [ + { noteId: 100, modelName: "Basic", cards: [1] }, + null, + { noteId: 102, modelName: "Cloze", cards: [2] }, + ], + }, + }) + .mockResolvedValueOnce({ + json: { + error: null, + result: [ + { cardId: 1, deckName: "Deck::One" }, + { cardId: 2, deckName: "Deck::Two" }, + ], + }, + }); const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765"); const summaries = await gateway.getNoteSummaries([100, 101, 102]); expect(summaries).toEqual([ - { noteId: 100, modelName: "Basic", cardIds: [1] }, - { noteId: 102, modelName: "Cloze", cardIds: [2] }, + { noteId: 100, modelName: "Basic", cardIds: [1], deckNames: ["Deck::One"] }, + { noteId: 102, modelName: "Cloze", cardIds: [2], deckNames: ["Deck::Two"] }, ]); expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ action: "notesInfo", @@ -109,6 +119,13 @@ describe("AnkiConnectGateway", () => { notes: [100, 101, 102], }, }); + expect(JSON.parse(requestUrlMock.mock.calls[1][0].body)).toEqual({ + action: "cardsInfo", + version: 6, + params: { + cards: [1, 2], + }, + }); }); it("maps deck stats into empty-deck detection shape", async () => { diff --git a/src/infrastructure/anki/AnkiConnectGateway.ts b/src/infrastructure/anki/AnkiConnectGateway.ts index 07eb904..747a018 100644 --- a/src/infrastructure/anki/AnkiConnectGateway.ts +++ b/src/infrastructure/anki/AnkiConnectGateway.ts @@ -15,6 +15,11 @@ interface NoteInfo { noteId?: number; } +interface CardInfo { + cardId?: number; + deckName?: string; +} + interface RawDeckStats { deck_id?: number; name?: string; @@ -89,15 +94,37 @@ export class AnkiConnectGateway implements AnkiGateway { notes: noteIds, }); + const allCardIds = noteInfo.flatMap((entry) => Array.isArray(entry?.cards) ? entry.cards : []); + const cardDeckNamesByCardId = new Map(); + + if (allCardIds.length > 0) { + const cardInfo = await this.invoke>("cardsInfo", { + cards: allCardIds, + }); + + for (const entry of cardInfo) { + if (!entry || typeof entry.cardId !== "number" || typeof entry.deckName !== "string") { + continue; + } + + cardDeckNamesByCardId.set(entry.cardId, entry.deckName); + } + } + return noteInfo.flatMap((entry) => { if (!entry || typeof entry.noteId !== "number" || typeof entry.modelName !== "string") { return []; } + const cardIds = Array.isArray(entry.cards) ? entry.cards : []; + return [{ noteId: entry.noteId, modelName: entry.modelName, - cardIds: Array.isArray(entry.cards) ? entry.cards : [], + cardIds, + deckNames: Array.from(new Set(cardIds + .map((cardId) => cardDeckNamesByCardId.get(cardId)) + .filter((deckName): deckName is string => typeof deckName === "string"))), }]; }); } diff --git a/src/infrastructure/obsidian/ObsidianVaultGateway.test.ts b/src/infrastructure/obsidian/ObsidianVaultGateway.test.ts index 163d99f..66b40f6 100644 --- a/src/infrastructure/obsidian/ObsidianVaultGateway.test.ts +++ b/src/infrastructure/obsidian/ObsidianVaultGateway.test.ts @@ -37,4 +37,71 @@ describe("ObsidianVaultGateway", () => { }, ]); }); -}); \ No newline at end of file + + it("normalizes trailing heading tags when creating backlinks", () => { + const gateway = new ObsidianVaultGateway({ + vault: { + getName: () => "Vault", + }, + } as never); + + expect( + gateway.createBacklink({ + filePath: "notes/example.md", + headingLine: 1, + blockStartLine: 1, + bodyStartLine: 2, + blockEndLine: 3, + contentEndLine: 2, + headingLevel: 4, + headingText: "什么的会人? #3地区", + }), + ).toBe("obsidian://open?vault=Vault&file=notes%2Fexample.md%23%E4%BB%80%E4%B9%88%E7%9A%84%E4%BC%9A%E4%BA%BA%EF%BC%9F%203%E5%9C%B0%E5%8C%BA"); + }); + + it("normalizes contiguous trailing heading tags but leaves inline hashes unchanged", () => { + const gateway = new ObsidianVaultGateway({ + vault: { + getName: () => "Vault", + }, + } as never); + + expect( + gateway.createBacklink({ + filePath: "notes/example.md", + headingLine: 1, + blockStartLine: 1, + bodyStartLine: 2, + blockEndLine: 3, + contentEndLine: 2, + headingLevel: 4, + headingText: "什么#会人? #3地区 #填空题", + }), + ).toBe( + "obsidian://open?vault=Vault&file=notes%2Fexample.md%23%E4%BB%80%E4%B9%88%23%E4%BC%9A%E4%BA%BA%EF%BC%9F%203%E5%9C%B0%E5%8C%BA%20%E5%A1%AB%E7%A9%BA%E9%A2%98", + ); + }); + + it("leaves headings without trailing tags unchanged when creating backlinks", () => { + const gateway = new ObsidianVaultGateway({ + vault: { + getName: () => "Vault", + }, + } as never); + + expect( + gateway.createBacklink({ + filePath: "notes/example.md", + headingLine: 1, + blockStartLine: 1, + bodyStartLine: 2, + blockEndLine: 3, + contentEndLine: 2, + headingLevel: 4, + headingText: "佬拓[[卡片试验]]", + }), + ).toBe( + "obsidian://open?vault=Vault&file=notes%2Fexample.md%23%E4%BD%AC%E6%8B%93%5B%5B%E5%8D%A1%E7%89%87%E8%AF%95%E9%AA%8C%5D%5D", + ); + }); +}); diff --git a/src/infrastructure/obsidian/ObsidianVaultGateway.ts b/src/infrastructure/obsidian/ObsidianVaultGateway.ts index f7fb7c8..98f9540 100644 --- a/src/infrastructure/obsidian/ObsidianVaultGateway.ts +++ b/src/infrastructure/obsidian/ObsidianVaultGateway.ts @@ -106,7 +106,7 @@ export class ObsidianVaultGateway implements VaultGateway, ManualSyncVaultGatewa } createBacklink(location: SourceLocation): string { - return this.createObsidianUrl(`${location.filePath}#${location.headingText}`); + return this.createObsidianUrl(`${location.filePath}#${normalizeHeadingForBacklink(location.headingText)}`); } private async toSourceFile(file: TFile) { @@ -157,4 +157,8 @@ function getFullPath(app: App, vaultPath: string): string { } return adapter.getFullPath(vaultPath); -} \ No newline at end of file +} + +function normalizeHeadingForBacklink(headingText: string): string { + return headingText.replace(/(?:\s+#\S+)+$/g, (trailingTags) => trailingTags.replace(/(^|\s)#(\S+)/g, "$1$2")); +} diff --git a/src/test-support/manualSyncFakes.ts b/src/test-support/manualSyncFakes.ts index 107e186..ab2c5ed 100644 --- a/src/test-support/manualSyncFakes.ts +++ b/src/test-support/manualSyncFakes.ts @@ -179,7 +179,7 @@ export class FakeManualSyncAnkiGateway implements AnkiGateway { async getNoteSummaries(noteIds: number[]): Promise { return noteIds.flatMap((noteId) => { const summary = this.noteSummariesById.get(noteId); - return summary ? [summary] : []; + return summary ? [{ ...summary, deckNames: summary.deckNames ? [...summary.deckNames] : undefined }] : []; }); }