diff --git a/packages/obsidian/src/library/CandidatePickerModal.ts b/packages/obsidian/src/library/CandidatePickerModal.ts index 0fb5f6f..c9ea9a8 100644 --- a/packages/obsidian/src/library/CandidatePickerModal.ts +++ b/packages/obsidian/src/library/CandidatePickerModal.ts @@ -3,20 +3,22 @@ import { FuzzySuggestModal } from 'obsidian'; import type { ResolveCandidate } from 'packages/obsidian/src/library/types'; const SKIP_LABEL = 'Skip'; +const NEVER_LABEL = 'Never resolve (mark no_resolve)'; interface PickerItem { label: string; - index: number | null; // null = Skip + index: number | 'never' | null; // null = Skip, 'never' = mark no_resolve } /** * Fuzzy picker for an ambiguous resolve() result. `pick()` resolves to the chosen candidate's - * index, or null when the user picks "Skip" / dismisses the modal (Esc, click-outside) without - * choosing -- both are treated identically by the caller (LibraryController.resolveType). + * index, 'never' when the user picks "Never resolve (mark no_resolve)", or null when the user + * picks "Skip" / dismisses the modal (Esc, click-outside) without choosing -- null is treated as + * a plain skip by the caller (LibraryController.resolveType). */ export class CandidatePickerModal extends FuzzySuggestModal { private settled = false; - private resolveFn?: (index: number | null) => void; + private resolveFn?: (index: number | 'never' | null) => void; constructor( app: App, @@ -29,7 +31,7 @@ export class CandidatePickerModal extends FuzzySuggestModal { } getItems(): PickerItem[] { - return [...this.candidates.map((c, index) => ({ label: c.label, index })), { label: SKIP_LABEL, index: null }]; + return [...this.candidates.map((c, index) => ({ label: c.label, index })), { label: SKIP_LABEL, index: null }, { label: NEVER_LABEL, index: 'never' as const }]; } getItemText(item: PickerItem): string { @@ -45,13 +47,13 @@ export class CandidatePickerModal extends FuzzySuggestModal { this.contentEl.empty(); } - private settle(index: number | null): void { + private settle(index: number | 'never' | null): void { if (this.settled) return; this.settled = true; this.resolveFn?.(index); } - pick(): Promise { + pick(): Promise { return new Promise(resolve => { this.resolveFn = resolve; this.open(); diff --git a/packages/obsidian/src/library/LibraryController.ts b/packages/obsidian/src/library/LibraryController.ts index 4f30f42..760391c 100644 --- a/packages/obsidian/src/library/LibraryController.ts +++ b/packages/obsidian/src/library/LibraryController.ts @@ -208,7 +208,7 @@ export class LibraryController { } /** Opens the real candidate picker modal; tests override this method directly to avoid needing a real Obsidian modal. */ - private async pickCandidate(filename: string, candidates: ResolveCandidate[]): Promise { + private async pickCandidate(filename: string, candidates: ResolveCandidate[]): Promise { const modal = new CandidatePickerModal(this.plugin.app, filename, candidates); return await modal.pick(); } @@ -225,10 +225,17 @@ export class LibraryController { let picked = 0; let skipped = 0; + let marked = 0; if (!dryRun) { for (const entry of report.needsChoice) { try { const idx = await this.pickCandidate(entry.filename, entry.candidates); + if (idx === 'never') { + const content = await deps.readNote(entry.path); + await deps.writeNote(entry.path, patchFrontmatter(content, { no_resolve: 'true' }, { defaultType: spec.itemType })); + marked++; + continue; + } const candidate = idx != null ? entry.candidates[idx] : undefined; if (!candidate) { skipped++; @@ -248,6 +255,7 @@ export class LibraryController { const mode = dryRun ? 'DRY-RUN ' : ''; let msg = `Library ${mode}resolve (${spec.typeName}): ${report.resolved.length} resolved, ${report.ambiguous.length} ambiguous/no match`; if (report.needsChoice.length) msg += `, ${picked} picked, ${skipped} skipped`; + if (marked) msg += `, ${marked} marked no-resolve`; if (report.errors.length) msg += `, ${report.errors.length} errors (see console)`; this.notify(msg, 0); return report; diff --git a/tests/library-candidate-picker-modal.test.ts b/tests/library-candidate-picker-modal.test.ts index dc95b24..2546bf7 100644 --- a/tests/library-candidate-picker-modal.test.ts +++ b/tests/library-candidate-picker-modal.test.ts @@ -11,13 +11,14 @@ function fakeApp(): any { } describe('CandidatePickerModal', () => { - test('getItems: candidate labels followed by a trailing Skip item (null index)', () => { + test('getItems: candidate labels followed by Skip then Never resolve', () => { const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES); const items = modal.getItems(); expect(items).toEqual([ { label: 'Foo (2020)', index: 0 }, { label: 'Bar (2021)', index: 1 }, { label: 'Skip', index: null }, + { label: 'Never resolve (mark no_resolve)', index: 'never' }, ]); }); @@ -40,6 +41,13 @@ describe('CandidatePickerModal', () => { expect(await result).toBeNull(); }); + test('onChooseItem(Never resolve) -> pick() resolves to \'never\'', async () => { + const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES); + const result = modal.pick(); + modal.onChooseItem({ label: 'Never resolve (mark no_resolve)', index: 'never' }, {} as MouseEvent); + expect(await result).toBe('never'); + }); + test('onClose without a prior choice (Esc / dismiss) -> pick() resolves to null', async () => { const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES); const result = modal.pick(); diff --git a/tests/library-controller.test.ts b/tests/library-controller.test.ts index 8ca614b..dba23fe 100644 --- a/tests/library-controller.test.ts +++ b/tests/library-controller.test.ts @@ -587,6 +587,24 @@ describe('resolveType: interactive candidate picker (needsChoice)', () => { expect(notices[0]).toContain('1 picked, 1 skipped'); }); + test('pickCandidate fake returns \'never\' -> no_resolve patched via patchFrontmatter, not resolved, counted marked', async () => { + const c = new LibraryController(fakePlugin()); + const notices: string[] = []; + (c as any).notify = (msg: string) => notices.push(msg); + const writes: { path: string; content: string }[] = []; + (c as any).makeDeps = () => makeCandidateDeps(writes); + (c as any).pickCandidate = async () => 'never'; + + const report = await c.resolveType(candidateSpec); + + expect(report.resolved).toEqual([]); + expect(writes.length).toBe(1); + expect(writes[0].path).toBe('Mangas/Foo.md'); + expect(writes[0].content).toContain('no_resolve: true'); + expect(notices[0]).toContain('0 picked, 0 skipped'); + expect(notices[0]).toContain('1 marked no-resolve'); + }); + test('no needsChoice entries -> summary omits the picked/skipped clause entirely', async () => { const c = new LibraryController(fakePlugin()); const notices: string[] = [];