diff --git a/packages/obsidian/src/library/CandidatePickerModal.ts b/packages/obsidian/src/library/CandidatePickerModal.ts index c9ea9a8..0daab33 100644 --- a/packages/obsidian/src/library/CandidatePickerModal.ts +++ b/packages/obsidian/src/library/CandidatePickerModal.ts @@ -1,12 +1,14 @@ -import type { App } from 'obsidian'; +import type { App, FuzzyMatch } from 'obsidian'; 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)'; +const DETAIL_CLASS = 'media-db-sync-candidate-detail'; interface PickerItem { label: string; + detail?: string; // optional muted second line (format/status/publisher/id, etc); absent for Skip/Never rows index: number | 'never' | null; // null = Skip, 'never' = mark no_resolve } @@ -31,13 +33,23 @@ export class CandidatePickerModal extends FuzzySuggestModal { } getItems(): PickerItem[] { - return [...this.candidates.map((c, index) => ({ label: c.label, index })), { label: SKIP_LABEL, index: null }, { label: NEVER_LABEL, index: 'never' as const }]; + return [ + ...this.candidates.map((c, index) => ({ label: c.label, detail: c.detail, index })), + { label: SKIP_LABEL, index: null }, + { label: NEVER_LABEL, index: 'never' as const }, + ]; } getItemText(item: PickerItem): string { return item.label; } + /** Two-line suggestion row: label, plus a muted detail line when the candidate has one (Skip/Never never do). */ + renderSuggestion(match: FuzzyMatch, el: HTMLElement): void { + el.createDiv({ text: match.item.label }); + if (match.item.detail) el.createDiv({ text: match.item.detail, cls: DETAIL_CLASS }); + } + onChooseItem(item: PickerItem, _evt: MouseEvent | KeyboardEvent): void { this.settle(item.index); } diff --git a/packages/obsidian/src/library/book.ts b/packages/obsidian/src/library/book.ts index 72624a7..7b2f012 100644 --- a/packages/obsidian/src/library/book.ts +++ b/packages/obsidian/src/library/book.ts @@ -142,6 +142,16 @@ function candidateLabel(d: any): string { return meta ? `${d.title ?? ''} (${meta})` : (d.title ?? ''); } +/** Picker second line: first author · first-publish year · page count · olid -- whichever pieces the doc actually has. */ +function candidateDetail(d: any): string | undefined { + const author = Array.isArray(d.author_name) && d.author_name.length ? String(d.author_name[0]) : ''; + const year = d.first_publish_year != null ? String(d.first_publish_year) : ''; + const pages = typeof d.number_of_pages_median === 'number' ? `${d.number_of_pages_median}p` : ''; + const olid = String(d.key ?? '').replace(/^\/works\//, ''); + const parts = [author, year, pages, olid].filter(Boolean); + return parts.length ? parts.join(' · ') : undefined; +} + export const bookSpec: MediaTypeSpec = { typeName: 'book', itemType: 'book_item', @@ -177,7 +187,7 @@ export const bookSpec: MediaTypeSpec = { deps.log(`ambiguous "${searchQuery}": candidates: ${docs.slice(0, 3).map(candidateSummary).join('; ')}`); const candidates = docs .slice(0, 6) - .map(d => ({ label: candidateLabel(d), patches: { olid: String(d.key ?? '').replace(/^\/works\//, '') } })) + .map(d => ({ label: candidateLabel(d), detail: candidateDetail(d), patches: { olid: String(d.key ?? '').replace(/^\/works\//, '') } })) .filter(c => c.patches.olid); return candidates.length ? { candidates } : null; } diff --git a/packages/obsidian/src/library/comic.ts b/packages/obsidian/src/library/comic.ts index f279d98..95bc258 100644 --- a/packages/obsidian/src/library/comic.ts +++ b/packages/obsidian/src/library/comic.ts @@ -178,6 +178,16 @@ function candidateLabel(r: any): string { return meta ? `${r.name ?? ''} (${meta})` : (r.name ?? ''); } +/** Picker second line: publisher · issue count · start year · cv:{id} -- whichever pieces the volume search result actually has. */ +function candidateDetail(r: any): string | undefined { + const publisher = r.publisher?.name ?? ''; + const issues = typeof r.count_of_issues === 'number' ? `${r.count_of_issues} issues` : ''; + const startYear = r.start_year != null ? `start ${r.start_year}` : ''; + const id = r.id != null ? `cv:${r.id}` : ''; + const parts = [publisher, issues, startYear, id].filter(Boolean); + return parts.length ? parts.join(' · ') : undefined; +} + export const comicSpec: MediaTypeSpec = { typeName: 'comic', itemType: 'comic_item', @@ -219,7 +229,7 @@ export const comicSpec: MediaTypeSpec = { const candidates = results .slice(0, 6) .filter(r => r.id != null) - .map(r => ({ label: candidateLabel(r), patches: { comicvine_id: String(r.id) } })); + .map(r => ({ label: candidateLabel(r), detail: candidateDetail(r), patches: { comicvine_id: String(r.id) } })); return candidates.length ? { candidates } : null; } return pick.id != null ? { patches: { comicvine_id: String(pick.id) } } : null; diff --git a/packages/obsidian/src/library/game.ts b/packages/obsidian/src/library/game.ts index 365d54f..a263083 100644 --- a/packages/obsidian/src/library/game.ts +++ b/packages/obsidian/src/library/game.ts @@ -179,6 +179,12 @@ function steamCandidateLabel(it: any): string { return String(it?.name ?? ''); } +/** Picker second line for a Steam storesearch item -- id is the only reliably-present differentiator + * beyond name (the storesearch payload carries no release date, same limitation steamCandidateLabel notes). */ +function steamCandidateDetail(it: any): string { + return `Steam appid ${it?.id ?? ''}`; +} + 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()}`, {}); @@ -234,7 +240,7 @@ export const gameSpec: MediaTypeSpec = { steamCandidates = items .slice(0, 6) .filter((it: any) => it.id != null) - .map((it: any) => ({ label: steamCandidateLabel(it), patches: { steam_appid: String(it.id) } })); + .map((it: any) => ({ label: steamCandidateLabel(it), detail: steamCandidateDetail(it), patches: { steam_appid: String(it.id) } })); } } catch (e) { deps.log(`game steam storesearch failed for "${query}": ${String(e)}`); diff --git a/packages/obsidian/src/library/manga.ts b/packages/obsidian/src/library/manga.ts index fabdc59..0d203c0 100644 --- a/packages/obsidian/src/library/manga.ts +++ b/packages/obsidian/src/library/manga.ts @@ -16,6 +16,7 @@ const ANILIST_MEDIA_FIELDS = ` id idMal title { romaji english } + format status chapters volumes @@ -169,6 +170,15 @@ function stripAniListHtml(html: string | null | undefined): string { .trim(); } +/** Story-role staff (author-equivalent) off an AniList `Media` payload -- shared by record-building + * (`buildMangaFromAniList`) and the ambiguous-resolve picker detail (`candidateAniListDetail`). */ +function aniListStoryAuthors(media: any): string[] { + return ((media?.staff?.edges ?? []) as any[]) + .filter(e => typeof e?.role === 'string' && e.role.includes('Story')) + .map(e => e?.node?.name?.full) + .filter(Boolean); +} + /** * Pure mapper: AniList `Media` payload (the unwrapped `data.Media` object) + prev frontmatter -> * canonical MangaRecord. Mirrors `buildManga`'s user-field preservation contract exactly; only @@ -192,10 +202,7 @@ export function buildMangaFromAniList(media: any, prev: Record): const malId = media.idMal != null ? String(media.idMal) : stripQuotes(prev['mal_id']); const anilistId = media.id != null ? String(media.id) : stripQuotes(prev['anilist_id']); - const authors: string[] = ((media.staff?.edges ?? []) as any[]) - .filter(e => typeof e?.role === 'string' && e.role.includes('Story')) - .map(e => e?.node?.name?.full) - .filter(Boolean); + const authors = aniListStoryAuthors(media); return { title: romaji, @@ -263,6 +270,28 @@ function candidateAniListLabel(m: any): string { return year ? `${title} (${year})` : title; } +/** AniList `format` enum ("ONE_SHOT") -> Title Case ("One Shot"), or '' when absent. */ +function aniListFormatLabel(format: unknown): string { + if (typeof format !== 'string' || !format) return ''; + return format + .toLowerCase() + .split('_') + .filter(Boolean) + .map(w => w[0].toUpperCase() + w.slice(1)) + .join(' '); +} + +/** Picker second line (AniList side): format · status · first-two story authors · anilist:{id} -- + * whichever pieces the candidate actually has (search results are sparser than a full Media fetch). */ +function candidateAniListDetail(m: any): string | undefined { + const format = aniListFormatLabel(m?.format); + const status = ANILIST_STATUS_MAP[m?.status as string] ?? ''; + const authors = aniListStoryAuthors(m).slice(0, 2).join(', '); + const id = m?.id != null ? `anilist:${m.id}` : ''; + const parts = [format, status, authors, id].filter(Boolean); + return parts.length ? parts.join(' · ') : undefined; +} + export function renderManga(r: MangaRecord, myNotes: string, customSections: CustomSection[] = []): string { const fm = [ '---', @@ -468,7 +497,7 @@ export const mangaSpec: MediaTypeSpec = { anilistCandidates = anilistResults.slice(0, 6).map(m => { const patch: Record = { anilist_id: String(m.id) }; if (m.idMal != null) patch.mal_id = String(m.idMal); - return { label: candidateAniListLabel(m), patches: patch }; + return { label: candidateAniListLabel(m), detail: candidateAniListDetail(m), patches: patch }; }); } diff --git a/packages/obsidian/src/library/types.ts b/packages/obsidian/src/library/types.ts index 5a1476f..c5087fd 100644 --- a/packages/obsidian/src/library/types.ts +++ b/packages/obsidian/src/library/types.ts @@ -11,6 +11,7 @@ export interface LibraryNoteCtx { /** 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) + detail?: string; // optional second line for the picker (format/status/publisher/id, etc) -- muted, smaller text patches: Record; // full id-field patch for this candidate (e.g. anilist_id + mal_id) } diff --git a/packages/obsidian/src/styles.css b/packages/obsidian/src/styles.css index 3544a5f..5269684 100644 --- a/packages/obsidian/src/styles.css +++ b/packages/obsidian/src/styles.css @@ -325,3 +325,8 @@ small.media-db-plugin-list-text { .media-db-plugin-hidden { display: none; } + +.media-db-sync-candidate-detail { + opacity: 0.7; + font-size: 0.85em; +} diff --git a/tests/library-book.test.ts b/tests/library-book.test.ts index 6c6cfaf..60bae56 100644 --- a/tests/library-book.test.ts +++ b/tests/library-book.test.ts @@ -326,8 +326,25 @@ describe('bookSpec.resolve', () => { const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); expect(result).toEqual({ candidates: [ - { label: 'Foo', patches: { olid: 'OL1W' } }, - { label: 'Bar', patches: { olid: 'OL2W' } }, + { label: 'Foo', detail: 'OL1W', patches: { olid: 'OL1W' } }, + { label: 'Bar', detail: 'OL2W', patches: { olid: 'OL2W' } }, + ], + }); + }); + test('ambiguous candidate detail: first author · first-publish year · page count · olid', async () => { + const deps = makeDeps({ + http: async () => ({ + docs: [ + { key: '/works/OL1W', title: 'Foo', author_name: ['Jane Doe', 'John Roe'], first_publish_year: 1990, number_of_pages_median: 250 }, + { key: '/works/OL2W', title: 'Bar' }, // sparse -- only olid survives + ], + }), + }); + const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); + expect(result).toEqual({ + candidates: [ + { label: 'Foo (Jane Doe, John Roe, 1990)', detail: 'Jane Doe · 1990 · 250p · OL1W', patches: { olid: 'OL1W' } }, + { label: 'Bar', detail: 'OL2W', patches: { olid: 'OL2W' } }, ], }); }); diff --git a/tests/library-candidate-picker-modal.test.ts b/tests/library-candidate-picker-modal.test.ts index 2546bf7..7f5f5a1 100644 --- a/tests/library-candidate-picker-modal.test.ts +++ b/tests/library-candidate-picker-modal.test.ts @@ -6,10 +6,27 @@ const CANDIDATES = [ { label: 'Bar (2021)', patches: { mal_id: '2' } }, ]; +const CANDIDATES_WITH_DETAIL = [ + { label: 'Foo (2020)', detail: 'Manga · Publishing · Author One · anilist:1', patches: { mal_id: '1' } }, + { label: 'Bar (2021)', patches: { mal_id: '2' } }, // no detail -- must render as a single-line row +]; + function fakeApp(): any { return {}; } +/** Minimal Obsidian `HTMLElement.createDiv` stand-in: records every call's options instead of touching a real DOM. */ +function fakeEl(): { calls: unknown[]; createDiv: (o?: unknown) => unknown } { + const calls: unknown[] = []; + return { + calls, + createDiv(o?: unknown) { + calls.push(o); + return fakeEl(); + }, + }; +} + describe('CandidatePickerModal', () => { test('getItems: candidate labels followed by Skip then Never resolve', () => { const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES); @@ -27,6 +44,44 @@ describe('CandidatePickerModal', () => { expect(modal.getItemText({ label: 'Foo (2020)', index: 0 })).toBe('Foo (2020)'); }); + test('getItems: candidate detail threaded through, Skip/Never rows have no detail', () => { + const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES_WITH_DETAIL); + const items = modal.getItems(); + expect(items).toEqual([ + { label: 'Foo (2020)', detail: 'Manga · Publishing · Author One · anilist:1', index: 0 }, + { label: 'Bar (2021)', detail: undefined, index: 1 }, + { label: 'Skip', index: null }, + { label: 'Never resolve (mark no_resolve)', index: 'never' }, + ]); + }); + + test('renderSuggestion: candidate with a detail -> label div + muted detail div', () => { + const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES_WITH_DETAIL); + const item = modal.getItems()[0]; + const el = fakeEl(); + modal.renderSuggestion({ item, match: { score: 0, matches: [] } } as any, el as unknown as HTMLElement); + expect(el.calls).toEqual([{ text: 'Foo (2020)' }, { text: 'Manga · Publishing · Author One · anilist:1', cls: 'media-db-sync-candidate-detail' }]); + }); + + test('renderSuggestion: candidate with no detail -> label div only', () => { + const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES_WITH_DETAIL); + const item = modal.getItems()[1]; + const el = fakeEl(); + modal.renderSuggestion({ item, match: { score: 0, matches: [] } } as any, el as unknown as HTMLElement); + expect(el.calls).toEqual([{ text: 'Bar (2021)' }]); + }); + + test('renderSuggestion: Skip/Never rows -> label div only, no detail row', () => { + const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES_WITH_DETAIL); + const items = modal.getItems(); + const skipEl = fakeEl(); + modal.renderSuggestion({ item: items[2], match: { score: 0, matches: [] } } as any, skipEl as unknown as HTMLElement); + expect(skipEl.calls).toEqual([{ text: 'Skip' }]); + const neverEl = fakeEl(); + modal.renderSuggestion({ item: items[3], match: { score: 0, matches: [] } } as any, neverEl as unknown as HTMLElement); + expect(neverEl.calls).toEqual([{ text: 'Never resolve (mark no_resolve)' }]); + }); + test('onChooseItem(candidate) -> pick() resolves to that candidate index', async () => { const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES); const result = modal.pick(); diff --git a/tests/library-comic.test.ts b/tests/library-comic.test.ts index 4b496af..7668936 100644 --- a/tests/library-comic.test.ts +++ b/tests/library-comic.test.ts @@ -291,8 +291,27 @@ describe('comicSpec.resolve', () => { const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps); expect(result).toEqual({ candidates: [ - { label: 'Batman', patches: { comicvine_id: '1' } }, - { label: 'Batman Beyond', patches: { comicvine_id: '2' } }, + { label: 'Batman', detail: 'cv:1', patches: { comicvine_id: '1' } }, + { label: 'Batman Beyond', detail: 'cv:2', patches: { comicvine_id: '2' } }, + ], + }); + }); + + test('ambiguous candidate detail: publisher · issue count · start year · cv:{id}', async () => { + const deps = makeDeps({ + http: async () => ({ + results: [ + { id: 1, name: 'Batman', publisher: { name: 'DC Comics' }, count_of_issues: 85, start_year: 2016 }, + { id: 2, name: 'Batman Beyond' }, // sparse -- only cv:id survives + ], + }), + getKey: () => 'cvkey', + }); + const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps); + expect(result).toEqual({ + candidates: [ + { label: 'Batman (DC Comics, 2016)', detail: 'DC Comics · 85 issues · start 2016 · cv:1', patches: { comicvine_id: '1' } }, + { label: 'Batman Beyond', detail: 'cv:2', patches: { comicvine_id: '2' } }, ], }); }); diff --git a/tests/library-game.test.ts b/tests/library-game.test.ts index 82925a7..64e327e 100644 --- a/tests/library-game.test.ts +++ b/tests/library-game.test.ts @@ -329,8 +329,8 @@ describe('gameSpec.resolve', () => { const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); expect(result).toEqual({ candidates: [ - { label: 'Foo', patches: { steam_appid: '1' } }, - { label: 'Bar', patches: { steam_appid: '2' } }, + { label: 'Foo', detail: 'Steam appid 1', patches: { steam_appid: '1' } }, + { label: 'Bar', detail: 'Steam appid 2', patches: { steam_appid: '2' } }, ], }); expect(deps.logCalls.some(m => m.includes('RAWG'))).toBe(true); diff --git a/tests/library-manga.test.ts b/tests/library-manga.test.ts index 46e4820..c495e75 100644 --- a/tests/library-manga.test.ts +++ b/tests/library-manga.test.ts @@ -873,8 +873,45 @@ describe('mangaSpec.resolve — AniList primary', () => { const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); expect(result).toEqual({ candidates: [ - { label: 'Foo (2020)', patches: { anilist_id: '1', mal_id: '11' } }, - { label: 'Bar (2021)', patches: { anilist_id: '2', mal_id: '22' } }, + { label: 'Foo (2020)', detail: 'anilist:1', patches: { anilist_id: '1', mal_id: '11' } }, + { label: 'Bar (2021)', detail: 'anilist:2', patches: { anilist_id: '2', mal_id: '22' } }, + ], + }); + }); + + test('AniList ambiguous candidate detail: format · status · first-two story authors · anilist:{id}', async () => { + const deps = makeDeps({ + http: async () => ({ data: [] }), + httpPostJson: async () => + anilistPage([ + { + id: 1, + idMal: 11, + title: { romaji: 'Foo', english: '' }, + startDate: { year: 2020 }, + format: 'ONE_SHOT', + status: 'RELEASING', + staff: { + edges: [ + { role: 'Story & Art', node: { name: { full: 'Author One' } } }, + { role: 'Story', node: { name: { full: 'Author Two' } } }, + { role: 'Story', node: { name: { full: 'Author Three' } } }, // 3rd Story credit -- dropped, detail caps at 2 + { role: 'Illustration', node: { name: { full: 'Illustrator Only' } } }, // non-Story role -- excluded + ], + }, + }, + { id: 2, idMal: 22, title: { romaji: 'Bar', english: '' }, startDate: { year: 2021 } }, // sparse -- only id survives + ]), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ + candidates: [ + { + label: 'Foo (2020)', + detail: 'One Shot · Publishing · Author One, Author Two · anilist:1', + patches: { anilist_id: '1', mal_id: '11' }, + }, + { label: 'Bar (2021)', detail: 'anilist:2', patches: { anilist_id: '2', mal_id: '22' } }, ], }); });