feat(library): picker option to mark notes no_resolve

This commit is contained in:
afiqzudinhadi 2026-08-05 15:47:13 +08:00
parent 2686c3fa64
commit 16b8b242b8
4 changed files with 45 additions and 9 deletions

View file

@ -3,20 +3,22 @@ import { FuzzySuggestModal } from 'obsidian';
import type { ResolveCandidate } from 'packages/obsidian/src/library/types'; import type { ResolveCandidate } from 'packages/obsidian/src/library/types';
const SKIP_LABEL = 'Skip'; const SKIP_LABEL = 'Skip';
const NEVER_LABEL = 'Never resolve (mark no_resolve)';
interface PickerItem { interface PickerItem {
label: string; 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 * 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 * index, 'never' when the user picks "Never resolve (mark no_resolve)", or null when the user
* choosing -- both are treated identically by the caller (LibraryController.resolveType). * 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<PickerItem> { export class CandidatePickerModal extends FuzzySuggestModal<PickerItem> {
private settled = false; private settled = false;
private resolveFn?: (index: number | null) => void; private resolveFn?: (index: number | 'never' | null) => void;
constructor( constructor(
app: App, app: App,
@ -29,7 +31,7 @@ export class CandidatePickerModal extends FuzzySuggestModal<PickerItem> {
} }
getItems(): PickerItem[] { 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 { getItemText(item: PickerItem): string {
@ -45,13 +47,13 @@ export class CandidatePickerModal extends FuzzySuggestModal<PickerItem> {
this.contentEl.empty(); this.contentEl.empty();
} }
private settle(index: number | null): void { private settle(index: number | 'never' | null): void {
if (this.settled) return; if (this.settled) return;
this.settled = true; this.settled = true;
this.resolveFn?.(index); this.resolveFn?.(index);
} }
pick(): Promise<number | null> { pick(): Promise<number | 'never' | null> {
return new Promise(resolve => { return new Promise(resolve => {
this.resolveFn = resolve; this.resolveFn = resolve;
this.open(); this.open();

View file

@ -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. */ /** 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<number | null> { private async pickCandidate(filename: string, candidates: ResolveCandidate[]): Promise<number | 'never' | null> {
const modal = new CandidatePickerModal(this.plugin.app, filename, candidates); const modal = new CandidatePickerModal(this.plugin.app, filename, candidates);
return await modal.pick(); return await modal.pick();
} }
@ -225,10 +225,17 @@ export class LibraryController {
let picked = 0; let picked = 0;
let skipped = 0; let skipped = 0;
let marked = 0;
if (!dryRun) { if (!dryRun) {
for (const entry of report.needsChoice) { for (const entry of report.needsChoice) {
try { try {
const idx = await this.pickCandidate(entry.filename, entry.candidates); 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; const candidate = idx != null ? entry.candidates[idx] : undefined;
if (!candidate) { if (!candidate) {
skipped++; skipped++;
@ -248,6 +255,7 @@ export class LibraryController {
const mode = dryRun ? 'DRY-RUN ' : ''; const mode = dryRun ? 'DRY-RUN ' : '';
let msg = `Library ${mode}resolve (${spec.typeName}): ${report.resolved.length} resolved, ${report.ambiguous.length} ambiguous/no match`; 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 (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)`; if (report.errors.length) msg += `, ${report.errors.length} errors (see console)`;
this.notify(msg, 0); this.notify(msg, 0);
return report; return report;

View file

@ -11,13 +11,14 @@ function fakeApp(): any {
} }
describe('CandidatePickerModal', () => { 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 modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES);
const items = modal.getItems(); const items = modal.getItems();
expect(items).toEqual([ expect(items).toEqual([
{ label: 'Foo (2020)', index: 0 }, { label: 'Foo (2020)', index: 0 },
{ label: 'Bar (2021)', index: 1 }, { label: 'Bar (2021)', index: 1 },
{ label: 'Skip', index: null }, { label: 'Skip', index: null },
{ label: 'Never resolve (mark no_resolve)', index: 'never' },
]); ]);
}); });
@ -40,6 +41,13 @@ describe('CandidatePickerModal', () => {
expect(await result).toBeNull(); 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 () => { test('onClose without a prior choice (Esc / dismiss) -> pick() resolves to null', async () => {
const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES); const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES);
const result = modal.pick(); const result = modal.pick();

View file

@ -587,6 +587,24 @@ describe('resolveType: interactive candidate picker (needsChoice)', () => {
expect(notices[0]).toContain('1 picked, 1 skipped'); 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 () => { test('no needsChoice entries -> summary omits the picked/skipped clause entirely', async () => {
const c = new LibraryController(fakePlugin()); const c = new LibraryController(fakePlugin());
const notices: string[] = []; const notices: string[] = [];