From 49d7779bc11dda103ddd96e91f0c3ea2d4822e7e Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 5 Aug 2026 11:05:04 +0800 Subject: [PATCH] feat(library): book author-hint resolve + ambiguous candidate logging --- packages/obsidian/src/library/book.ts | 37 ++++++++++++++--- packages/obsidian/src/library/comic.ts | 13 +++++- packages/obsidian/src/library/game.ts | 12 ++++++ packages/obsidian/src/library/manga.ts | 9 ++++ tests/library-book.test.ts | 57 ++++++++++++++++++++++++++ tests/library-comic.test.ts | 14 +++++++ tests/library-game.test.ts | 13 ++++++ tests/library-manga.test.ts | 13 ++++++ 8 files changed, 161 insertions(+), 7 deletions(-) diff --git a/packages/obsidian/src/library/book.ts b/packages/obsidian/src/library/book.ts index 0ece284..02af346 100644 --- a/packages/obsidian/src/library/book.ts +++ b/packages/obsidian/src/library/book.ts @@ -114,6 +114,26 @@ async function fetchDocs(query: string, deps: SpecDeps): Promise { return res?.docs ?? []; } +/** Skeleton/legacy `author` (singular, stock) or canonical `authors` (bracketed list) frontmatter + * field -> first author name, used as a query hint to disambiguate common book titles against + * Open Library's `search.json`. Match rule is unaffected -- still checked against the OL doc's + * `title` field only. */ +function authorHint(fm: Record): string { + const stock = stripQuotes(fm['author']); + const canonical = (fm['authors'] ?? '').trim().replace(/^\[|\]$/g, ''); + const raw = stock || canonical; + if (!raw) return ''; + return stripQuotes(raw.split(',')[0].trim()); +} + +/** Top-candidate identifying info for ambiguous-resolve logging. */ +function candidateSummary(d: any): string { + const olid = String(d.key ?? '').replace(/^\/works\//, ''); + 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(', ') : ''; + return `olid=${olid} «${d.title ?? ''}»${year ? ` (${year})` : ''}${author ? ` ${author}` : ''}`; +} + export const bookSpec: MediaTypeSpec = { typeName: 'book', itemType: 'book_item', @@ -135,19 +155,24 @@ export const bookSpec: MediaTypeSpec = { }, async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise | null> { - const query = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, ''); - if (!query) return null; + const title = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, ''); + if (!title) return null; + const hint = authorHint(ctx.frontmatter); + const searchQuery = hint ? `${title} ${hint}` : title; try { - const docs = await fetchDocs(query, deps); + const docs = await fetchDocs(searchQuery, deps); if (docs.length === 0) return null; - const q = query.toLowerCase(); + const q = title.toLowerCase(); const exacts = docs.filter(d => String(d.title ?? '').toLowerCase() === q); const pick = exacts.length === 1 ? exacts[0] : exacts.length === 0 && docs.length === 1 ? docs[0] : null; - if (!pick) return null; + if (!pick) { + deps.log(`ambiguous "${searchQuery}": candidates: ${docs.slice(0, 3).map(candidateSummary).join('; ')}`); + return null; + } const olid = String(pick.key ?? '').replace(/^\/works\//, ''); return olid ? { olid } : null; } catch (e) { - deps.log(`book resolve failed for "${query}": ${String(e)}`); + deps.log(`book resolve failed for "${searchQuery}": ${String(e)}`); return null; } }, diff --git a/packages/obsidian/src/library/comic.ts b/packages/obsidian/src/library/comic.ts index f9e3933..18fdd2c 100644 --- a/packages/obsidian/src/library/comic.ts +++ b/packages/obsidian/src/library/comic.ts @@ -145,6 +145,13 @@ async function fetchVolumeResults(query: string, key: string, deps: SpecDeps): P return Array.isArray(res?.results) ? res.results : []; } +/** Top-candidate identifying info for ambiguous-resolve logging. */ +function candidateSummary(r: any): string { + const year = r.start_year != null ? String(r.start_year) : ''; + const publisher = r.publisher?.name ?? ''; + return `id=${r.id ?? ''} «${r.name ?? ''}»${year ? ` (${year})` : ''}${publisher ? ` ${publisher}` : ''}`; +} + export const comicSpec: MediaTypeSpec = { typeName: 'comic', itemType: 'comic_item', @@ -180,7 +187,11 @@ export const comicSpec: MediaTypeSpec = { const q = query.toLowerCase(); const exacts = results.filter(r => String(r.name ?? '').toLowerCase() === q); const pick = exacts.length === 1 ? exacts[0] : exacts.length === 0 && results.length === 1 ? results[0] : null; - return pick && pick.id != null ? { comicvine_id: String(pick.id) } : null; + if (!pick) { + deps.log(`ambiguous "${query}": candidates: ${results.slice(0, 3).map(candidateSummary).join('; ')}`); + return null; + } + return pick.id != null ? { comicvine_id: String(pick.id) } : null; } catch (e) { deps.log(`comic resolve failed for "${query}": ${String(e)}`); return null; diff --git a/packages/obsidian/src/library/game.ts b/packages/obsidian/src/library/game.ts index 6c8944f..d26e643 100644 --- a/packages/obsidian/src/library/game.ts +++ b/packages/obsidian/src/library/game.ts @@ -168,6 +168,11 @@ function pickUniqueExact(items: T[], query: string, titleOf: (t: T) => string return null; } +/** Top-candidate identifying info for ambiguous-resolve logging. */ +function candidateSummary(label: string, id: unknown, name: string, year?: string): string { + return `${label}=${id ?? ''} «${name}»${year ? ` (${year})` : ''}`; +} + async function fetchSteamSearch(query: string, deps: SpecDeps): Promise { const qs = new URLSearchParams({ term: query, cc: 'us', l: 'en' }); const res = await deps.http(`${STEAM_STORE_BASE}/api/storesearch/?${qs.toString()}`, {}); @@ -213,6 +218,9 @@ export const gameSpec: MediaTypeSpec = { 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 (items.length > 0) { + deps.log(`ambiguous "${query}": candidates: ${items.slice(0, 3).map((it: any) => candidateSummary('appid', it.id, it.name ?? '')).join('; ')}`); + } } catch (e) { deps.log(`game steam storesearch failed for "${query}": ${String(e)}`); } @@ -226,6 +234,10 @@ export const gameSpec: MediaTypeSpec = { 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 (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('; ')}`); + } } catch (e) { deps.log(`game rawg search failed for "${query}": ${String(e)}`); } diff --git a/packages/obsidian/src/library/manga.ts b/packages/obsidian/src/library/manga.ts index 1ba7fbc..3e29880 100644 --- a/packages/obsidian/src/library/manga.ts +++ b/packages/obsidian/src/library/manga.ts @@ -181,6 +181,12 @@ function resultTitles(r: any): string[] { return titles.filter(Boolean).map((t: string) => String(t).toLowerCase()); } +/** Top-candidate identifying info for ambiguous-resolve logging. */ +function candidateSummary(r: any): string { + const year = r.published?.from ? String(r.published.from).slice(0, 4) : ''; + return `mal_id=${r.mal_id ?? ''} «${r.title ?? ''}»${year ? ` (${year})` : ''}`; +} + async function resolveMalId(query: string, deps: SpecDeps): Promise { const qs = new URLSearchParams({ q: query, limit: '10' }); const res = await deps.http(`${JIKAN_BASE}/manga?${qs.toString()}`, {}); @@ -189,6 +195,9 @@ async function resolveMalId(query: string, deps: SpecDeps): Promise resultTitles(r).includes(q)); 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 pick ? String(pick.mal_id) : null; } diff --git a/tests/library-book.test.ts b/tests/library-book.test.ts index b8d348c..3ab039f 100644 --- a/tests/library-book.test.ts +++ b/tests/library-book.test.ts @@ -325,6 +325,19 @@ describe('bookSpec.resolve', () => { const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); expect(result).toBeNull(); }); + test('ambiguous -> logs top candidates with olid + title', async () => { + const deps = makeDeps({ + http: async () => ({ + docs: [ + { key: '/works/OL1W', title: 'Foo' }, + { key: '/works/OL2W', title: 'Bar' }, + ], + }), + }); + const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); + expect(result).toBeNull(); + expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('olid=OL1W') && m.includes('Foo') && m.includes('olid=OL2W') && m.includes('Bar'))).toBe(true); + }); test('no results -> null', async () => { const deps = makeDeps({ http: async () => ({ docs: [] }) }); const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); @@ -341,3 +354,47 @@ describe('bookSpec.resolve', () => { expect(deps.logCalls.length).toBeGreaterThan(0); }); }); + +describe('bookSpec.resolve — author hint (stock `author` / canonical `authors`)', () => { + test('stock `author` field present -> query is "title author", exact-title match still resolves', async () => { + let capturedUrl = ''; + const deps = makeDeps({ + http: async (url: string) => { + capturedUrl = url; + return olFixture; + }, + }); + const result = await bookSpec.resolve(ctxFor({ title: '1984', author: 'George Orwell' }, ''), deps); + const q = new URL(capturedUrl).searchParams.get('q'); + expect(q).toBe('1984 George Orwell'); + expect(result).toEqual({ olid: 'OL1168083W' }); + }); + + test('canonical `authors` bracketed list -> first author used in query', async () => { + let capturedUrl = ''; + const deps = makeDeps({ + http: async (url: string) => { + capturedUrl = url; + return olFixture; + }, + }); + const result = await bookSpec.resolve(ctxFor({ title: '1984', authors: '[George Orwell, Someone Else]' }, ''), deps); + const q = new URL(capturedUrl).searchParams.get('q'); + expect(q).toBe('1984 George Orwell'); + expect(result).toEqual({ olid: 'OL1168083W' }); + }); + + test('no author anywhere in frontmatter -> query is title only (unchanged behavior)', async () => { + let capturedUrl = ''; + const deps = makeDeps({ + http: async (url: string) => { + capturedUrl = url; + return olFixture; + }, + }); + const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); + const q = new URL(capturedUrl).searchParams.get('q'); + expect(q).toBe('1984'); + expect(result).toEqual({ olid: 'OL1168083W' }); + }); +}); diff --git a/tests/library-comic.test.ts b/tests/library-comic.test.ts index a0e4bd9..7213222 100644 --- a/tests/library-comic.test.ts +++ b/tests/library-comic.test.ts @@ -290,6 +290,20 @@ describe('comicSpec.resolve', () => { const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps); expect(result).toBeNull(); }); + test('ambiguous -> logs top candidates with id + name', async () => { + const deps = makeDeps({ + http: async () => ({ + results: [ + { id: 1, name: 'Batman' }, + { id: 2, name: 'Batman Beyond' }, + ], + }), + getKey: () => 'cvkey', + }); + const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps); + expect(result).toBeNull(); + expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('id=1') && m.includes('Batman') && m.includes('id=2') && m.includes('Batman Beyond'))).toBe(true); + }); test('no results -> null', async () => { const deps = makeDeps({ http: async () => ({ results: [] }), getKey: () => 'cvkey' }); diff --git a/tests/library-game.test.ts b/tests/library-game.test.ts index 8b5d593..32486e8 100644 --- a/tests/library-game.test.ts +++ b/tests/library-game.test.ts @@ -329,6 +329,19 @@ describe('gameSpec.resolve', () => { expect(result).toBeNull(); expect(deps.logCalls.some(m => m.includes('RAWG'))).toBe(true); }); + test('steam storesearch ambiguous -> logs top candidates with appid + name', async () => { + const deps = makeDeps({ + http: async () => ({ + items: [ + { id: 1, name: 'Foo' }, + { id: 2, name: 'Bar' }, + ], + }), + }); + const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); + expect(result).toBeNull(); + expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('appid=1') && m.includes('Foo') && m.includes('appid=2') && m.includes('Bar'))).toBe(true); + }); test('steam storesearch throws -> falls through to RAWG', async () => { const deps = makeDeps({ diff --git a/tests/library-manga.test.ts b/tests/library-manga.test.ts index 166161d..5a9694e 100644 --- a/tests/library-manga.test.ts +++ b/tests/library-manga.test.ts @@ -587,6 +587,19 @@ describe('mangaSpec.resolve', () => { const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); expect(result).toBeNull(); }); + test('ambiguous -> logs top candidates with mal_id + title', async () => { + const deps = makeDeps({ + http: async () => ({ + data: [ + { mal_id: 1, title: 'Foo' }, + { mal_id: 2, title: 'Bar' }, + ], + }), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toBeNull(); + expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('mal_id=1') && m.includes('Foo') && m.includes('mal_id=2') && m.includes('Bar'))).toBe(true); + }); test('no results -> null', async () => { const deps = makeDeps({ http: async () => ({ data: [] }) }); const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps);