feat(library): interactive candidate picker for ambiguous resolves

This commit is contained in:
afiqzudinhadi 2026-08-05 15:24:50 +08:00
parent 3eb89772c7
commit d7162d3ac2
16 changed files with 516 additions and 87 deletions

View file

@ -0,0 +1,60 @@
import type { App } from 'obsidian';
import { FuzzySuggestModal } from 'obsidian';
import type { ResolveCandidate } from 'packages/obsidian/src/library/types';
const SKIP_LABEL = 'Skip';
interface PickerItem {
label: string;
index: number | null; // null = Skip
}
/**
* 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).
*/
export class CandidatePickerModal extends FuzzySuggestModal<PickerItem> {
private settled = false;
private resolveFn?: (index: number | null) => void;
constructor(
app: App,
private filename: string,
private candidates: ResolveCandidate[],
) {
super(app);
this.setTitle(filename);
this.setPlaceholder(`Pick a match for ${filename}`);
}
getItems(): PickerItem[] {
return [...this.candidates.map((c, index) => ({ label: c.label, index })), { label: SKIP_LABEL, index: null }];
}
getItemText(item: PickerItem): string {
return item.label;
}
onChooseItem(item: PickerItem, _evt: MouseEvent | KeyboardEvent): void {
this.settle(item.index);
}
onClose(): void {
this.settle(null); // dismissed without choosing -> treat as skip
this.contentEl.empty();
}
private settle(index: number | null): void {
if (this.settled) return;
this.settled = true;
this.resolveFn?.(index);
}
pick(): Promise<number | null> {
return new Promise(resolve => {
this.resolveFn = resolve;
this.open();
});
}
}

View file

