feat(library): two-line candidate picker entries with per-type detail

This commit is contained in:
afiqzudinhadi 2026-08-05 16:00:33 +08:00
parent 16b8b242b8
commit 1e4567e844
12 changed files with 219 additions and 18 deletions

View file

@ -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<PickerItem> {
}
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<PickerItem>, 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);
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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<any[]> {
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)}`);

View file

@ -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<string, string>):
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<string, string> = { 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 };
});
}

View file

@ -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<string, string>; // full id-field patch for this candidate (e.g. anilist_id + mal_id)
}

View file

@ -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;
}