mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'.
|
||||
|
||||
Reference in New Issue
Block a user