fix(metadata): use per-provider keys and extract relevance scoring

- replace shared 'providerId' key in pickedSources with provider-specific
  field names (googleBooksId, goodreadsId, etc.) so each provider's ID
  pick is independent and not overwritten when switching tabs
- extract candidate relevance scoring into candidate-relevance.ts with
  Levenshtein-based title/author scoring, ISBN exact match, and junk
  title filtering; remove duplicate inline scoring from goodreads provider
This commit is contained in:
neon
2026-03-24 09:23:01 -06:00
parent ac7b896c6e
commit f847cc1d22
20 changed files with 1054 additions and 282 deletions
@@ -1,5 +1,16 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest'
beforeEach(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
})
import DetailsTab from '../detail/tabs/DetailsTab.vue'
import type { BookDetail } from '@projectx/types'
@@ -78,6 +89,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
abridged: false,
chapters: null,
formatPriority: [],
comicMetadata: null,
...overrides,
}
}
@@ -1,19 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { ArrowLeft, CopyCheck, Copy, CheckCheck } from 'lucide-vue-next'
import type { MetadataCandidate, MetadataProviderInfo, MetadataSource } from '@projectx/types'
import { useMetadataDiff, type MetadataPatch } from '../../../composables/useMetadataDiff'
import { getProviderLabel, providerBadgeStyle, getProviderColor } from '../../../lib/metadata-fetch'
import type { MetadataCandidate, MetadataProviderInfo, MetadataProviderKey, MetadataSource, ProviderIds } from '@projectx/types'
import { useMetadataDiff, type DiffFieldKey, type MetadataPatch } from '../../../composables/useMetadataDiff'
import { getProviderLabel, getProviderColor, hideOnError, providerBadgeStyle, providerActivePillStyle } from '../../../lib/metadata-fetch'
import MetadataDiffRow from './MetadataDiffRow.vue'
const props = defineProps<{
current: MetadataSource
candidate: MetadataCandidate
candidates: MetadataCandidate[]
initialCandidate: MetadataCandidate
providers: MetadataProviderInfo[]
filteredResults: MetadataCandidate[]
backLabel?: string
currentCoverUrl?: string
currentProviderId?: string | null
fieldSources?: Record<string, string>
providerIds?: ProviderIds
}>()
const emit = defineEmits<{
@@ -21,15 +22,78 @@ const emit = defineEmits<{
apply: [{ formPatch: MetadataPatch; coverUrl?: string }]
}>()
const providerLabel = computed(() => getProviderLabel(props.candidate.provider, props.providers))
const activeProvider = ref<MetadataProviderKey>(props.initialCandidate.provider)
const { fields, toggleField, copyAll, copyMissing, buildPatch, hasCopied } = useMetadataDiff(
props.current,
props.candidate,
props.currentCoverUrl,
props.currentProviderId,
// Tracks which specific result the user has picked per provider tab
const selectedPerProvider = reactive(new Map<MetadataProviderKey, MetadataCandidate>())
selectedPerProvider.set(props.initialCandidate.provider, props.initialCandidate)
for (const c of props.filteredResults) {
if (!selectedPerProvider.has(c.provider)) {
selectedPerProvider.set(c.provider, c)
}
}
const representativeCandidates = computed<MetadataCandidate[]>(() => {
const out: MetadataCandidate[] = []
const seen = new Set<MetadataProviderKey>()
// initialCandidate's provider always first
const initSelected = selectedPerProvider.get(props.initialCandidate.provider) ?? props.initialCandidate
out.push(initSelected)
seen.add(initSelected.provider)
for (const c of props.filteredResults) {
if (!seen.has(c.provider)) {
seen.add(c.provider)
out.push(selectedPerProvider.get(c.provider) ?? c)
}
}
return out
})
watch(representativeCandidates, (list) => {
if (!list.find((c) => c.provider === activeProvider.value)) {
activeProvider.value = list[0]?.provider ?? props.initialCandidate.provider
}
})
const activeCandidate = computed(
() => representativeCandidates.value.find((c) => c.provider === activeProvider.value) ?? representativeCandidates.value[0],
)
const { fields, picksPerProvider, toggleField, pickFieldFromProvider, clearPicksForProvider, copyAll, copyMissing, buildPatch, hasCopied } =
useMetadataDiff(
props.current,
representativeCandidates,
activeProvider,
computed(() => props.providers),
props.currentCoverUrl,
computed(() => props.providerIds),
)
const hasMultipleProviders = computed(() => representativeCandidates.value.length > 1)
const resultsForActiveProvider = computed(() => props.filteredResults.filter((c) => c.provider === activeProvider.value))
function isSelectedForTab(c: MetadataCandidate): boolean {
const selected = selectedPerProvider.get(c.provider)
return !!selected && selected.providerId === c.providerId
}
function selectResultForTab(c: MetadataCandidate) {
selectedPerProvider.set(c.provider, c)
clearPicksForProvider(c.provider)
}
function setActiveProvider(provider: MetadataProviderKey) {
activeProvider.value = provider
}
function handlePickFromProvider(key: DiffFieldKey, provider: MetadataProviderKey) {
pickFieldFromProvider(key, provider)
}
function apply() {
emit('apply', buildPatch())
}
@@ -38,64 +102,115 @@ function apply() {
<template>
<div class="flex flex-col h-full">
<!-- Header -->
<div class="flex items-start gap-3 px-4 py-3 border-b border-border shrink-0 bg-card/50">
<button
class="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors mt-0.5 shrink-0 group"
@click="$emit('back')"
>
<ArrowLeft class="size-4 transition-transform group-hover:-translate-x-0.5" />
{{ backLabel ?? 'Results' }}
</button>
<div v-if="candidate.coverUrl" class="shrink-0 w-8 rounded overflow-hidden bg-muted ring-1 ring-border" style="aspect-ratio: 2/3">
<img
:src="candidate.coverUrl"
alt=""
class="w-full h-full object-cover"
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold truncate leading-snug">{{ candidate.title }}</p>
<div class="flex items-center gap-1.5 mt-0.5">
<span class="text-[10px] font-semibold px-2 py-0.5 rounded-full" :style="providerBadgeStyle(candidate.provider)">
{{ providerLabel }}
</span>
<div class="flex flex-col shrink-0 border-b border-border bg-card/50">
<!-- Top row -->
<div class="flex items-start gap-3 px-4 py-3">
<button
class="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors mt-0.5 shrink-0 group"
@click="$emit('back')"
>
<ArrowLeft class="size-4 transition-transform group-hover:-translate-x-0.5" />
{{ backLabel ?? 'Results' }}
</button>
<div v-if="activeCandidate.coverUrl" class="shrink-0 w-8 rounded overflow-hidden bg-muted ring-1 ring-border" style="aspect-ratio: 2/3">
<img
:src="activeCandidate.coverUrl"
alt=""
class="w-full h-full object-cover"
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold truncate leading-snug">{{ activeCandidate.title }}</p>
<div class="flex items-center gap-1.5 mt-0.5">
<span class="text-[10px] font-semibold px-2 py-0.5 rounded-full" :style="providerBadgeStyle(activeCandidate.provider)">
{{ getProviderLabel(activeCandidate.provider, providers) }}
</span>
</div>
</div>
<div class="flex gap-1.5 shrink-0">
<button
class="flex items-center gap-1.5 h-7 px-2.5 rounded-lg border border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs font-medium hover:bg-amber-500/20 transition-all active:scale-95"
@click="copyMissing"
>
<Copy class="size-3" />
Copy Missing
</button>
<button
class="flex items-center gap-1.5 h-7 px-2.5 rounded-lg border border-primary/40 bg-primary/10 text-primary text-xs font-medium hover:bg-primary/20 transition-all active:scale-95"
@click="copyAll"
>
<CopyCheck class="size-3" />
Copy All
</button>
</div>
</div>
<div class="flex gap-1.5 shrink-0">
<!-- Provider tabs -->
<div v-if="hasMultipleProviders" class="flex items-center gap-1.5 px-4 pb-2.5 overflow-x-auto">
<button
class="flex items-center gap-1.5 h-7 px-2.5 rounded-lg border border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs font-medium hover:bg-amber-500/20 transition-all active:scale-95"
@click="copyMissing"
v-for="c in representativeCandidates"
:key="c.provider"
class="relative flex items-center gap-1.5 h-6 px-2.5 rounded-full text-xs font-medium transition-all shrink-0 active:scale-95"
:style="activeProvider === c.provider ? providerActivePillStyle(c.provider) : {}"
:class="activeProvider !== c.provider ? 'bg-muted text-muted-foreground hover:text-foreground hover:bg-muted/80' : ''"
@click="setActiveProvider(c.provider)"
>
<Copy class="size-3" />
Copy Missing
{{ getProviderLabel(c.provider, providers) }}
<span
v-if="(picksPerProvider.get(c.provider) ?? 0) > 0"
class="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full text-[10px] font-semibold bg-black/10"
>
{{ picksPerProvider.get(c.provider) }}
</span>
</button>
</div>
<!-- Within-tab result picker (shown when active provider has multiple results) -->
<div v-if="resultsForActiveProvider.length > 1" class="flex items-center gap-2 px-4 pb-2.5 overflow-x-auto">
<p class="text-[10px] text-muted-foreground shrink-0">Result:</p>
<button
class="flex items-center gap-1.5 h-7 px-2.5 rounded-lg border border-primary/40 bg-primary/10 text-primary text-xs font-medium hover:bg-primary/20 transition-all active:scale-95"
@click="copyAll"
v-for="(c, i) in resultsForActiveProvider"
:key="c.providerId ?? i"
class="shrink-0 rounded-md overflow-hidden bg-muted ring-1 transition-all active:scale-95"
:class="isSelectedForTab(c) ? 'ring-2' : 'ring-border/60 hover:ring-border'"
:style="isSelectedForTab(c) ? { outline: 'none', boxShadow: `0 0 0 2px ${getProviderColor(c.provider)}` } : {}"
:title="`${c.title}${c.publishedYear ? ' · ' + c.publishedYear : ''}`"
style="width: 28px; aspect-ratio: 2/3"
@click="selectResultForTab(c)"
>
<CopyCheck class="size-3" />
Copy All
<img v-if="c.coverUrl" :src="c.coverUrl" alt="" class="w-full h-full object-cover" @error="hideOnError" />
<div v-else class="w-full h-full bg-muted-foreground/10" />
</button>
</div>
</div>
<!-- Column labels -->
<div class="grid grid-cols-[1fr_40px_1fr] gap-2 px-4 py-2 border-b border-border shrink-0">
<div class="grid grid-cols-[1fr_auto_1fr] gap-2 px-4 py-2 border-b border-border shrink-0">
<div class="flex items-center gap-1.5">
<div class="size-1.5 rounded-full bg-muted-foreground/40" />
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Current</p>
</div>
<div />
<div class="w-11" />
<div class="flex items-center gap-1.5">
<div class="size-1.5 rounded-full" :style="{ backgroundColor: getProviderColor(candidate.provider) }" />
<p class="text-xs font-semibold uppercase tracking-wider" :style="{ color: getProviderColor(candidate.provider) }">{{ providerLabel }}</p>
<div class="size-1.5 rounded-full" :style="{ backgroundColor: getProviderColor(activeProvider) }" />
<p class="text-xs font-semibold uppercase tracking-wider" :style="{ color: getProviderColor(activeProvider) }">
{{ getProviderLabel(activeProvider, providers) }}
</p>
</div>
</div>
<!-- Diff rows -->
<div class="flex-1 overflow-y-auto px-4">
<MetadataDiffRow v-for="field in fields" :key="field.key" :field="field" :source="fieldSources?.[field.key]" @toggle="toggleField" />
<MetadataDiffRow
v-for="field in fields"
:key="field.key"
:field="field"
:active-provider="activeProvider"
:providers="providers"
@toggle="toggleField"
@pick-from-provider="handlePickFromProvider"
/>
<p v-if="fields.length === 0" class="py-8 text-center text-sm text-muted-foreground">No metadata available from this source.</p>
</div>
@@ -1,60 +1,138 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ArrowLeft, RotateCcw, CheckCircle2, X, ZoomIn } from 'lucide-vue-next'
import { computed, ref } from 'vue'
import { ArrowLeft, RotateCcw, CheckCircle2, X, ZoomIn, Layers } from 'lucide-vue-next'
import type { MetadataProviderInfo, MetadataProviderKey } from '@projectx/types'
import type { DiffField, DiffFieldKey } from '../../../composables/useMetadataDiff'
import { hideOnError } from '../../../lib/metadata-fetch'
import { hideOnError, providerBadgeStyle } from '../../../lib/metadata-fetch'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
defineProps<{ field: DiffField; source?: string }>()
defineEmits<{ toggle: [DiffFieldKey] }>()
const props = defineProps<{
field: DiffField
activeProvider: MetadataProviderKey
providers: MetadataProviderInfo[]
}>()
const emit = defineEmits<{
toggle: [DiffFieldKey]
pickFromProvider: [key: DiffFieldKey, provider: MetadataProviderKey]
}>()
const lightboxSrc = ref<string | null>(null)
const pickedProviderLabel = computed(() => {
if (!props.field.pickedProvider) return ''
return props.field.providerValues.find((pv) => pv.provider === props.field.pickedProvider)?.label ?? props.field.pickedProvider
})
function handleToggle() {
emit('toggle', props.field.key)
}
function handlePickFromProvider(provider: MetadataProviderKey) {
emit('pickFromProvider', props.field.key, provider)
}
</script>
<template>
<!-- Cover row -->
<div v-if="field.isCover" class="py-3.5 border-b border-border/40">
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground mb-2.5">{{ field.label }}</p>
<div class="grid grid-cols-[1fr_44px_1fr] gap-2 items-center">
<div class="grid grid-cols-[1fr_auto_1fr] gap-2 items-center">
<!-- Current cover -->
<div
class="w-16 rounded-lg overflow-hidden bg-muted transition-all duration-300 shadow-sm ring-1 relative group"
:class="field.isCopied ? 'ring-primary ring-2' : field.bookValue ? 'ring-border cursor-zoom-in' : 'ring-border opacity-50'"
:class="field.isPicked ? 'ring-primary ring-2' : field.bookValue ? 'ring-border cursor-zoom-in' : 'ring-border opacity-50'"
style="aspect-ratio: 2/3"
@click="field.isCopied ? (lightboxSrc = field.candidateDisplay) : field.bookValue ? (lightboxSrc = field.bookValue) : null"
@click="
field.isPicked ? (lightboxSrc = field.pickedDisplay || field.candidateDisplay) : field.bookValue ? (lightboxSrc = field.bookValue) : null
"
>
<img v-if="field.isCopied" :src="field.candidateDisplay" alt="Preview" class="w-full h-full object-cover" @error="hideOnError" />
<img
v-if="field.isPicked && field.pickedDisplay"
:src="field.pickedDisplay"
alt="Preview"
class="w-full h-full object-cover"
@error="hideOnError"
/>
<img v-else-if="field.bookValue" :src="field.bookValue" alt="Current cover" class="w-full h-full object-cover" @error="hideOnError" />
<div v-else class="w-full h-full bg-linear-to-br from-muted to-muted-foreground/10" />
<div
v-if="field.isCopied || field.bookValue"
v-if="field.isPicked || field.bookValue"
class="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<ZoomIn class="size-4 text-white" />
</div>
<!-- Picked-from-other indicator -->
<div v-if="field.isPicked && !field.pickedFromActive && field.pickedProvider" class="absolute bottom-1 left-1 right-1">
<span
class="inline-flex items-center text-[8px] font-bold px-1 py-0.5 rounded w-full justify-center truncate"
:style="providerBadgeStyle(field.pickedProvider)"
>
{{ pickedProviderLabel }}
</span>
</div>
</div>
<!-- Toggle -->
<div class="flex justify-center">
<!-- Center: toggle + popover -->
<div class="flex flex-col items-center gap-1 w-11">
<button
v-if="field.isCopyable && field.candidateDisplay"
class="size-8 rounded-full flex items-center justify-center transition-all active:scale-90 shadow-sm"
:class="
field.isCopied
field.isPicked
? 'bg-primary text-primary-foreground shadow-primary/30'
: 'bg-card border border-border text-muted-foreground hover:border-primary/40 hover:text-primary hover:bg-primary/5'
"
@click="$emit('toggle', field.key)"
@click="handleToggle"
>
<RotateCcw v-if="field.isCopied" class="size-3.5" />
<RotateCcw v-if="field.isPicked && field.pickedFromActive" class="size-3.5" />
<Layers v-else-if="field.isPicked && !field.pickedFromActive" class="size-3.5" />
<ArrowLeft v-else class="size-3.5" />
</button>
<Popover v-if="field.isCopyable && field.providerValues.length >= 2">
<PopoverTrigger as-child>
<button
class="size-5 rounded-full flex items-center justify-center transition-all hover:bg-muted text-muted-foreground hover:text-foreground"
title="Pick cover from another provider"
>
<Layers class="size-3" />
</button>
</PopoverTrigger>
<PopoverContent class="w-auto p-1.5" side="bottom" :side-offset="4">
<div class="flex flex-col gap-1 min-w-48">
<p class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground px-2 py-1">Pick cover source</p>
<button
v-for="pv in field.providerValues"
:key="pv.provider"
class="flex items-center gap-2 px-2 py-1.5 rounded-md text-left transition-colors hover:bg-muted w-full"
:class="field.pickedProvider === pv.provider ? 'bg-primary/8 ring-1 ring-inset ring-primary/20' : ''"
@click="handlePickFromProvider(pv.provider)"
>
<span class="shrink-0 w-8 rounded overflow-hidden bg-muted" style="aspect-ratio: 2/3">
<img :src="pv.display" alt="" class="w-full h-full object-cover" @error="hideOnError" />
</span>
<span
class="inline-flex items-center text-[9px] font-bold px-1.5 py-0.5 rounded-md shrink-0"
:style="providerBadgeStyle(pv.provider)"
>
{{ pv.label }}
</span>
</button>
</div>
</PopoverContent>
</Popover>
</div>
<!-- New cover -->
<div
class="w-16 rounded-lg overflow-hidden bg-muted shadow-sm ring-1 transition-all duration-300 relative group"
:class="
field.candidateDisplay ? (field.isCopied ? 'ring-primary ring-2 cursor-zoom-in' : 'ring-border cursor-zoom-in') : 'ring-border opacity-50'
field.candidateDisplay
? field.isPicked && field.pickedFromActive
? 'ring-primary ring-2 cursor-zoom-in'
: 'ring-border cursor-zoom-in'
: 'ring-border opacity-50'
"
style="aspect-ratio: 2/3"
@click="field.candidateDisplay ? (lightboxSrc = field.candidateDisplay) : null"
@@ -75,53 +153,87 @@ const lightboxSrc = ref<string | null>(null)
<div v-else class="py-2.5 border-b border-border/40">
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground mb-1.5">{{ field.label }}</p>
<div class="flex flex-col gap-2 sm:grid sm:grid-cols-[1fr_44px_1fr] sm:gap-1.5 sm:items-stretch">
<!-- Current value: framed card to signal it's the active record -->
<div class="flex flex-col gap-2 sm:grid sm:grid-cols-[1fr_auto_1fr] sm:gap-1.5 sm:items-stretch">
<!-- Current value -->
<div
class="min-w-0 rounded-lg px-3 py-2 transition-all duration-200"
:class="!field.hasDiff ? 'bg-muted/30 opacity-50' : field.isCopied ? 'bg-muted/30 opacity-40' : 'bg-background ring-1 ring-border'"
:class="!field.hasDiff ? 'bg-muted/30 opacity-50' : field.isPicked ? 'bg-muted/30 opacity-40' : 'bg-background ring-1 ring-border'"
>
<p class="text-[10px] font-medium text-muted-foreground mb-0.5 sm:hidden">Current</p>
<p class="wrap-break-word leading-snug text-sm w-full" :class="!field.currentDisplay ? 'text-muted-foreground/60 italic' : 'text-foreground'">
{{ field.currentDisplay || 'empty' }}
</p>
<!-- Badge when value is picked from a different provider than the active tab -->
<span
v-if="field.isPicked && !field.pickedFromActive && field.pickedProvider"
class="inline-flex items-center text-[9px] font-bold px-1.5 py-0.5 rounded-md mt-1"
:style="providerBadgeStyle(field.pickedProvider)"
>
{{ pickedProviderLabel }}
</span>
</div>
<!-- Toggle button -->
<div class="flex items-center justify-center">
<!-- Center: toggle + popover stacked -->
<div class="flex items-center justify-center gap-0.5 w-11 sm:flex-col sm:justify-center">
<button
v-if="field.isCopyable && field.hasDiff"
class="size-8 rounded-full flex items-center justify-center transition-all active:scale-90 shadow-sm shrink-0"
:class="
field.isCopied
field.isPicked
? 'bg-primary text-primary-foreground shadow-primary/30'
: 'bg-card border border-border text-muted-foreground hover:border-primary/40 hover:text-primary hover:bg-primary/5'
"
@click="$emit('toggle', field.key)"
@click="handleToggle"
>
<RotateCcw v-if="field.isCopied" class="size-3.5" />
<RotateCcw v-if="field.isPicked && field.pickedFromActive" class="size-3.5" />
<Layers v-else-if="field.isPicked && !field.pickedFromActive" class="size-3.5" />
<ArrowLeft v-else class="size-3.5" />
</button>
<div v-else-if="field.isCopyable" class="size-8 flex items-center justify-center">
<div v-else-if="field.isCopyable" class="size-8 flex items-center justify-center shrink-0">
<CheckCircle2 class="size-3.5 text-muted-foreground/25" />
</div>
<div v-else class="size-8" />
<div v-else class="size-8 shrink-0" />
<Popover v-if="field.isCopyable && field.providerValues.length >= 2">
<PopoverTrigger as-child>
<button
class="size-5 rounded-full flex items-center justify-center transition-all hover:bg-muted text-muted-foreground hover:text-foreground shrink-0"
title="Pick from another provider"
>
<Layers class="size-3" />
</button>
</PopoverTrigger>
<PopoverContent class="w-auto p-1.5" side="bottom" :side-offset="4" align="center">
<div class="flex flex-col gap-0.5 min-w-44 max-w-72">
<p class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground px-2 py-1">Pick source</p>
<button
v-for="pv in field.providerValues"
:key="pv.provider"
class="flex items-start gap-2 px-2 py-1.5 rounded-md text-left transition-colors hover:bg-muted w-full"
:class="field.pickedProvider === pv.provider ? 'bg-primary/8 ring-1 ring-inset ring-primary/20' : ''"
@click="handlePickFromProvider(pv.provider)"
>
<span
class="inline-flex items-center text-[9px] font-bold px-1.5 py-0.5 rounded-md shrink-0 mt-0.5"
:style="providerBadgeStyle(pv.provider)"
>
{{ pv.label }}
</span>
<span class="text-xs text-muted-foreground leading-snug line-clamp-2 min-w-0">{{ pv.display }}</span>
</button>
</div>
</PopoverContent>
</Popover>
</div>
<!-- New value: muted by default, highlights when selected -->
<!-- New value -->
<div
class="min-w-0 rounded-lg px-3 py-2 transition-all duration-200"
:class="!field.hasDiff ? 'bg-muted/30 opacity-50' : field.isCopied ? 'bg-primary/8 ring-1 ring-primary/20' : 'bg-muted/40'"
:class="
!field.hasDiff ? 'bg-muted/30 opacity-50' : field.isPicked && field.pickedFromActive ? 'bg-primary/8 ring-1 ring-primary/20' : 'bg-muted/40'
"
>
<div class="flex items-center gap-1.5 mb-0.5">
<p class="text-[10px] font-medium text-muted-foreground sm:hidden">New</p>
<span
v-if="source"
class="inline-flex items-center px-1.5 py-px rounded text-[9px] font-semibold bg-muted text-muted-foreground uppercase tracking-wide"
>
{{ source }}
</span>
</div>
<p class="text-[10px] font-medium text-muted-foreground mb-0.5 sm:hidden">New</p>
<a
v-if="field.key === 'sourceUrl' && field.candidateDisplay"
:href="field.candidateDisplay"
@@ -131,7 +243,11 @@ const lightboxSrc = ref<string | null>(null)
>
{{ field.candidateDisplay }}
</a>
<p v-else class="wrap-break-word leading-snug text-sm w-full" :class="field.isCopied ? 'text-primary font-medium' : 'text-muted-foreground'">
<p
v-else
class="wrap-break-word leading-snug text-sm w-full"
:class="field.isPicked && field.pickedFromActive ? 'text-primary font-medium' : 'text-muted-foreground'"
>
{{ field.candidateDisplay }}
</p>
</div>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { BookOpen, ChevronRight } from 'lucide-vue-next'
import { BookOpen } from 'lucide-vue-next'
import type { MetadataCandidate, MetadataProviderInfo } from '@projectx/types'
import { getProviderLabel, hideOnError, providerBadgeStyle } from '../../../lib/metadata-fetch'
@@ -16,7 +16,7 @@ const providerLabel = computed(() => getProviderLabel(props.candidate.provider,
<template>
<button
class="group relative flex flex-row gap-3 p-2.5 rounded-xl border border-border/60 bg-card text-left transition-all hover:border-border hover:shadow-md hover:-translate-y-px active:scale-[0.98] active:shadow-sm overflow-hidden w-full"
class="group relative flex flex-row gap-3 p-2.5 rounded-xl border text-left transition-all active:scale-[0.98] overflow-hidden w-full border-border/60 bg-card hover:border-border hover:shadow-md hover:-translate-y-px"
@click="$emit('select', candidate)"
>
<!-- Cover -->
@@ -47,10 +47,5 @@ const providerLabel = computed(() => getProviderLabel(props.candidate.provider,
<span v-if="candidate.pageCount" class="text-[10px] text-muted-foreground tabular-nums">{{ candidate.pageCount }}p</span>
</span>
</span>
<!-- Arrow -->
<span class="shrink-0 self-center opacity-0 group-hover:opacity-100 transition-all -translate-x-1 group-hover:translate-x-0">
<ChevronRight class="size-4 text-muted-foreground" />
</span>
</button>
</template>
@@ -40,6 +40,7 @@ const currentSource = computed<MetadataSource>(() => ({
durationSeconds: props.book.durationSeconds,
abridged: props.book.abridged,
}))
const {
filteredResults,
providerCounts,
@@ -55,7 +56,6 @@ const {
const view = ref<'search' | 'diff'>('search')
const selectedCandidate = ref<MetadataCandidate | null>(null)
const currentProviderId = computed(() => (selectedCandidate.value ? (props.book.providerIds[selectedCandidate.value.provider] ?? null) : null))
onMounted(() => {
loadProviders()
@@ -63,17 +63,18 @@ onMounted(() => {
function handleSearch(params: { title: string; author: string; isbn: string }) {
const isAudiobook = props.book.durationSeconds != null || !!props.book.providerIds.audible
selectedCandidate.value = null
view.value = 'search'
search({ ...params, bookId: props.book.id, isAudiobook })
}
function selectCandidate(candidate: MetadataCandidate) {
function handleSelect(candidate: MetadataCandidate) {
selectedCandidate.value = candidate
view.value = 'diff'
}
function backToSearch() {
view.value = 'search'
selectedCandidate.value = null
}
function handleApply(patch: { formPatch: MetadataPatch; coverUrl?: string }) {
@@ -85,15 +86,15 @@ function handleApply(patch: { formPatch: MetadataPatch; coverUrl?: string }) {
<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 flex">
<!-- Backdrop: hidden on mobile since the panel is full-screen there -->
<!-- Backdrop -->
<div class="hidden sm:block flex-1 bg-black/50 backdrop-blur-sm" @click="$emit('close')" />
<!-- Drawer panel: full-screen on mobile, right-side panel on sm+ -->
<!-- Drawer panel -->
<div class="relative flex flex-col w-full sm:w-3/4 sm:max-w-4xl h-full bg-background sm:border-l border-border shadow-2xl overflow-hidden">
<!-- Gradient accent strip -->
<div class="h-px w-full bg-linear-to-r from-transparent via-primary to-transparent shrink-0 opacity-60" />
<!-- Ambient glow behind title -->
<!-- Ambient glow -->
<div class="absolute top-0 right-0 w-64 h-32 bg-primary/5 rounded-full blur-3xl pointer-events-none" />
<!-- Close button -->
@@ -121,7 +122,7 @@ function handleApply(patch: { formPatch: MetadataPatch; coverUrl?: string }) {
</div>
<!-- Content -->
<div class="flex-1 min-h-0">
<div class="flex-1 min-h-0 relative">
<MetadataSearchPanel
v-if="view === 'search'"
:search-defaults="searchDefaults"
@@ -134,16 +135,19 @@ function handleApply(patch: { formPatch: MetadataPatch; coverUrl?: string }) {
@search="handleSearch"
@toggle-provider="toggleProvider"
@clear-filter="clearProviderFilter"
@select="selectCandidate"
@select="handleSelect"
/>
<MetadataDiffPanel
v-else-if="view === 'diff' && selectedCandidate"
:current="currentSource"
:candidate="selectedCandidate"
:candidates="filteredResults"
:initial-candidate="selectedCandidate"
:providers="providers"
:current-cover-url="bookCoverUrl"
:current-provider-id="currentProviderId"
:provider-ids="book.providerIds"
:filtered-results="filteredResults"
back-label="Results"
@back="backToSearch"
@apply="handleApply"
/>
@@ -114,7 +114,7 @@ function runSearch() {
</button>
</div>
<!-- Results -->
<!-- Results (scrollable) -->
<div class="flex-1 overflow-y-auto px-4 pb-4">
<!-- Skeleton grid while loading with no results yet -->
<div v-if="isStreaming && !filteredResults.length" class="grid grid-cols-1 sm:grid-cols-2 gap-3">
@@ -139,6 +139,11 @@ function runSearch() {
</div>
</div>
<!-- Instructions when no search yet -->
<div v-else-if="!hasSearched" class="py-12 flex flex-col items-center gap-2 text-muted-foreground">
<p class="text-sm">Search above, then click a result to start comparing metadata.</p>
</div>
<!-- Results grid -->
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<MetadataResultCard
@@ -0,0 +1,172 @@
import { describe, it, expect } from 'vitest'
import { ref } from 'vue'
import type { MetadataCandidate, MetadataProviderKey, MetadataSource } from '@projectx/types'
import { useMetadataDiff } from '../useMetadataDiff'
describe('useMetadataDiff', () => {
const mockCurrent: MetadataSource = {
title: 'Original Title',
authors: ['Original Author'],
genres: ['Genre 1'],
description: 'Original Description',
publisher: 'Original Publisher',
publishedYear: 2020,
language: 'en',
pageCount: 300,
seriesName: 'Original Series',
seriesIndex: 1,
isbn10: '1234567890',
isbn13: '1234567890123',
}
const mockCandidate1: MetadataCandidate = {
provider: 'google',
providerId: 'g1',
title: 'Google Title',
authors: ['Google Author'],
description: 'Google Description',
coverUrl: 'http://google.com/cover.jpg',
}
const mockCandidate2: MetadataCandidate = {
provider: 'goodreads',
providerId: 'gr1',
title: 'Goodreads Title',
authors: ['Goodreads Author'],
genres: ['Genre 2'],
}
const providers = [
{ key: 'google' as MetadataProviderKey, label: 'Google Books' },
{ key: 'goodreads' as MetadataProviderKey, label: 'Goodreads' },
]
it('initializes with fields from active provider', () => {
const candidates = ref([mockCandidate1, mockCandidate2])
const activeProvider = ref<MetadataProviderKey>('google')
const { fields } = useMetadataDiff(mockCurrent, candidates, activeProvider, providers)
const titleField = fields.value.find((f) => f.key === 'title')
expect(titleField).toBeDefined()
expect(titleField?.candidateDisplay).toBe('Google Title')
expect(titleField?.bookValue).toBe('Original Title')
})
it('switches fields when active provider changes', () => {
const candidates = ref([mockCandidate1, mockCandidate2])
const activeProvider = ref<MetadataProviderKey>('google')
const { fields } = useMetadataDiff(mockCurrent, candidates, activeProvider, providers)
activeProvider.value = 'goodreads'
const titleField = fields.value.find((f) => f.key === 'title')
expect(titleField?.candidateDisplay).toBe('Goodreads Title')
})
it('toggles a field for picking', () => {
const candidates = ref([mockCandidate1, mockCandidate2])
const activeProvider = ref<MetadataProviderKey>('google')
const { fields, toggleField } = useMetadataDiff(mockCurrent, candidates, activeProvider, providers)
toggleField('title')
const titleField = fields.value.find((f) => f.key === 'title')
expect(titleField?.isPicked).toBe(true)
expect(titleField?.pickedProvider).toBe('google')
})
it('picks a field from a specific provider', () => {
const candidates = ref([mockCandidate1, mockCandidate2])
const activeProvider = ref<MetadataProviderKey>('google')
const { fields, pickFieldFromProvider } = useMetadataDiff(mockCurrent, candidates, activeProvider, providers)
pickFieldFromProvider('authors', 'goodreads')
const authorsField = fields.value.find((f) => f.key === 'authors')
expect(authorsField?.isPicked).toBe(true)
expect(authorsField?.pickedProvider).toBe('goodreads')
expect(authorsField?.pickedDisplay).toBe('Goodreads Author')
})
it('builds a patch with picked fields', () => {
const candidates = ref([mockCandidate1, mockCandidate2])
const activeProvider = ref<MetadataProviderKey>('google')
const { toggleField, pickFieldFromProvider, buildPatch } = useMetadataDiff(mockCurrent, candidates, activeProvider, providers)
toggleField('title') // google
pickFieldFromProvider('authors', 'goodreads')
const { formPatch } = buildPatch()
expect(formPatch.title).toBe('Google Title')
expect(formPatch.authors).toEqual(['Goodreads Author'])
// Should auto-include provider IDs
expect(formPatch.googleBooksId).toBe('g1')
expect(formPatch.goodreadsId).toBe('gr1')
})
it('copyAll picks everything from active provider', () => {
const candidates = ref([mockCandidate1, mockCandidate2])
const activeProvider = ref<MetadataProviderKey>('google')
const { fields, copyAll } = useMetadataDiff(mockCurrent, candidates, activeProvider, providers)
copyAll()
expect(fields.value.every((f) => !f.isCopyable || f.isPicked)).toBe(true)
expect(fields.value.find((f) => f.key === 'title')?.pickedProvider).toBe('google')
})
it('clearPicksForProvider clears only that provider', () => {
const candidates = ref([mockCandidate1, mockCandidate2])
const activeProvider = ref<MetadataProviderKey>('google')
const { toggleField, pickFieldFromProvider, clearPicksForProvider, fields } = useMetadataDiff(mockCurrent, candidates, activeProvider, providers)
toggleField('title') // google
pickFieldFromProvider('authors', 'goodreads')
clearPicksForProvider('google')
expect(fields.value.find((f) => f.key === 'title')?.isPicked).toBe(false)
expect(fields.value.find((f) => f.key === 'authors')?.isPicked).toBe(true)
})
it('copyMissing picks only fields that are empty on the book', () => {
const currentWithEmpty = { ...mockCurrent, description: '' }
const candidates = ref([mockCandidate1])
const activeProvider = ref<MetadataProviderKey>('google')
const { fields, copyMissing } = useMetadataDiff(currentWithEmpty, candidates, activeProvider, providers)
copyMissing()
expect(fields.value.find((f) => f.key === 'title')?.isPicked).toBe(false)
expect(fields.value.find((f) => f.key === 'description')?.isPicked).toBe(true)
})
it('handles comic metadata fields', () => {
const mockComic: MetadataCandidate = {
provider: 'google',
providerId: 'c1',
title: 'Comic',
comicMetadata: {
issueNumber: '42',
volumeName: 'Volume 1',
},
}
const candidates = ref([mockComic])
const activeProvider = ref<MetadataProviderKey>('google')
const { fields } = useMetadataDiff(mockCurrent, candidates, activeProvider, providers)
const issueField = fields.value.find((f) => f.key === 'comicIssueNumber')
expect(issueField).toBeDefined()
expect(issueField?.candidateDisplay).toBe('42')
})
it('provides multiple values from different providers for a single field', () => {
const candidates = ref([mockCandidate1, mockCandidate2])
const activeProvider = ref<MetadataProviderKey>('google')
const { fields } = useMetadataDiff(mockCurrent, candidates, activeProvider, providers)
const authorsField = fields.value.find((f) => f.key === 'authors')
expect(authorsField?.providerValues).toHaveLength(2)
expect(authorsField?.providerValues).toEqual(
expect.arrayContaining([
expect.objectContaining({ provider: 'google', display: 'Google Author' }),
expect.objectContaining({ provider: 'goodreads', display: 'Goodreads Author' }),
]),
)
})
})
@@ -1,5 +1,6 @@
import { computed, reactive } from 'vue'
import type { ComicMetadataFields, MetadataCandidate, MetadataProviderKey, MetadataSource } from '@projectx/types'
import { computed, reactive, toValue, type MaybeRefOrGetter } from 'vue'
import type { ComicMetadataFields, MetadataCandidate, MetadataProviderInfo, MetadataProviderKey, MetadataSource, ProviderIds } from '@projectx/types'
import { getProviderLabel } from '../lib/metadata-fetch'
type ComicDiffFieldKey =
| 'comicIssueNumber'
@@ -32,10 +33,17 @@ export type DiffFieldKey =
| 'durationSeconds'
| 'abridged'
| 'coverUrl'
| 'providerId'
| ProviderIdPatchField
| 'sourceUrl'
| ComicDiffFieldKey
export interface ProviderFieldValue {
provider: MetadataProviderKey
label: string
display: string
isPicked: boolean
}
export interface DiffField {
key: DiffFieldKey
label: string
@@ -43,9 +51,13 @@ export interface DiffField {
currentDisplay: string
candidateDisplay: string
hasDiff: boolean
isCopied: boolean
isPicked: boolean
pickedFromActive: boolean
pickedProvider: MetadataProviderKey | null
pickedDisplay: string
isCover: boolean
isCopyable: boolean
providerValues: ProviderFieldValue[]
}
export interface MetadataPatch {
@@ -76,7 +88,7 @@ export interface MetadataPatch {
comicMetadata?: ComicMetadataFields
}
const FIELD_DEFS: { key: DiffFieldKey; label: string }[] = [
export const FIELD_DEFS: { key: DiffFieldKey; label: string }[] = [
{ key: 'coverUrl', label: 'Cover' },
{ key: 'title', label: 'Title' },
{ key: 'subtitle', label: 'Subtitle' },
@@ -96,13 +108,13 @@ const FIELD_DEFS: { key: DiffFieldKey; label: string }[] = [
{ key: 'abridged', label: 'Abridged' },
]
interface ComicFieldDef {
export interface ComicFieldDef {
key: ComicDiffFieldKey
label: string
comicKey: keyof ComicMetadataFields
}
const COMIC_FIELD_DEFS: ComicFieldDef[] = [
export const COMIC_FIELD_DEFS: ComicFieldDef[] = [
{ key: 'comicIssueNumber', label: 'Issue Number', comicKey: 'issueNumber' },
{ key: 'comicVolumeName', label: 'Volume', comicKey: 'volumeName' },
{ key: 'comicPencillers', label: 'Pencillers', comicKey: 'pencillers' },
@@ -116,17 +128,25 @@ const COMIC_FIELD_DEFS: ComicFieldDef[] = [
{ key: 'comicStoryArcs', label: 'Story Arcs', comicKey: 'storyArcs' },
]
const COMIC_KEY_MAP: Record<ComicDiffFieldKey, keyof ComicMetadataFields> = Object.fromEntries(
export const COMIC_KEY_MAP: Record<ComicDiffFieldKey, keyof ComicMetadataFields> = Object.fromEntries(
COMIC_FIELD_DEFS.map((d) => [d.key, d.comicKey]),
) as Record<ComicDiffFieldKey, keyof ComicMetadataFields>
function isComicDiffFieldKey(key: DiffFieldKey): key is ComicDiffFieldKey {
export function isComicDiffFieldKey(key: DiffFieldKey): key is ComicDiffFieldKey {
return key in COMIC_KEY_MAP
}
type ProviderIdPatchField = 'googleBooksId' | 'goodreadsId' | 'amazonId' | 'hardcoverId' | 'openLibraryId' | 'itunesId' | 'audibleId' | 'comicvineId'
export type ProviderIdPatchField =
| 'googleBooksId'
| 'goodreadsId'
| 'amazonId'
| 'hardcoverId'
| 'openLibraryId'
| 'itunesId'
| 'audibleId'
| 'comicvineId'
const PROVIDER_ID_FIELD: Record<MetadataProviderKey, ProviderIdPatchField | undefined> = {
export const PROVIDER_ID_FIELD: Record<MetadataProviderKey, ProviderIdPatchField | undefined> = {
google: 'googleBooksId',
goodreads: 'goodreadsId',
amazon: 'amazonId',
@@ -138,7 +158,13 @@ const PROVIDER_ID_FIELD: Record<MetadataProviderKey, ProviderIdPatchField | unde
comicvine: 'comicvineId',
}
const PROVIDER_ID_LABEL: Record<MetadataProviderKey, string> = {
const PROVIDER_ID_PATCH_FIELDS = new Set<string>(Object.values(PROVIDER_ID_FIELD).filter((v): v is ProviderIdPatchField => v !== undefined))
export function isProviderIdPatchField(key: DiffFieldKey): key is ProviderIdPatchField {
return PROVIDER_ID_PATCH_FIELDS.has(key)
}
export const PROVIDER_ID_LABEL: Record<MetadataProviderKey, string> = {
google: 'Google Books ID',
goodreads: 'Goodreads ID',
amazon: 'Amazon ID',
@@ -150,8 +176,30 @@ const PROVIDER_ID_LABEL: Record<MetadataProviderKey, string> = {
comicvine: 'ComicVine ID',
}
export function useMetadataDiff(current: MetadataSource, candidate: MetadataCandidate, currentCoverUrl?: string, currentProviderId?: string | null) {
const copiedFields = reactive(new Set<DiffFieldKey>())
export function getCandidateValueFrom(candidate: MetadataCandidate, key: DiffFieldKey): string {
if (isComicDiffFieldKey(key)) {
const comicKey = COMIC_KEY_MAP[key]
const val = candidate.comicMetadata?.[comicKey]
if (Array.isArray(val)) return val.join(', ')
return val ?? ''
}
if (key === 'coverUrl') return candidate.coverUrl ?? ''
if (key === 'authors') return (candidate.authors ?? []).join(', ')
if (key === 'genres') return (candidate.genres ?? []).join(', ')
if (key === 'narrators') return (candidate.narrators ?? []).join(', ')
const val = candidate[key as keyof MetadataCandidate]
return val != null ? String(val) : ''
}
export function useMetadataDiff(
current: MetadataSource,
candidates: MaybeRefOrGetter<MetadataCandidate[]>,
activeProvider: MaybeRefOrGetter<MetadataProviderKey>,
providerInfos: MaybeRefOrGetter<MetadataProviderInfo[]>,
currentCoverUrl?: string,
providerIds?: MaybeRefOrGetter<ProviderIds | undefined>,
) {
const pickedSources = reactive(new Map<DiffFieldKey, MetadataProviderKey>())
function getBookValue(key: DiffFieldKey): string {
if (isComicDiffFieldKey(key)) return ''
@@ -163,86 +211,125 @@ export function useMetadataDiff(current: MetadataSource, candidate: MetadataCand
return val != null ? String(val) : ''
}
function getCandidateValue(key: DiffFieldKey): string {
if (isComicDiffFieldKey(key)) {
const comicKey = COMIC_KEY_MAP[key]
const val = candidate.comicMetadata?.[comicKey]
if (Array.isArray(val)) return val.join(', ')
return val ?? ''
function buildProviderValues(key: DiffFieldKey): ProviderFieldValue[] {
const allCandidates = toValue(candidates)
const infos = toValue(providerInfos)
const result: ProviderFieldValue[] = []
const seenProviders = new Set<MetadataProviderKey>()
for (const c of allCandidates) {
if (seenProviders.has(c.provider)) continue
seenProviders.add(c.provider)
const display = getCandidateValueFrom(c, key)
if (!display) continue
result.push({
provider: c.provider,
label: getProviderLabel(c.provider, infos),
display,
isPicked: pickedSources.get(key) === c.provider,
})
}
if (key === 'coverUrl') return candidate.coverUrl ?? ''
if (key === 'authors') return (candidate.authors ?? []).join(', ')
if (key === 'genres') return (candidate.genres ?? []).join(', ')
if (key === 'narrators') return (candidate.narrators ?? []).join(', ')
const val = candidate[key as keyof MetadataCandidate]
return val != null ? String(val) : ''
return result
}
function makeRow(key: DiffFieldKey, label: string): DiffField | null {
const candidateVal = getCandidateValue(key)
const allCandidates = toValue(candidates)
const ap = toValue(activeProvider)
const activeCandidate = allCandidates.find((c) => c.provider === ap)
const candidateVal = activeCandidate ? getCandidateValueFrom(activeCandidate, key) : ''
const bookVal = getBookValue(key)
if (key === 'coverUrl') {
if (!candidateVal && !bookVal) return null
} else {
if (!candidateVal) return null
}
const isCopied = copiedFields.has(key)
const pickedProvider = pickedSources.get(key) ?? null
const isPicked = pickedProvider !== null
const pickedFromActive = isPicked && pickedProvider === ap
const pickedCandidate = isPicked ? allCandidates.find((c) => c.provider === pickedProvider) : null
const pickedDisplay = pickedCandidate ? getCandidateValueFrom(pickedCandidate, key) : ''
return {
key,
label,
bookValue: bookVal,
currentDisplay: isCopied ? candidateVal : bookVal,
currentDisplay: isPicked ? pickedDisplay : bookVal,
candidateDisplay: candidateVal,
hasDiff: bookVal !== candidateVal,
isCopied,
isPicked,
pickedFromActive,
pickedProvider,
pickedDisplay,
isCover: key === 'coverUrl',
isCopyable: true,
providerValues: buildProviderValues(key),
}
}
const fields = computed<DiffField[]>(() => {
const rows: DiffField[] = []
const allCandidates = toValue(candidates)
const ap = toValue(activeProvider)
const ids = toValue(providerIds)
const activeCandidate = allCandidates.find((c) => c.provider === ap)
for (const def of FIELD_DEFS) {
const row = makeRow(def.key, def.label)
if (row) rows.push(row)
}
if (candidate.comicMetadata) {
if (activeCandidate?.comicMetadata) {
for (const def of COMIC_FIELD_DEFS) {
const row = makeRow(def.key, def.label)
if (row) rows.push(row)
}
}
if (candidate.providerId || currentProviderId) {
const providerIsCopied = copiedFields.has('providerId')
const candidateProviderId = candidate.providerId ?? ''
const existingProviderId = currentProviderId ?? ''
const existingProviderId = ids?.[ap] ?? ''
const providerIdKey = PROVIDER_ID_FIELD[ap]
if (providerIdKey && activeCandidate && (activeCandidate.providerId || existingProviderId)) {
const pickedProvider = pickedSources.get(providerIdKey) ?? null
const isPicked = pickedProvider !== null
const pickedFromActive = isPicked && pickedProvider === ap
const pickedCandidate = isPicked ? allCandidates.find((c) => c.provider === pickedProvider) : null
const pickedDisplay = pickedCandidate?.providerId ?? ''
const activeProviderIdVal = activeCandidate.providerId ?? ''
rows.push({
key: 'providerId',
label: PROVIDER_ID_LABEL[candidate.provider] ?? 'Provider ID',
bookValue: existingProviderId,
currentDisplay: providerIsCopied ? candidateProviderId : existingProviderId,
candidateDisplay: candidateProviderId,
hasDiff: existingProviderId !== candidateProviderId,
isCopied: providerIsCopied,
key: providerIdKey,
label: PROVIDER_ID_LABEL[ap] ?? 'Provider ID',
bookValue: existingProviderId ?? '',
currentDisplay: pickedFromActive ? pickedDisplay : (existingProviderId ?? ''),
candidateDisplay: activeProviderIdVal,
hasDiff: (existingProviderId ?? '') !== activeProviderIdVal,
isPicked: pickedFromActive,
pickedFromActive,
pickedProvider: pickedFromActive ? pickedProvider : null,
pickedDisplay: pickedFromActive ? pickedDisplay : '',
isCover: false,
isCopyable: true,
providerValues: [],
})
}
if (candidate.sourceUrl) {
if (activeCandidate?.sourceUrl) {
rows.push({
key: 'sourceUrl',
label: 'Source URL',
bookValue: '',
currentDisplay: '',
candidateDisplay: candidate.sourceUrl,
candidateDisplay: activeCandidate.sourceUrl,
hasDiff: true,
isCopied: false,
isPicked: false,
pickedFromActive: false,
pickedProvider: null,
pickedDisplay: '',
isCover: false,
isCopyable: false,
providerValues: [],
})
}
@@ -250,19 +337,33 @@ export function useMetadataDiff(current: MetadataSource, candidate: MetadataCand
})
function toggleField(key: DiffFieldKey) {
if (copiedFields.has(key)) copiedFields.delete(key)
else copiedFields.add(key)
const ap = toValue(activeProvider)
if (pickedSources.get(key) === ap) {
pickedSources.delete(key)
} else {
pickedSources.set(key, ap)
}
}
function pickFieldFromProvider(key: DiffFieldKey, provider: MetadataProviderKey) {
if (pickedSources.get(key) === provider) {
pickedSources.delete(key)
} else {
pickedSources.set(key, provider)
}
}
function copyAll() {
const ap = toValue(activeProvider)
for (const f of fields.value) {
if (f.isCopyable) copiedFields.add(f.key)
if (f.isCopyable) pickedSources.set(f.key, ap)
}
}
function copyMissing() {
const ap = toValue(activeProvider)
for (const f of fields.value) {
if (f.isCopyable && !f.bookValue) copiedFields.add(f.key)
if (f.isCopyable && !f.bookValue && !f.isPicked) pickedSources.set(f.key, ap)
}
}
@@ -270,8 +371,12 @@ export function useMetadataDiff(current: MetadataSource, candidate: MetadataCand
const formPatch: MetadataPatch = {}
let coverUrl: string | undefined
const comicPatch: Partial<ComicMetadataFields> = {}
const allCandidates = toValue(candidates)
for (const [key, providerKey] of pickedSources) {
const candidate = allCandidates.find((c) => c.provider === providerKey)
if (!candidate) continue
for (const key of copiedFields) {
if (isComicDiffFieldKey(key)) {
const comicKey = COMIC_KEY_MAP[key]
;(comicPatch as Record<string, unknown>)[comicKey] = candidate.comicMetadata?.[comicKey]
@@ -313,9 +418,8 @@ export function useMetadataDiff(current: MetadataSource, candidate: MetadataCand
formPatch.seriesIndex = candidate.seriesIndex ?? null
continue
}
if (key === 'providerId') {
const idField = PROVIDER_ID_FIELD[candidate.provider]
if (idField) formPatch[idField] = candidate.providerId
if (isProviderIdPatchField(key)) {
formPatch[key] = candidate.providerId
continue
}
if (key === 'sourceUrl') continue
@@ -327,10 +431,57 @@ export function useMetadataDiff(current: MetadataSource, candidate: MetadataCand
formPatch.comicMetadata = comicPatch as ComicMetadataFields
}
// Auto-include provider IDs for every provider that contributed at least one picked field
const pickedProviders = new Set(pickedSources.values())
for (const provider of pickedProviders) {
const candidate = allCandidates.find((c) => c.provider === provider)
if (!candidate?.providerId) continue
const idField = PROVIDER_ID_FIELD[provider]
if (idField && formPatch[idField] === undefined) {
formPatch[idField] = candidate.providerId
}
}
// Preserve existing provider IDs on the book that we aren't overwriting
const ids = toValue(providerIds)
if (ids) {
for (const [provider, id] of Object.entries(ids)) {
const idField = PROVIDER_ID_FIELD[provider as MetadataProviderKey]
if (idField && formPatch[idField] === undefined && id) {
formPatch[idField] = id
}
}
}
return { formPatch, coverUrl }
}
const hasCopied = computed(() => copiedFields.size > 0)
const picksPerProvider = computed<Map<MetadataProviderKey, number>>(() => {
const counts = new Map<MetadataProviderKey, number>()
for (const provKey of pickedSources.values()) {
counts.set(provKey, (counts.get(provKey) ?? 0) + 1)
}
return counts
})
return { fields, copiedFields, toggleField, copyAll, copyMissing, buildPatch, hasCopied }
function clearPicksForProvider(provider: MetadataProviderKey) {
for (const [key, p] of pickedSources) {
if (p === provider) pickedSources.delete(key)
}
}
const hasCopied = computed(() => pickedSources.size > 0)
return {
fields,
pickedSources,
picksPerProvider,
toggleField,
pickFieldFromProvider,
clearPicksForProvider,
copyAll,
copyMissing,
buildPatch,
hasCopied,
}
}
+26 -8
View File
@@ -1,6 +1,31 @@
# Client typecheck baseline
# Auto-generated by: pnpm run typecheck:client:baseline:update
# One diagnostic per line. Do not edit manually unless necessary.
src/features/book/components/detail/tabs/EditMetadataTab.vue(394,14): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(400,16): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(404,16): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(411,16): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(415,16): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(421,14): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(427,16): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(431,16): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(437,14): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/MetadataDiffPanel.vue(115,20): error TS18048: '__VLS_ctx.activeCandidate' is possibly 'undefined'.
src/features/book/components/detail/tabs/MetadataDiffPanel.vue(117,19): error TS18048: '__VLS_ctx.activeCandidate' is possibly 'undefined'.
src/features/book/components/detail/tabs/MetadataDiffPanel.vue(124,69): error TS18048: '__VLS_ctx.activeCandidate' is possibly 'undefined'.
src/features/book/components/detail/tabs/MetadataDiffPanel.vue(126,105): error TS18048: '__VLS_ctx.activeCandidate' is possibly 'undefined'.
src/features/book/components/detail/tabs/MetadataDiffPanel.vue(127,35): error TS18048: '__VLS_ctx.activeCandidate' is possibly 'undefined'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(107,90): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(117,140): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(131,99): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(151,81): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(161,81): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(47,81): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(58,81): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(68,94): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(7,9): error TS2739: Type '{ title: string; authors: string[]; genres: string[]; description: string; publisher: string; publishedYear: number; language: string; pageCount: number; seriesName: string; seriesIndex: number; isbn10: string; isbn13: string; }' is missing the following properties from type 'MetadataSource': subtitle, narrators, durationSeconds, abridged
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(79,104): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(91,121): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/dashboard/components/DashboardSettingsSheet.vue(45,26): error TS2345: Argument of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to parameter of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/reader/epub/composables/useFoliateInput.ts(31,19): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(32,19): error TS18048: 'touch' is possibly 'undefined'.
@@ -17,19 +42,12 @@ src/features/reader/epub/composables/useReaderState.ts(69,40): error TS18048: 't
src/features/reader/epub/composables/useReaderState.ts(76,23): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(77,22): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/utils.ts(2,3): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
src/features/reader/pdf/components/PdfToolbar.vue(205,93): error TS2769: No overload matches this call.
src/features/reader/pdf/composables/usePdfFind.ts(33,22): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
src/features/reader/pdf/composables/usePdfFind.ts(33,43): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
src/features/reader/pdf/composables/usePdfRenderer.ts(97,17): error TS18048: 'e' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(98,34): error TS18048: 'f' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(99,31): error TS18048: 'a' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(99,50): error TS18048: 'b' is possibly 'undefined'.
src/features/reader/ReaderView.vue(157,15): error TS2345: Argument of type 'unknown[]' is not assignable to parameter of type 'RawTocItem[]'.
src/features/reader/ReaderView.vue(249,12): error TS2345: Argument of type '{}' is not assignable to parameter of type 'FoliateView'.
src/features/reader/ReaderView.vue(261,15): error TS2345: Argument of type 'unknown' is not assignable to parameter of type 'FoliateView | null'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,25): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,5): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(65,21): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(65,5): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/ProviderChipList.vue(56,34): error TS2345: Argument of type 'MetadataProviderKey | undefined' is not assignable to parameter of type 'MetadataProviderKey'.
src/features/staging/components/StagingFileSheet.vue(581,12): error TS2345: Argument of type '{ current: MetadataSource; candidate: { provider: MetadataProviderKey; providerId: string; title: string; subtitle?: string | undefined; authors?: string[] | undefined; description?: string | undefined; publisher?: string | undefined; ... 15 more ...; comicMetadata?: { ...; } | undefined; }; ... 5 more ...; onBack: ...' is not assignable to parameter of type '{ readonly current: MetadataSource; readonly candidates: MetadataCandidate[]; readonly initialCandidate: MetadataCandidate; ... 6 more ...; readonly onApply?: ((args_0: { ...; }) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/lib/api.ts(55,37): error TS2353: Object literal may only specify known properties, and '_isRetry' does not exist in type 'RequestInit'.
+6 -5
View File
@@ -57,8 +57,9 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"drizzle-orm": "^0.45.1",
"fast-xml-parser": "^5.5.8",
"fastify": "^5.8.2",
"fast-xml-parser": "^5.5.9",
"fastest-levenshtein": "^1.0.16",
"fastify": "^5.8.4",
"geoip-lite": "^2.0.1",
"jose": "^6.2.2",
"ms": "^2.1.3",
@@ -95,7 +96,7 @@
"@types/pg": "^8.18.0",
"@types/supertest": "^7.2.0",
"@types/unzipper": "^0.10.11",
"@vitest/coverage-v8": "^4.1.0",
"@vitest/coverage-v8": "^4.1.1",
"drizzle-kit": "^0.31.10",
"eslint": "^10.1.0",
"eslint-config-prettier": "^10.1.8",
@@ -110,7 +111,7 @@
"tsconfig-paths": "^4.2.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.1",
"vitest": "^4.1.0"
"typescript-eslint": "^8.57.2",
"vitest": "^4.1.1"
}
}
@@ -2,8 +2,8 @@ import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, Max, MaxLeng
import { Type } from 'class-transformer';
export class ComicMetadataDto {
@IsOptional() @IsString() @MaxLength(50) issueNumber?: string;
@IsOptional() @IsString() @MaxLength(500) volumeName?: string;
@IsOptional() @IsString() @MaxLength(50) issueNumber?: string | null;
@IsOptional() @IsString() @MaxLength(500) volumeName?: string | null;
@IsOptional() @IsArray() @IsString({ each: true }) pencillers?: string[];
@IsOptional() @IsArray() @IsString({ each: true }) inkers?: string[];
@IsOptional() @IsArray() @IsString({ each: true }) colorists?: string[];
@@ -0,0 +1,110 @@
import { MetadataCandidate } from '@projectx/types';
import { describe, expect, it } from 'vitest';
import { filterAndRank } from './candidate-relevance';
import { MetadataSearchParams } from './providers/metadata-search-params';
function candidate(title: string, author?: string, isbn10?: string, isbn13?: string): MetadataCandidate {
return {
provider: 'google',
providerId: Math.random().toString(),
title,
authors: author ? [author] : [],
isbn10,
isbn13,
};
}
describe('candidate-relevance', () => {
describe('filterAndRank', () => {
it('returns first N candidates if no title/author query provided', () => {
const candidates = [candidate('A'), candidate('B'), candidate('C')];
const params: MetadataSearchParams = { isbn: '123' };
const result = filterAndRank(candidates, params, 2);
expect(result).toHaveLength(2);
expect(result[0].title).toBe('A');
expect(result[1].title).toBe('B');
});
it('filters out study guides and summaries', () => {
const candidates = [candidate('The Great Gatsby'), candidate('Summary: The Great Gatsby'), candidate('Study Guide for The Great Gatsby')];
const params: MetadataSearchParams = { title: 'The Great Gatsby' };
const result = filterAndRank(candidates, params);
expect(result).toHaveLength(1);
expect(result[0].title).toBe('The Great Gatsby');
});
it('always includes exact ISBN matches regardless of title', () => {
const candidates = [candidate('Wrong Title', 'Wrong Author', '1234567890')];
const params: MetadataSearchParams = { title: 'Right Title', isbn: '123-456-7890' }; // hyphens in query
const result = filterAndRank(candidates, params);
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Wrong Title');
});
it('normalizes ISBN in candidate before matching', () => {
const candidates = [candidate('Title', 'Author', '123-456-789-0')]; // hyphens in candidate
const params: MetadataSearchParams = { isbn: '1234567890' };
const result = filterAndRank(candidates, params);
expect(result).toHaveLength(1);
});
it('ranks exact matches above partial matches', () => {
const candidates = [candidate('The Lord of the Rings: The Fellowship of the Ring'), candidate('The Fellowship of the Ring')];
const params: MetadataSearchParams = { title: 'The Fellowship of the Ring' };
const result = filterAndRank(candidates, params);
expect(result[0].title).toBe('The Fellowship of the Ring');
expect(result[1].title).toBe('The Lord of the Rings: The Fellowship of the Ring');
});
it('handles candidates with missing titles or authors', () => {
const candidates = [{ ...candidate('Valid'), title: undefined } as unknown as MetadataCandidate, candidate('Valid')];
const params: MetadataSearchParams = { title: 'Valid' };
const result = filterAndRank(candidates, params);
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Valid');
});
it('respects the limit parameter', () => {
const candidates = [candidate('Match 1'), candidate('Match 2'), candidate('Match 3')];
const params: MetadataSearchParams = { title: 'Match' };
const result = filterAndRank(candidates, params, 2);
expect(result).toHaveLength(2);
});
it('boosts score with author match', () => {
const candidates = [candidate('Project Hail Mary', 'Andy Weir'), candidate('Project Hail Mary', 'Someone Else')];
const params: MetadataSearchParams = { title: 'Project Hail Mary', author: 'Andy Weir' };
const result = filterAndRank(candidates, params);
expect(result[0].authors).toContain('Andy Weir');
});
it('drops candidates with zero score', () => {
const candidates = [candidate('Unrelated Book')];
const params: MetadataSearchParams = { title: 'Different Title' };
const result = filterAndRank(candidates, params);
expect(result).toHaveLength(0);
});
});
describe('scoreTitle', () => {
it('gives highest score to exact matches', () => {
const params: MetadataSearchParams = { title: 'Foundation' };
const result = filterAndRank([candidate('Foundation')], params);
expect(result).toHaveLength(1);
});
it('supports fuzzy matching via levenshtein', () => {
const params: MetadataSearchParams = { title: 'Foundatun' }; // typo
const result = filterAndRank([candidate('Foundation')], params);
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Foundation');
});
it('supports token overlap', () => {
const params: MetadataSearchParams = { title: 'The Way of Kings' };
const result = filterAndRank([candidate('Kings Way')], params);
expect(result).toHaveLength(1);
});
});
});
@@ -0,0 +1,97 @@
import { MetadataCandidate } from '@projectx/types';
import { distance } from 'fastest-levenshtein';
import { MetadataSearchParams } from './providers/metadata-search-params';
const DEFAULT_LIMIT = 5;
const SKIP_TITLE_PREFIXES = /^(summary|study guide|analysis|guide to|workbook|review of|summary of|chapter summary|chapter-by-chapter)\b/i;
/**
* Scores, filters, and ranks candidates by relevance to the search params.
*
* - If no title or author is available in params, returns candidates as-is (no basis for scoring).
* - ISBN exact match always survives regardless of title/author score.
* - Candidates scoring 0 are dropped (no meaningful signal).
* - Survivors are sorted descending by score and capped at `limit`.
*/
export function filterAndRank(candidates: MetadataCandidate[], params: MetadataSearchParams, limit = DEFAULT_LIMIT): MetadataCandidate[] {
if (!hasText(params.title) && !hasText(params.author)) {
return candidates.slice(0, limit);
}
return candidates
.filter((c) => !c.title || !SKIP_TITLE_PREFIXES.test(c.title))
.map((c) => ({ candidate: c, score: scoreCandidate(c, params) }))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(({ candidate }) => candidate);
}
function scoreCandidate(candidate: MetadataCandidate, params: MetadataSearchParams): number {
if (params.isbn) {
const isbn = params.isbn.replace(/[^0-9X]/gi, '');
if (candidate.isbn10 === isbn || candidate.isbn13 === isbn) return 100;
}
let score = 0;
if (params.title && candidate.title) {
score += scoreTitle(normalize(params.title), normalize(candidate.title));
}
// Author only boosts when there is already a positive title signal.
// A matching author alone (e.g. a different book by the same author) is not enough to keep a result.
if (score > 0 && params.author && candidate.authors?.length) {
score += scoreAuthor(normalize(params.author), candidate.authors.map(normalize));
}
return score;
}
function scoreTitle(query: string, candidate: string): number {
if (candidate === query) return 10;
if (candidate.startsWith(query) || query.startsWith(candidate)) return 8;
if (candidate.includes(query) || query.includes(candidate)) return 7;
const queryTokens = tokenize(query);
const candidateTokens = new Set(tokenize(candidate));
const overlapRatio = queryTokens.length > 0 ? queryTokens.filter((w) => candidateTokens.has(w)).length / queryTokens.length : 0;
const tokenScore = overlapRatio * 6;
const sim = levenshteinSim(query, candidate);
const levenScore = sim >= 0.6 ? sim * 4 : 0;
return Math.max(tokenScore, levenScore);
}
function scoreAuthor(query: string, candidateAuthors: string[]): number {
const queryTokens = new Set(tokenize(query).filter((w) => w.length > 2));
for (const ca of candidateAuthors) {
if (ca.includes(query) || query.includes(ca)) return 3;
if (tokenize(ca).some((w) => queryTokens.has(w))) return 1;
}
return 0;
}
function levenshteinSim(a: string, b: string): number {
const maxLen = Math.max(a.length, b.length);
return maxLen === 0 ? 1 : 1 - distance(a, b) / maxLen;
}
function normalize(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9 ]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function tokenize(s: string): string[] {
return s.split(' ').filter((w) => w.length > 1);
}
function hasText(v: string | undefined): boolean {
return typeof v === 'string' && v.trim().length > 0;
}
@@ -52,13 +52,15 @@ describe('MetadataFetchService', () => {
key: MetadataProviderKey.GOOGLE,
label: 'Google',
identifiable: false,
search: vi.fn().mockResolvedValue([candidate(MetadataProviderKey.GOOGLE, 'g1')]),
search: vi.fn().mockResolvedValue([candidate(MetadataProviderKey.GOOGLE, 'g1', 'Dune')]),
};
const openLibrary: MetadataProvider = {
key: MetadataProviderKey.OPEN_LIBRARY,
label: 'OpenLibrary',
identifiable: false,
search: vi.fn().mockResolvedValue([candidate(MetadataProviderKey.OPEN_LIBRARY, 'ol1'), candidate(MetadataProviderKey.OPEN_LIBRARY, 'ol2')]),
search: vi
.fn()
.mockResolvedValue([candidate(MetadataProviderKey.OPEN_LIBRARY, 'ol1', 'Dune'), candidate(MetadataProviderKey.OPEN_LIBRARY, 'ol2', 'Dune')]),
};
registry.select.mockReturnValue([google, openLibrary]);
@@ -67,9 +69,9 @@ describe('MetadataFetchService', () => {
expect(results).toHaveLength(3);
expect(results).toEqual(
expect.arrayContaining([
candidate(MetadataProviderKey.GOOGLE, 'g1'),
candidate(MetadataProviderKey.OPEN_LIBRARY, 'ol1'),
candidate(MetadataProviderKey.OPEN_LIBRARY, 'ol2'),
candidate(MetadataProviderKey.GOOGLE, 'g1', 'Dune'),
candidate(MetadataProviderKey.OPEN_LIBRARY, 'ol1', 'Dune'),
candidate(MetadataProviderKey.OPEN_LIBRARY, 'ol2', 'Dune'),
]),
);
expect(google.search).toHaveBeenCalledWith({ title: 'Dune' });
@@ -84,13 +86,13 @@ describe('MetadataFetchService', () => {
search: vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([candidate(MetadataProviderKey.GOOGLE, 'g-fallback')]),
.mockResolvedValueOnce([candidate(MetadataProviderKey.GOOGLE, 'g-fallback', 'Dune')]),
};
registry.select.mockReturnValue([google]);
const results = await firstValueFrom(service.search({ title: 'Dune', author: 'Frank Herbert', isbn: '9780441013593' }).pipe(toArray()));
expect(results).toEqual([candidate(MetadataProviderKey.GOOGLE, 'g-fallback')]);
expect(results).toEqual([candidate(MetadataProviderKey.GOOGLE, 'g-fallback', 'Dune')]);
expect(google.search).toHaveBeenCalledTimes(2);
expect(google.search).toHaveBeenNthCalledWith(1, {
title: 'Dune',
@@ -109,13 +111,13 @@ describe('MetadataFetchService', () => {
key: MetadataProviderKey.GOOGLE,
label: 'Google',
identifiable: false,
search: vi.fn().mockResolvedValue([candidate(MetadataProviderKey.GOOGLE, 'g-isbn')]),
search: vi.fn().mockResolvedValue([candidate(MetadataProviderKey.GOOGLE, 'g-isbn', 'Dune')]),
};
registry.select.mockReturnValue([google]);
const results = await firstValueFrom(service.search({ title: 'Dune', isbn: '9780441013593' }).pipe(toArray()));
expect(results).toEqual([candidate(MetadataProviderKey.GOOGLE, 'g-isbn')]);
expect(results).toEqual([candidate(MetadataProviderKey.GOOGLE, 'g-isbn', 'Dune')]);
expect(google.search).toHaveBeenCalledTimes(1);
expect(google.search).toHaveBeenCalledWith({ title: 'Dune', isbn: '9780441013593' });
});
@@ -141,8 +143,8 @@ describe('MetadataFetchService', () => {
key: MetadataProviderKey.GOOGLE,
label: 'Google',
identifiable: true,
search: vi.fn().mockResolvedValue([candidate(MetadataProviderKey.GOOGLE, 'search-id')]),
lookupById: vi.fn().mockResolvedValue(candidate(MetadataProviderKey.GOOGLE, 'stored-id')),
search: vi.fn().mockResolvedValue([candidate(MetadataProviderKey.GOOGLE, 'search-id', 'Dune')]),
lookupById: vi.fn().mockResolvedValue(candidate(MetadataProviderKey.GOOGLE, 'stored-id', 'Dune')),
};
registry.select.mockReturnValue([google]);
@@ -150,7 +152,7 @@ describe('MetadataFetchService', () => {
service.search({ title: 'Dune', existingProviderIds: { [MetadataProviderKey.GOOGLE]: 'stored-id' } }).pipe(toArray()),
);
expect(results).toEqual([candidate(MetadataProviderKey.GOOGLE, 'stored-id')]);
expect(results).toEqual([candidate(MetadataProviderKey.GOOGLE, 'stored-id', 'Dune')]);
expect(google.lookupById).toHaveBeenCalledWith('stored-id');
expect(google.search).not.toHaveBeenCalled();
});
@@ -160,7 +162,7 @@ describe('MetadataFetchService', () => {
key: MetadataProviderKey.GOOGLE,
label: 'Google',
identifiable: true,
search: vi.fn().mockResolvedValue([candidate(MetadataProviderKey.GOOGLE, 'search-id')]),
search: vi.fn().mockResolvedValue([candidate(MetadataProviderKey.GOOGLE, 'search-id', 'Dune')]),
lookupById: vi.fn().mockResolvedValue(null),
};
registry.select.mockReturnValue([google]);
@@ -185,13 +187,13 @@ describe('MetadataFetchService', () => {
key: MetadataProviderKey.OPEN_LIBRARY,
label: 'OpenLibrary',
identifiable: false,
search: vi.fn().mockResolvedValue([candidate(MetadataProviderKey.OPEN_LIBRARY, 'ol1')]),
search: vi.fn().mockResolvedValue([candidate(MetadataProviderKey.OPEN_LIBRARY, 'ol1', 'Dune')]),
};
registry.select.mockReturnValue([failing, healthy]);
const results = await firstValueFrom(service.search({ title: 'Dune' }).pipe(toArray()));
expect(results).toEqual([candidate(MetadataProviderKey.OPEN_LIBRARY, 'ol1')]);
expect(results).toEqual([candidate(MetadataProviderKey.OPEN_LIBRARY, 'ol1', 'Dune')]);
});
it('times out a stalled provider instead of hanging indefinitely', async () => {
@@ -7,6 +7,7 @@ import { from, merge, Observable, switchMap } from 'rxjs';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { bookMetadata } from '../../db/schema';
import { filterAndRank } from './candidate-relevance';
import { ProviderThrottleError } from './provider-throttle.error';
import { ProviderThrottleTracker } from './provider-throttle.tracker';
import { ProviderRegistry } from './provider-registry';
@@ -85,7 +86,7 @@ export class MetadataFetchService {
return p.lookupById(existingId).then((r) => (r ? [r] : []));
}
const primary = await p.search(params);
const primary = filterAndRank(await p.search(params), params);
const hasIsbn = hasText(params.isbn);
if (!hasIsbn) return primary;
@@ -102,7 +103,7 @@ export class MetadataFetchService {
this.logger.log(`[${p.key}] ISBN search returned no results; falling back to non-ISBN search`);
const fallbackParams: MetadataSearchParams = { ...params, isbn: undefined };
const fallback = await p.search(fallbackParams);
const fallback = filterAndRank(await p.search(fallbackParams), fallbackParams);
this.logger.log(`[${p.key}] Non-ISBN fallback ${fallback.length > 0 ? `returned ${fallback.length} result(s)` : 'returned no results'}`);
return fallback;
}
@@ -163,47 +163,6 @@ describe('GoodreadsProvider', () => {
await expect(provider.search({ title: 'Test' })).rejects.toBeInstanceOf(ProviderThrottleError);
});
it('should sort results by relevance score', async () => {
const searchHtml = `
<a href="/book/show/1?from_srp=true">Exact Title</a>
<a href="/book/show/2?from_srp=true">Partial</a>
`;
const mockState1 = { 'Book:kca:1': { title: 'Exact Title' } };
const mockState2 = { 'Book:kca:2': { title: 'Partial Title' } };
const bookHtml1 = `<script id="__NEXT_DATA__">{"props":{"pageProps":{"apolloState":${JSON.stringify(mockState1)}}}}</script>`;
const bookHtml2 = `<script id="__NEXT_DATA__">{"props":{"pageProps":{"apolloState":${JSON.stringify(mockState2)}}}}</script>`;
global.fetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(searchHtml) })
.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(bookHtml1) })
.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(bookHtml2) });
const result = await provider.search({ title: 'Exact Title' });
expect(result).toHaveLength(2);
expect(result[0].title).toBe('Exact Title');
});
it('should score relevance by author', async () => {
const searchHtml = `<a href="/book/show/1?from_srp=true">Book</a>`;
const mockState = {
'Book:kca:1': {
title: 'Book',
primaryContributorEdge: { node: { __ref: 'Contributor:1' } },
},
'Contributor:1': { name: 'F. Scott Fitzgerald' },
};
const bookHtml = `<script id="__NEXT_DATA__">{"props":{"pageProps":{"apolloState":${JSON.stringify(mockState)}}}}</script>`;
global.fetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(searchHtml) })
.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(bookHtml) });
const result = await provider.search({ title: 'Book', author: 'Fitzgerald' });
expect(result).toHaveLength(1);
});
it('should use slug-based scoring in extractBookIds', async () => {
const searchHtml = `
<a href="/book/show/1.The_Great_Gatsby?from_srp=true">B1</a>
@@ -40,10 +40,6 @@ export class GoodreadsProvider implements IdentifiableProvider {
if (candidate) results.push(candidate);
}
if (results.length > 1 && (params.title || params.author)) {
results.sort((a, b) => scoreRelevance(b, params) - scoreRelevance(a, params));
}
return results;
}
@@ -157,48 +153,6 @@ function extractBookIds(html: string, titleHint: string | undefined, limit: numb
return scored.slice(0, limit).map((s) => s.id);
}
function normalizeStr(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9 ]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function scoreRelevance(candidate: MetadataCandidate, params: MetadataSearchParams): number {
let score = 0;
if (params.title && candidate.title) {
const qt = normalizeStr(params.title);
const ct = normalizeStr(candidate.title);
if (ct === qt) score += 10;
else if (ct.startsWith(qt)) score += 6;
else {
const queryWords = qt.split(' ').filter((w) => w.length > 1);
const candidateWords = new Set(ct.split(' '));
score += queryWords.filter((w) => candidateWords.has(w)).length * 2;
}
}
if (params.author && candidate.authors?.length) {
const qa = normalizeStr(params.author);
const qaWords = new Set(qa.split(' ').filter((w) => w.length > 2));
for (const author of candidate.authors) {
const ca = normalizeStr(author);
if (ca.includes(qa) || qa.includes(ca)) {
score += 5;
break;
}
if (ca.split(' ').some((w) => qaWords.has(w))) {
score += 2;
break;
}
}
}
return score;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest';
import * as fetchWithThrottleModule from '../../fetch-with-throttle';
import { HardcoverClient } from './hardcover.client';
vi.mock('../../fetch-with-throttle', () => ({
fetchWithThrottle: vi.fn(),
}));
describe('HardcoverClient', () => {
const client = new HardcoverClient();
const apiKey = 'test-api-key';
it('passes the apiKey directly to the Authorization header', async () => {
const mockFetch = vi.mocked(fetchWithThrottleModule.fetchWithThrottle);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: { books: [] } }),
} as Response);
await client.searchByIsbn('1234567890', apiKey);
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: apiKey,
}),
}),
);
});
it('returns empty array when API returns non-ok status', async () => {
const mockFetch = vi.mocked(fetchWithThrottleModule.fetchWithThrottle);
mockFetch.mockResolvedValue({
ok: false,
status: 404,
} as Response);
const result = await client.searchByIsbn('123', 'key');
expect(result).toEqual([]);
});
it('returns empty array when fetch fails', async () => {
const mockFetch = vi.mocked(fetchWithThrottleModule.fetchWithThrottle);
mockFetch.mockRejectedValue(new Error('Network error'));
const result = await client.searchBooks('query', 'key');
expect(result).toEqual([]);
});
it('rethrows ProviderThrottleError on 429', async () => {
const { ProviderThrottleError } = await import('../../provider-throttle.error');
const mockFetch = vi.mocked(fetchWithThrottleModule.fetchWithThrottle);
mockFetch.mockRejectedValue(new ProviderThrottleError('google', 100));
await expect(client.searchByIsbn('123', 'key')).rejects.toThrow(ProviderThrottleError);
});
});
@@ -119,7 +119,7 @@ export class HardcoverClient {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
Authorization: apiKey,
},
body: JSON.stringify({ query, variables }),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
@@ -5,10 +5,11 @@ vi.mock('../embedding/embedding.module', () => ({ EmbeddingModule: class Embeddi
import { MetadataModule } from './metadata.module';
import { MetadataEventsService } from './metadata-events.service';
import { MetadataService } from './metadata.service';
import { ComicMetadataService } from '../book/comic-metadata.service';
describe('MetadataModule', () => {
it('registers MetadataService provider/export', () => {
expect(Reflect.getMetadata('providers', MetadataModule)).toEqual([MetadataService, MetadataEventsService]);
expect(Reflect.getMetadata('providers', MetadataModule)).toEqual([MetadataService, MetadataEventsService, ComicMetadataService]);
expect(Reflect.getMetadata('exports', MetadataModule)).toEqual([MetadataService, MetadataEventsService]);
});
});