@ -1,12 +1,14 @@
import { Notice, TFile, TFolder } from 'obsidian';
import { bookSpec } from 'packages/obsidian/src/library/book';
import { CandidatePickerModal } from 'packages/obsidian/src/library/CandidatePickerModal';
import { comicSpec } from 'packages/obsidian/src/library/comic';
import { gameSpec } from 'packages/obsidian/src/library/game';
import { libraryFolderResolve, libraryFolderSync, type LibraryEngineDeps, type LibraryReport, type LibraryResolveReport } from 'packages/obsidian/src/library/LibraryEngine';
import { mangaSpec } from 'packages/obsidian/src/library/manga';
import type { HttpJsonFn, HttpPostJsonFn, HttpTextFn, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types';
import type { HttpJsonFn, HttpPostJsonFn, HttpTextFn, MediaTypeSpec, ResolveCandidate, SpecDeps } from 'packages/obsidian/src/library/types';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import { obsidianFetch } from 'packages/obsidian/src/utils/Utils';
import { patchFrontmatter } from 'packages/obsidian/src/watchlist/patchFrontmatter';
import { TmdbRateLimitError } from 'packages/obsidian/src/watchlist/SyncEngine';
import { shouldNotifySync } from 'packages/obsidian/src/watchlist/WatchlistController';
@ -34,7 +36,7 @@ function emptyReport(): LibraryReport {
}
function emptyResolveReport(): LibraryResolveReport {
return { resolved: [], ambiguous: [], errors: [] };
return { resolved: [], ambiguous: [], needsChoice: [], skippedNoResolve: 0, errors: [] };
}
export class LibraryController {
@ -205,6 +207,12 @@ 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<number | null> {
const modal = new CandidatePickerModal(this.plugin.app, filename, candidates);
return await modal.pick();
}
async resolveType(spec: MediaTypeSpec, dryRun = false): Promise<LibraryResolveReport> {
if (this.syncing) {
this.notify('Library sync already running');
@ -214,12 +222,29 @@ export class LibraryController {
try {
const deps = this.makeDeps(spec, dryRun);
const report = await libraryFolderResolve(spec, deps, { dryRun });
let picked = 0;
let skipped = 0;
if (!dryRun) {
for (const entry of report.needsChoice) {
const idx = await this.pickCandidate(entry.filename, entry.candidates);
const candidate = idx != null ? entry.candidates[idx] : undefined;
if (!candidate) {
skipped++;
continue;
}
const content = await deps.readNote(entry.path);
await deps.writeNote(entry.path, patchFrontmatter(content, candidate.patches, { defaultType: spec.itemType }));
report.resolved.push(entry.path);
picked++;
}
}
const mode = dryRun ? 'DRY-RUN ' : '';
this.notify(
`Library ${mode}resolve (${spec.typeName}): ${report.resolved.length} resolved, ${report.ambiguous.length} ambiguous/no match` +
(report.errors.length ? `, ${report.errors.length} errors (see console)` : ''),
0,
);
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.errors.length) msg += `, ${report.errors.length} errors (see console)`;
this.notify(msg, 0);
return report;
} finally {
this.syncing = false;

View file

@ -1,4 +1,4 @@
import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types';
import type { LibraryNoteCtx, MediaTypeSpec, ResolveCandidate, SpecDeps } from 'packages/obsidian/src/library/types';
import { parseNote, stripQuotes } from 'packages/obsidian/src/watchlist/parse';
import { patchFrontmatter } from 'packages/obsidian/src/watchlist/patchFrontmatter';
import { withRateLimitRetry } from 'packages/obsidian/src/watchlist/SyncEngine';
@ -26,6 +26,8 @@ export interface LibraryReport {
export interface LibraryResolveReport {
resolved: string[];
ambiguous: string[];
needsChoice: { path: string; filename: string; candidates: ResolveCandidate[] }[];
skippedNoResolve: number;
errors: { path: string; error: string }[];
}
@ -89,8 +91,13 @@ export async function libraryFolderSync(spec: MediaTypeSpec, deps: LibraryEngine
return report;
}
/** `no_resolve: true` opts a note out of the entire resolve pass (all specs), same as a manual skip. */
function hasNoResolveFlag(fm: Record<string, string>): boolean {
return stripQuotes(fm['no_resolve']) === 'true';
}
export async function libraryFolderResolve(spec: MediaTypeSpec, deps: LibraryEngineDeps, opts: { dryRun?: boolean } = {}): Promise<LibraryResolveReport> {
const report: LibraryResolveReport = { resolved: [], ambiguous: [], errors: [] };
const report: LibraryResolveReport = { resolved: [], ambiguous: [], needsChoice: [], skippedNoResolve: 0, errors: [] };
const notes = await deps.listNotes();
for (const note of notes) {
try {
@ -98,16 +105,24 @@ export async function libraryFolderResolve(spec: MediaTypeSpec, deps: LibraryEng
const { frontmatter, body } = parseNote(content);
const filename = filenameOf(note.path);
if (isSkippableNote(filename, frontmatter, spec.itemType)) continue;
if (hasNoResolveFlag(frontmatter)) {
report.skippedNoResolve++;
deps.log(`no_resolve flag set, skipping: ${note.path}`);
continue;
}
if (spec.hasId(frontmatter)) continue; // already resolved
const ctx: LibraryNoteCtx = { frontmatter, body, filename };
const patch = await withRateLimitRetry(() => spec.resolve(ctx, deps.specDeps), deps.sleep);
if (!patch) {
const outcome = await withRateLimitRetry(() => spec.resolve(ctx, deps.specDeps), deps.sleep);
if (!outcome) {
report.ambiguous.push(note.path);
deps.log(`ambiguous/no match: ${note.path}`);
} else if ('candidates' in outcome) {
report.needsChoice.push({ path: note.path, filename, candidates: outcome.candidates });
deps.log(`needs choice (${outcome.candidates.length} candidates): ${note.path}`);
} else {
report.resolved.push(note.path);
deps.log(`${opts.dryRun ? '[dry] ' : ''}resolved ${note.path}`);
if (!opts.dryRun) await deps.writeNote(note.path, patchFrontmatter(content, patch, { defaultType: spec.itemType }));
if (!opts.dryRun) await deps.writeNote(note.path, patchFrontmatter(content, outcome.patches, { defaultType: spec.itemType }));
}
await deps.sleep(spec.throttleMs);
} catch (e) {

View file

@ -1,4 +1,4 @@
import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types';
import type { LibraryNoteCtx, MediaTypeSpec, ResolveOutcome, SpecDeps } from 'packages/obsidian/src/library/types';
import { stripQuotes, extractMyNotes, extractCustomSections, type CustomSection } from 'packages/obsidian/src/watchlist/parse';
import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml';
import { deriveReadStatus, deriveRating } from 'packages/obsidian/src/library/convert';
@ -134,6 +134,14 @@ function candidateSummary(d: any): string {
return `olid=${olid} «${d.title ?? ''}»${year ? ` (${year})` : ''}${author ? ` ${author}` : ''}`;
}
/** Human-readable picker label -- same title/year/author info as candidateSummary, minus the olid prefix. */
function candidateLabel(d: any): string {
const year = d.first_publish_year != null ? String(d.first_publish_year) : '';
const author = Array.isArray(d.author_name) ? d.author_name.filter(Boolean).join(', ') : '';
const meta = [author, year].filter(Boolean).join(', ');
return meta ? `${d.title ?? ''} (${meta})` : (d.title ?? '');
}
export const bookSpec: MediaTypeSpec = {
typeName: 'book',
itemType: 'book_item',
@ -154,7 +162,7 @@ export const bookSpec: MediaTypeSpec = {
return !stripQuotes(fm['olid']) || !stripQuotes(fm['read_status']);
},
async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<Record<string, string> | null> {
async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<ResolveOutcome | null> {
const title = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, '');
if (!title) return null;
const hint = authorHint(ctx.frontmatter);
@ -167,10 +175,14 @@ export const bookSpec: MediaTypeSpec = {
const pick = exacts.length === 1 ? exacts[0] : exacts.length === 0 && docs.length === 1 ? docs[0] : null;
if (!pick) {
deps.log(`ambiguous "${searchQuery}": candidates: ${docs.slice(0, 3).map(candidateSummary).join('; ')}`);
return null;
const candidates = docs
.slice(0, 6)
.map(d => ({ label: candidateLabel(d), patches: { olid: String(d.key ?? '').replace(/^\/works\//, '') } }))
.filter(c => c.patches.olid);
return candidates.length ? { candidates } : null;
}
const olid = String(pick.key ?? '').replace(/^\/works\//, '');
return olid ? { olid } : null;
return olid ? { patches: { olid } } : null;
} catch (e) {
deps.log(`book resolve failed for "${searchQuery}": ${String(e)}`);
return null;

View file

@ -1,4 +1,4 @@
import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types';
import type { LibraryNoteCtx, MediaTypeSpec, ResolveOutcome, SpecDeps } from 'packages/obsidian/src/library/types';
import { stripQuotes, extractMyNotes, extractCustomSections, type CustomSection } from 'packages/obsidian/src/watchlist/parse';
import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml';
import { deriveReadStatus, deriveRating, parseNumOrNull } from 'packages/obsidian/src/library/convert';
@ -170,6 +170,14 @@ function candidateSummary(r: any): string {
return `id=${r.id ?? ''} «${r.name ?? ''}»${year ? ` (${year})` : ''}${publisher ? ` ${publisher}` : ''}`;
}
/** Human-readable picker label -- same name/year/publisher info as candidateSummary, minus the id prefix. */
function candidateLabel(r: any): string {
const year = r.start_year != null ? String(r.start_year) : '';
const publisher = r.publisher?.name ?? '';
const meta = [publisher, year].filter(Boolean).join(', ');
return meta ? `${r.name ?? ''} (${meta})` : (r.name ?? '');
}
export const comicSpec: MediaTypeSpec = {
typeName: 'comic',
itemType: 'comic_item',
@ -191,7 +199,7 @@ export const comicSpec: MediaTypeSpec = {
return false;
},
async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<Record<string, string> | null> {
async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<ResolveOutcome | null> {
const key = deps.getKey('comicvine');
if (!key) {
deps.log('comic resolve: no Comic Vine key configured, skipping id lookup');
@ -208,9 +216,13 @@ export const comicSpec: MediaTypeSpec = {
const pick = exacts.length === 1 ? exacts[0] : exacts.length === 0 && results.length === 1 ? results[0] : null;
if (!pick) {
deps.log(`ambiguous "${query}": candidates: ${results.slice(0, 3).map(candidateSummary).join('; ')}`);
return null;
const candidates = results
.slice(0, 6)
.filter(r => r.id != null)
.map(r => ({ label: candidateLabel(r), patches: { comicvine_id: String(r.id) } }));
return candidates.length ? { candidates } : null;
}
return pick.id != null ? { comicvine_id: String(pick.id) } : null;
return pick.id != null ? { patches: { comicvine_id: String(pick.id) } } : null;
} catch (e) {
deps.log(`comic resolve failed for "${query}": ${String(e)}`);
return null;

View file

@ -1,4 +1,4 @@
import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types';
import type { LibraryNoteCtx, MediaTypeSpec, ResolveCandidate, ResolveOutcome, SpecDeps } from 'packages/obsidian/src/library/types';
import { stripQuotes, extractMyNotes, extractCustomSections, type CustomSection } from 'packages/obsidian/src/watchlist/parse';
import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml';
import { deriveRating } from 'packages/obsidian/src/library/convert';
@ -173,6 +173,12 @@ function candidateSummary(label: string, id: unknown, name: string, year?: strin
return `${label}=${id ?? ''} «${name}»${year ? ` (${year})` : ''}`;
}
/** Human-readable picker label for a Steam storesearch item -- Steam's search payload carries no
* year/date, so name is all there is to show (matches what candidateSummary already logs for it). */
function steamCandidateLabel(it: any): string {
return String(it?.name ?? '');
}
async function fetchSteamSearch(query: string, deps: SpecDeps): Promise<any[]> {
const qs = new URLSearchParams({ term: query, cc: 'us', l: 'en' });
const res = await deps.http(`${STEAM_STORE_BASE}/api/storesearch/?${qs.toString()}`, {});
@ -205,21 +211,30 @@ export const gameSpec: MediaTypeSpec = {
return (!stripQuotes(fm['steam_appid']) && !stripQuotes(fm['rawg_id'])) || !stripQuotes(fm['play_status']);
},
async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<Record<string, string> | null> {
// Steam-primary: RAWG is still tried as a fallback on a Steam ambiguous/miss (unchanged). Only
// when NEITHER source lands a unique match do Steam's ambiguous results (if any) come back as
// candidates for the user to pick from, instead of a bare null -- RAWG-only ambiguity (Steam
// had 0/1 results) still returns null unchanged.
async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<ResolveOutcome | null> {
// fastest, no-network path: 28/31 real game notes already carry a Steam store url
const url = stripQuotes(ctx.frontmatter['url']);
const urlMatch = /store\.steampowered\.com\/app\/(\d+)/.exec(url);
if (urlMatch) return { steam_appid: urlMatch[1] };
if (urlMatch) return { patches: { steam_appid: urlMatch[1] } };
const query = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, '');
if (!query) return null;
let steamCandidates: ResolveCandidate[] = [];
try {
const items = await fetchSteamSearch(query, deps);
const pick = pickUniqueExact(items, query, (it: any) => String(it.name ?? ''));
if (pick) return { steam_appid: String(pick.id) };
if (pick) return { patches: { steam_appid: String(pick.id) } };
if (items.length > 0) {
deps.log(`ambiguous "${query}": candidates: ${items.slice(0, 3).map((it: any) => candidateSummary('appid', it.id, it.name ?? '')).join('; ')}`);
steamCandidates = items
.slice(0, 6)
.filter((it: any) => it.id != null)
.map((it: any) => ({ label: steamCandidateLabel(it), patches: { steam_appid: String(it.id) } }));
}
} catch (e) {
deps.log(`game steam storesearch failed for "${query}": ${String(e)}`);
@ -228,12 +243,12 @@ export const gameSpec: MediaTypeSpec = {
const key = deps.getKey('rawg');
if (!key) {
deps.log(`game resolve: no RAWG key configured, skipping RAWG fallback for "${query}"`);
return null;
return steamCandidates.length ? { candidates: steamCandidates } : null;
}
try {
const results = await fetchRawgSearch(query, key, deps);
const pick = pickUniqueExact(results, query, (it: any) => String(it.name ?? ''));
if (pick) return { rawg_id: String(pick.id) };
if (pick) return { patches: { rawg_id: String(pick.id) } };
if (results.length > 0) {
const year = (it: any) => (typeof it.released === 'string' ? it.released.slice(0, 4) : '');
deps.log(`ambiguous "${query}": candidates: ${results.slice(0, 3).map((it: any) => candidateSummary('rawg_id', it.id, it.name ?? '', year(it))).join('; ')}`);
@ -241,7 +256,7 @@ export const gameSpec: MediaTypeSpec = {
} catch (e) {
deps.log(`game rawg search failed for "${query}": ${String(e)}`);
}
return null;
return steamCandidates.length ? { candidates: steamCandidates } : null;
},
async sync(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<{ content: string; flipped: boolean } | null> {

View file

@ -1,4 +1,4 @@
import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types';
import type { LibraryNoteCtx, MediaTypeSpec, ResolveCandidate, ResolveOutcome, SpecDeps } from 'packages/obsidian/src/library/types';
import { parseFeed, latestChapter } from 'packages/obsidian/src/library/rss';
import { stripQuotes, extractMyNotes, extractCustomSections, type CustomSection } from 'packages/obsidian/src/watchlist/parse';
import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml';
@ -256,6 +256,13 @@ function candidateAniListSummary(m: any): string {
return `anilist_id=${m?.id ?? ''} «${m?.title?.romaji ?? ''}»${year ? ` (${year})` : ''}`;
}
/** Human-readable picker label (AniList side) -- same title/year info as candidateAniListSummary, minus the id prefix. */
function candidateAniListLabel(m: any): string {
const title = m?.title?.romaji || m?.title?.english || '';
const year = m?.startDate?.year ? String(m.startDate.year) : '';
return year ? `${title} (${year})` : title;
}
export function renderManga(r: MangaRecord, myNotes: string, customSections: CustomSection[] = []): string {
const fm = [
'---',
@ -421,10 +428,14 @@ export const mangaSpec: MediaTypeSpec = {
// AniList-primary: search AniList first (Page search, unique-exact vs romaji+english). A
// unique hit patches anilist_id (+ mal_id via idMal, when AniList has a MAL bridge for it).
// AniList miss/ambiguous/throw all fall back to the existing Jikan title-search resolve.
// When BOTH AniList and Jikan fail to land a unique match, an AniList-ambiguous result set
// (2+ exacts, or 0-exact-multi) is offered back to the caller as candidates for the user to
// pick from, instead of a bare null -- Jikan-only ambiguity (AniList had 0/1 results) still
// returns null unchanged, since AniList carries the richer id bridge worth surfacing.
// TmdbRateLimitError is never treated as a fallback trigger -- it propagates so the engine's
// withRateLimitRetry wrapper (around the whole resolve() call) retries instead of masking a
// transient 429 as an AniList miss.
async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<Record<string, string> | null> {
async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<ResolveOutcome | null> {
const query = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, '');
if (!query) return null;
@ -448,11 +459,17 @@ export const mangaSpec: MediaTypeSpec = {
const mangadexId = await resolveMangadexId(query, deps);
if (mangadexId) patch.mangadex_id = mangadexId;
}
return patch;
return { patches: patch };
}
let anilistCandidates: ResolveCandidate[] = [];
if (anilistResults.length > 1) {
deps.log(`ambiguous "${query}": candidates: ${anilistResults.slice(0, 3).map(candidateAniListSummary).join('; ')}`);
anilistCandidates = anilistResults.slice(0, 6).map(m => {
const patch: Record<string, string> = { anilist_id: String(m.id) };
if (m.idMal != null) patch.mal_id = String(m.idMal);
return { label: candidateAniListLabel(m), patches: patch };
});
}
// Jikan fallback (unchanged behavior)
@ -462,9 +479,9 @@ export const mangaSpec: MediaTypeSpec = {
} catch (e) {
if (e instanceof TmdbRateLimitError) throw e;
deps.log(`manga resolve failed for "${query}": ${String(e)}`);
return null;
return anilistCandidates.length ? { candidates: anilistCandidates } : null;
}
if (!malId) return null;
if (!malId) return anilistCandidates.length ? { candidates: anilistCandidates } : null;
const patch: Record<string, string> = { mal_id: malId };
// best-effort: only attempt when the note doesn't already carry a mangadex_id
@ -472,7 +489,7 @@ export const mangaSpec: MediaTypeSpec = {
const mangadexId = await resolveMangadexId(query, deps);
if (mangadexId) patch.mangadex_id = mangadexId;
}
return patch;
return { patches: patch };
},
// AniList-primary: anilist_id present -> fetch by id; else mal_id -> fetch by idMal bridge.

View file

@ -8,6 +8,15 @@ export interface LibraryNoteCtx {
filename: string;
}
/** One candidate offered to the user when resolve() can't pick a unique match on its own. */
export interface ResolveCandidate {
label: string; // human-readable (title + year/author/publisher/etc, whatever the spec already logs)
patches: Record<string, string>; // full id-field patch for this candidate (e.g. anilist_id + mal_id)
}
/** resolve() outcome: either a confident unique-match patch, or a shortlist for the user to pick from. */
export type ResolveOutcome = { patches: Record<string, string> } | { candidates: ResolveCandidate[] };
export interface MediaTypeSpec {
typeName: 'manga' | 'book' | 'game' | 'comic';
itemType: string; // 'manga_item' etc.
@ -16,7 +25,7 @@ export interface MediaTypeSpec {
throttleMs: number;
hasId(fm: Record<string, string>): boolean;
isActive(fm: Record<string, string>): boolean;
resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<Record<string, string> | null>; // returns fm patches (id fields)
resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<ResolveOutcome | null>; // null = no match/error
sync(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<{ content: string; flipped: boolean } | null>; // full new note content; null = skip
}