mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
fix(sync): self-heal deck drift and normalize tag backlinks
中文: 补充 cardsInfo 读取与 deck 校验流程,让 marker 驱动的旧卡和本地状态漂移场景也能自愈迁移牌组;同时对标题末尾的 #标签 回跳做最小归一化,提升从 Anki 返回 Obsidian 标题定位的兼容性,并提交用于排查的查询脚本。 English: Add cardsInfo-backed deck verification so marker-backed existing notes and deck drift can self-heal during sync. Also normalize trailing heading tags in Obsidian backlinks to improve heading jump compatibility from Anki, and include the helper query scripts used for investigation.
This commit is contained in:
parent
e2f973dc46
commit
f313f702ac
14 changed files with 365 additions and 20 deletions
39
query_note.js
Normal file
39
query_note.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
})();
|
||||
57
query_notes.js
Normal file
57
query_notes.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
})();
|
||||
|
|
@ -18,6 +18,7 @@ export interface AnkiNoteSummary {
|
|||
noteId: number;
|
||||
modelName: string;
|
||||
cardIds: number[];
|
||||
deckNames?: string[];
|
||||
}
|
||||
|
||||
export interface ChangeDeckInput {
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<AnkiGateway["getNoteSummaries"]>>[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);
|
||||
}
|
||||
|
|
@ -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", "<!--ID: 42-->"].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", "<!--ID: 42-->"].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" });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export class DiffPlannerService {
|
|||
const seenNoteKeys = new Set<string>();
|
||||
const toCreate: PlannedCard[] = [];
|
||||
const toUpdate: PlannedCard[] = [];
|
||||
const toVerifyDeck: PlannedCard[] = [];
|
||||
const toChangeDeck: PlannedCard[] = [];
|
||||
const toRewriteMarker: PlannedCard[] = [];
|
||||
const warningMap = new Map<string, DeckResolutionWarning>();
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export interface PlannedCard {
|
|||
export interface ManualSyncPlan {
|
||||
toCreate: PlannedCard[];
|
||||
toUpdate: PlannedCard[];
|
||||
toVerifyDeck: PlannedCard[];
|
||||
toChangeDeck: PlannedCard[];
|
||||
toRewriteMarker: PlannedCard[];
|
||||
toOrphan: CardState[];
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<number, string>();
|
||||
|
||||
if (allCardIds.length > 0) {
|
||||
const cardInfo = await this.invoke<Array<CardInfo | null>>("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"))),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,4 +37,71 @@ describe("ObsidianVaultGateway", () => {
|
|||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHeadingForBacklink(headingText: string): string {
|
||||
return headingText.replace(/(?:\s+#\S+)+$/g, (trailingTags) => trailingTags.replace(/(^|\s)#(\S+)/g, "$1$2"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ export class FakeManualSyncAnkiGateway implements AnkiGateway {
|
|||
async getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]> {
|
||||
return noteIds.flatMap((noteId) => {
|
||||
const summary = this.noteSummariesById.get(noteId);
|
||||
return summary ? [summary] : [];
|
||||
return summary ? [{ ...summary, deckNames: summary.deckNames ? [...summary.deckNames] : undefined }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue