feat(metadata): merge search and compare into one match sheet

Search and Compare Metadata become one two-pane sheet, shared with the Book Dock. Results are
ranked by match strength beside a field-by-field comparison with Keep/Use per row, word-level
diffs, other sources inline, a cover strip, and picks that survive switching results.
The search runs on open and a single source can be retried. No API change.
This commit is contained in:
neon
2026-09-29 04:53:34 +00:00
parent 1dd389563c
commit 6f673e2e59
39 changed files with 5191 additions and 2902 deletions
+6 -1
View File
@@ -31,7 +31,12 @@ export default defineConfigWithVueTs(
// podcast feature was swept clean of them. The rest of the app still carries several hundred, so
// widening this is its own task rather than a side effect of one feature's refactor.
{
files: ['src/features/podcast/**/*.vue', 'src/components/FormSheet.vue', 'src/features/collection/components/CollectionMembershipList.vue'],
files: [
'src/features/podcast/**/*.vue',
'src/components/FormSheet.vue',
'src/features/collection/components/CollectionMembershipList.vue',
'src/features/book/components/metadata-match/*.vue',
],
rules: { 'vue/v-on-handler-style': ['error', ['method', 'inline']] },
},
@@ -17,8 +17,8 @@ import {
SERIES_INDEX_MAX_LENGTH,
} from '@bookorbit/types'
import BookDockStatusBadge from './BookDockStatusBadge.vue'
import MetadataSearchPanel from '@/features/book/components/detail/tabs/MetadataSearchPanel.vue'
import MetadataDiffPanel from '@/features/book/components/detail/tabs/MetadataDiffPanel.vue'
import MetadataMatchWorkspace from '@/features/book/components/metadata-match/MetadataMatchWorkspace.vue'
import type { MetadataQuery } from '@/features/book/components/metadata-match/MetadataMatchQuery.vue'
import { useBookDockDetail } from '../composables/useBookDockDetail'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useMetadataSearch } from '@/features/book/composables/useMetadataSearch'
@@ -42,10 +42,10 @@ const { libraries, fetchLibraries: fetchLibs } = useLibraries()
const meta = computed(() => props.file.selectedMetadata ?? props.file.embeddedMetadata ?? ({} as BookDockMetadata))
const metaView = ref<'editor' | 'search' | 'diff'>('editor')
const selectedCandidate = ref<MetadataCandidate | null>(null)
const diffSource = ref<'search' | 'fetched'>('search')
/** Metadata fetched earlier, compared on its own without a search. */
const fetchedCandidate = ref<MetadataCandidate | null>(null)
const sheetWidthClass = computed(() => (metaView.value === 'editor' ? 'sm:w-md lg:w-lg' : 'sm:w-3/4 sm:max-w-4xl'))
const sheetWidthClass = computed(() => (metaView.value === 'editor' ? 'sm:w-md lg:w-lg' : 'sm:w-[min(100vw-3rem,80rem)] sm:max-w-none'))
const targetLibraryId = ref<number | null>(null)
const targetFolderId = ref<number | null>(null)
@@ -239,12 +239,17 @@ const {
filteredResults,
providerCounts,
interruptedProviders,
retryingProviders,
isStreaming,
hasSearched,
providers,
selectedProviders,
resultProviderOrder,
coverProviderOrder,
audioCoverProviderOrder,
loadProviders,
search,
retryProvider,
toggleProvider,
selectFieldRuleProviders,
clearProviderFilter,
@@ -308,33 +313,32 @@ const providerIds = computed<ProviderIds>(() => ({
aladin: passthroughMetadata.value.aladinId ?? null,
}))
function openSearch() {
async function openSearch() {
metaView.value = 'search'
loadProviders()
if (hasSearched.value) return
await loadProviders()
const defaults = searchDefaults.value
if (defaults.title || defaults.isbn) handleSearchSubmit({ title: defaults.title ?? '', author: defaults.author ?? '', isbn: defaults.isbn ?? '' })
}
// A dock file has one medium, so its search asks only the providers for it, and audio files get square art.
const fileMediaKind = computed(() => getBookMediaKind(props.file.format))
const fileCoverMedium = computed<CoverMedium>(() => (fileMediaKind.value === 'audiobook' ? 'audio' : 'ebook'))
function handleSearchSubmit(params: { title: string; author: string; isbn: string }) {
const fileCoverPriority = computed(() => (fileCoverMedium.value === 'audio' ? audioCoverProviderOrder.value : coverProviderOrder.value))
function handleSearchSubmit(params: MetadataQuery) {
const mediaKind = fileMediaKind.value
search(mediaKind === 'unknown' ? params : { ...params, mediaKind })
}
function selectCandidate(candidate: MetadataCandidate) {
selectedCandidate.value = candidate
diffSource.value = 'search'
metaView.value = 'diff'
function backToEditor() {
metaView.value = 'editor'
fetchedCandidate.value = null
}
function backFromDiff() {
if (diffSource.value === 'fetched') {
metaView.value = 'editor'
} else {
metaView.value = 'search'
}
selectedCandidate.value = null
function handleRetry(provider: MetadataProviderKey) {
void retryProvider(provider)
}
async function handleApply(patch: MetadataDiffApply) {
@@ -369,8 +373,7 @@ async function handleApply(patch: MetadataDiffApply) {
const updated = await saveMetadata(props.file.id, buildMetadataPatchFromForm())
if (updated) emit('updated', updated)
metaView.value = 'editor'
selectedCandidate.value = null
backToEditor()
}
const hasFetchedMetadata = computed(() => {
@@ -405,7 +408,7 @@ function onCurrentBookDockCoverError(event: Event) {
function openFetchedDiff() {
const f = props.file.fetchedMetadata
if (!f) return
selectedCandidate.value = {
fetchedCandidate.value = {
provider: 'auto' as MetadataProviderKey,
providerId: '',
title: f.title ?? '',
@@ -431,7 +434,6 @@ function openFetchedDiff() {
chapters: f.chapters ?? undefined,
comicMetadata: f.comicMetadata ?? undefined,
}
diffSource.value = 'fetched'
metaView.value = 'diff'
}
@@ -682,54 +684,48 @@ onMounted(() => {
</div>
</template>
<!-- Metadata search view -->
<template v-else-if="metaView === 'search'">
<!-- Metadata search and compare -->
<template v-else>
<div class="flex items-center gap-2 px-4 py-2 border-b border-border shrink-0">
<button
class="size-7 rounded-full flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
@click="metaView = 'editor'"
:aria-label="t('common.back')"
@click="backToEditor"
>
<ArrowLeft class="size-4" />
</button>
<Sparkles class="size-3.5 text-primary" />
<span class="text-sm font-medium">{{ t('bookDock.sheet.searchMetadata') }}</span>
</div>
<div class="flex-1 min-h-0">
<MetadataSearchPanel
:search-defaults="searchDefaults"
:providers="providers"
:filtered-results="filteredResults"
:provider-counts="providerCounts"
:selected-providers="selectedProviders"
:is-streaming="isStreaming"
:has-searched="hasSearched"
:interrupted-providers="interruptedProviders"
@search="handleSearchSubmit"
@toggle-provider="toggleProvider"
@clear-filter="clearProviderFilter"
@select-field-rules="selectFieldRuleProviders"
@select="selectCandidate"
/>
</div>
</template>
<!-- Metadata diff view -->
<template v-else-if="metaView === 'diff' && selectedCandidate">
<div class="flex-1 min-h-0">
<MetadataDiffPanel
:current="currentSource"
:candidates="diffSource === 'fetched' ? [selectedCandidate] : filteredResults"
:initial-candidate="selectedCandidate"
:filtered-results="diffSource === 'fetched' ? [selectedCandidate] : filteredResults"
:providers="providers"
:back-label="diffSource === 'fetched' ? t('common.back') : t('bookDock.sheet.results')"
:current-cover-url="currentBookDockCoverUrl"
:cover-medium="fileCoverMedium"
:provider-ids="providerIds"
@back="backFromDiff"
@apply="handleApply"
/>
<span class="text-sm font-medium">{{
metaView === 'diff' ? t('book.detail.editMetadata.searchDrawer.compareTitle') : t('bookDock.sheet.searchMetadata')
}}</span>
</div>
<MetadataMatchWorkspace
class="min-h-0 flex-1"
:current="currentSource"
:provider-ids="providerIds"
:current-cover-url="currentBookDockCoverUrl"
:cover-medium="fileCoverMedium"
:cover-priority="fileCoverPriority"
:search-defaults="searchDefaults"
:providers="providers"
:results="filteredResults"
:provider-counts="providerCounts"
:selected-providers="selectedProviders"
:interrupted-providers="interruptedProviders"
:retrying-providers="retryingProviders"
:provider-order="resultProviderOrder"
:is-streaming="isStreaming"
:has-searched="hasSearched"
:fixed-candidate="metaView === 'diff' ? fetchedCandidate : null"
:apply-hint="t('book.detail.editMetadata.match.footer.fileHint')"
@search="handleSearchSubmit"
@toggle-provider="toggleProvider"
@select-all="clearProviderFilter"
@select-field-rules="selectFieldRuleProviders"
@retry-provider="handleRetry"
@apply="handleApply"
@cancel="backToEditor"
/>
</template>
</div>
</div>
@@ -39,35 +39,34 @@ vi.mock('@/features/book/composables/useMetadataSearch', () => ({
useMetadataSearch: () => ({
filteredResults: ref(mocks.filteredResults),
providerCounts: reactive({}),
interruptedProviders: ref([]),
retryingProviders: ref([]),
resultProviderOrder: ref([]),
coverProviderOrder: ref(['amazon']),
audioCoverProviderOrder: ref(['audible']),
isStreaming: ref(false),
hasSearched: ref(false),
providers: ref([]),
selectedProviders: ref([]),
loadProviders: mocks.loadProviders,
search: mocks.search,
retryProvider: vi.fn<(...args: unknown[]) => Promise<void>>(),
toggleProvider: vi.fn<(...args: unknown[]) => void>(),
selectFieldRuleProviders: vi.fn<() => void>(),
clearProviderFilter: vi.fn<() => void>(),
}),
}))
const MetadataSearchPanelStub = defineComponent({
name: 'MetadataSearchPanel',
const WorkspaceStub = defineComponent({
name: 'MetadataMatchWorkspace',
props: {
searchDefaults: {
type: Object,
required: true,
},
searchDefaults: { type: Object, required: true },
coverMedium: { type: String, default: undefined },
coverPriority: { type: Array, default: undefined },
fixedCandidate: { type: Object, default: null },
},
emits: ['search', 'select'],
template: '<div data-test="metadata-search-panel" />',
})
const MetadataDiffPanelStub = defineComponent({
name: 'MetadataDiffPanel',
props: { coverMedium: { type: String, default: undefined } },
emits: ['apply'],
template: '<div data-test="metadata-diff-panel" />',
emits: ['search', 'apply', 'cancel'],
template: '<div data-test="metadata-match-workspace" />',
})
function makeFile(overrides: Partial<BookDockFile> = {}): BookDockFile {
@@ -98,8 +97,7 @@ function mountSheet(file: BookDockFile) {
props: { file },
global: {
stubs: {
MetadataSearchPanel: MetadataSearchPanelStub,
MetadataDiffPanel: MetadataDiffPanelStub,
MetadataMatchWorkspace: WorkspaceStub,
BookDockStatusBadge: true,
},
},
@@ -111,7 +109,7 @@ async function openSearchAndReadDefaults(file: BookDockFile): Promise<Record<str
const searchButton = wrapper.findAll('button').find((button) => button.text().trim() === 'Search')
expect(searchButton).toBeDefined()
await searchButton!.trigger('click')
return wrapper.getComponent(MetadataSearchPanelStub).props('searchDefaults') as Record<string, string | undefined>
return wrapper.getComponent(WorkspaceStub).props('searchDefaults') as Record<string, string | undefined>
}
describe('BookDockFileSheet', () => {
@@ -219,10 +217,8 @@ describe('BookDockFileSheet', () => {
const wrapper = mountSheet(makeFile())
const searchButton = wrapper.findAll('button').find((button) => button.text().trim() === 'Search')
await searchButton!.trigger('click')
wrapper.getComponent(MetadataSearchPanelStub).vm.$emit('select', candidate)
await wrapper.vm.$nextTick()
wrapper.getComponent(MetadataDiffPanelStub).vm.$emit('apply', {
wrapper.getComponent(WorkspaceStub).vm.$emit('apply', {
formPatch: {
title: 'Dune',
pageCount: 688,
@@ -259,6 +255,20 @@ describe('BookDockFileSheet', () => {
)
})
it('searches a docked file with its own name as soon as the search opens', async () => {
const wrapper = mountSheet(makeFile({ fileName: 'Dune.epub', format: 'epub' }))
const searchButton = wrapper.findAll('button').find((button) => button.text().trim() === 'Search')
await searchButton!.trigger('click')
await flushPromises()
expect(mocks.search).toHaveBeenCalledWith(expect.objectContaining({ title: 'Dune', mediaKind: 'ebook' }))
expect(wrapper.getComponent(WorkspaceStub).props('fixedCandidate')).toBeNull()
wrapper.getComponent(WorkspaceStub).vm.$emit('cancel')
await wrapper.vm.$nextTick()
expect(wrapper.findComponent(WorkspaceStub).exists()).toBe(false)
})
it('searches a docked audio file as an audiobook and stages the audiobook cover it picks', async () => {
const candidate: MetadataCandidate = { provider: 'audible', providerId: 'B0DUNE', title: 'Dune' }
mocks.filteredResults.push(candidate)
@@ -266,15 +276,13 @@ describe('BookDockFileSheet', () => {
const searchButton = wrapper.findAll('button').find((button) => button.text().trim() === 'Search')
await searchButton!.trigger('click')
wrapper.getComponent(MetadataSearchPanelStub).vm.$emit('search', { title: 'Dune', author: 'Frank Herbert', isbn: '' })
const workspace = wrapper.getComponent(WorkspaceStub)
workspace.vm.$emit('search', { title: 'Dune', author: 'Frank Herbert', isbn: '' })
expect(mocks.search).toHaveBeenLastCalledWith({ title: 'Dune', author: 'Frank Herbert', isbn: '', mediaKind: 'audiobook' })
expect(workspace.props('coverMedium')).toBe('audio')
expect(workspace.props('coverPriority')).toEqual(['audible'])
wrapper.getComponent(MetadataSearchPanelStub).vm.$emit('select', candidate)
await wrapper.vm.$nextTick()
const diff = wrapper.getComponent(MetadataDiffPanelStub)
expect(diff.props('coverMedium')).toBe('audio')
diff.vm.$emit('apply', { formPatch: {}, audioCoverUrl: 'https://covers.example/dune-audio.jpg' })
workspace.vm.$emit('apply', { formPatch: {}, audioCoverUrl: 'https://covers.example/dune-audio.jpg' })
await wrapper.vm.$nextTick()
expect(mocks.saveMetadata).toHaveBeenCalledWith(1, expect.objectContaining({ coverUrl: 'https://covers.example/dune-audio.jpg' }))
@@ -1,88 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Check, Loader2 } from '@lucide/vue'
import type { CoverMedium, MetadataCandidate, MetadataProviderInfo } from '@bookorbit/types'
import { getProviderLabel, hideOnError, providerBadgeStyle, toDisplayCoverUrl } from '../../../lib/metadata-fetch'
const props = defineProps<{
medium: CoverMedium
choices: MetadataCandidate[]
active: MetadataCandidate | null
picked: MetadataCandidate | null
providers: MetadataProviderInfo[]
searching: boolean
}>()
const emit = defineEmits<{ select: [candidate: MetadataCandidate] }>()
const { t } = useI18n()
const aspectRatio = computed(() => (props.medium === 'audio' ? '1/1' : '2/3'))
const copy = computed(() =>
props.medium === 'audio'
? {
group: t('book.detail.editMetadata.diffPanel.secondCover.audioResults'),
searching: t('book.detail.editMetadata.diffPanel.secondCover.searchingAudio'),
none: t('book.detail.editMetadata.diffPanel.secondCover.noAudio'),
}
: {
group: t('book.detail.editMetadata.diffPanel.secondCover.bookResults'),
searching: t('book.detail.editMetadata.diffPanel.secondCover.searchingBook'),
none: t('book.detail.editMetadata.diffPanel.secondCover.noBook'),
},
)
function providerLabel(candidate: MetadataCandidate): string {
return getProviderLabel(candidate.provider, props.providers)
}
function selectChoice(candidate: MetadataCandidate) {
emit('select', candidate)
}
</script>
<template>
<div class="mt-2.5">
<div v-if="choices.length" role="group" :aria-label="copy.group" class="flex items-center gap-1.5 overflow-x-auto p-0.5">
<button
v-for="(candidate, index) in choices"
:key="`${candidate.provider}:${candidate.providerId}:${index}`"
type="button"
class="relative w-12 shrink-0 overflow-hidden rounded-md bg-muted ring-1 transition-all focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
:class="candidate === active ? 'ring-2 ring-primary' : 'ring-border hover:ring-foreground/40'"
:style="{ aspectRatio }"
:aria-pressed="candidate === active"
:aria-label="
t('book.detail.editMetadata.diffPanel.secondCover.showCover', {
provider: providerLabel(candidate),
index: index + 1,
total: choices.length,
})
"
@click="selectChoice(candidate)"
>
<img :src="toDisplayCoverUrl(candidate.coverUrl)" alt="" class="h-full w-full object-contain" @error="hideOnError" />
<span
class="absolute inset-x-0.5 bottom-0.5 truncate rounded px-0.5 text-center text-[8px] font-bold"
:style="providerBadgeStyle(candidate.provider)"
aria-hidden="true"
>
{{ providerLabel(candidate) }}
</span>
<span
v-if="candidate === picked"
class="absolute end-0.5 top-0.5 flex size-3.5 items-center justify-center rounded-full bg-primary text-primary-foreground"
aria-hidden="true"
>
<Check class="size-2.5" />
</span>
</button>
</div>
<p v-if="searching" role="status" class="mt-1.5 inline-flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Loader2 class="size-3 animate-spin motion-reduce:animate-none" aria-hidden="true" />
{{ copy.searching }}
</p>
<p v-else-if="!choices.length" class="mt-1.5 text-[11px] text-muted-foreground">{{ copy.none }}</p>
</div>
</template>
@@ -1,515 +0,0 @@
<script setup lang="ts">
import { computed, inject, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowLeft, CopyCheck, Copy, CheckCheck, Eye, EyeOff, Info } from '@lucide/vue'
import type {
BookMetadataLockField,
CoverMedium,
MetadataCandidate,
MetadataProviderInfo,
MetadataProviderKey,
MetadataSource,
ProviderIds,
} from '@bookorbit/types'
import { useMetadataDiff, type DiffField, type DiffFieldKey, type MetadataDiffApply } from '../../../composables/useMetadataDiff'
import { useSecondCoverRow, type SecondCoverInput } from '../../../composables/useSecondCoverRow'
import { useCoverShapes } from '../../../composables/useCoverShapes'
import { coverLockField } from '../../../lib/cover-slots'
import {
getProviderColor,
getProviderLabel,
hideOnError,
providerActivePillStyle,
resolveCandidateDisplayTitle,
toDisplayCoverUrl,
} from '../../../lib/metadata-fetch'
import MetadataDiffRow from './MetadataDiffRow.vue'
import MetadataDiffCoverResults from './MetadataDiffCoverResults.vue'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../../lib/cover-aspect-ratio'
const props = defineProps<{
current: MetadataSource
candidates: MetadataCandidate[]
initialCandidate: MetadataCandidate
providers: MetadataProviderInfo[]
filteredResults: MetadataCandidate[]
backLabel?: string
currentCoverUrl?: string
providerIds?: ProviderIds
lockedFields?: BookMetadataLockField[]
/** The slot the main cover row fills. Defaults to the ebook slot. */
coverMedium?: CoverMedium
/** The other medium's cover row, for a book that has both media; fed by its own search. */
secondCover?: SecondCoverInput | null
}>()
const emit = defineEmits<{
back: []
apply: [MetadataDiffApply]
}>()
const { t } = useI18n()
const libraryAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const mainMedium = computed<CoverMedium>(() => props.coverMedium ?? 'ebook')
/** Result thumbnails take the main cover row's slot shape, or the library's when the panel has no slot. */
const coverAspectRatio = computed(() => {
if (!props.coverMedium) return libraryAspectRatio.value
return props.coverMedium === 'audio' ? '1/1' : '2/3'
})
const activeProvider = ref<MetadataProviderKey>(props.initialCandidate.provider)
const showUnchanged = ref(false)
const showSwitchHint = ref(false)
let switchHintTimer: ReturnType<typeof setTimeout> | null = null
interface ProviderResultMeta {
total: number
selectedIndex: number
}
// 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 candidate of props.filteredResults) {
if (!selectedPerProvider.has(candidate.provider)) {
selectedPerProvider.set(candidate.provider, candidate)
}
}
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 candidate of props.filteredResults) {
if (!seen.has(candidate.provider)) {
seen.add(candidate.provider)
out.push(selectedPerProvider.get(candidate.provider) ?? candidate)
}
}
return out
})
watch(representativeCandidates, (list) => {
if (!list.find((candidate) => candidate.provider === activeProvider.value)) {
activeProvider.value = list[0]?.provider ?? props.initialCandidate.provider
}
})
const activeCandidate = computed(
() =>
representativeCandidates.value.find((candidate) => candidate.provider === activeProvider.value) ??
representativeCandidates.value[0] ??
props.initialCandidate,
)
const { shapeOf: coverShapeOf } = useCoverShapes(() => [...representativeCandidates.value, ...(props.secondCover?.candidates ?? [])])
const {
fields,
picksPerProvider,
toggleField,
pickFieldFromProvider,
clearPicksForProvider,
copyAll,
copyMissing,
buildPatch,
hasCopied,
genreWriteMode,
setGenreWriteMode,
} = useMetadataDiff(
props.current,
representativeCandidates,
activeProvider,
computed(() => props.providers),
() => props.currentCoverUrl,
computed(() => props.providerIds),
computed(() => props.lockedFields ?? []),
{
coverMedium: mainMedium,
coverLabelKey: () =>
props.secondCover ? `book.detail.editMetadata.diff.fields.${mainMedium.value === 'audio' ? 'audioCover' : 'bookCover'}` : undefined,
coverShapeOf,
},
)
const secondCoverLocked = computed(() => Boolean(props.secondCover && (props.lockedFields ?? []).includes(coverLockField(props.secondCover.medium))))
const {
choices: secondCoverChoices,
active: secondCoverActive,
row: secondCoverRow,
picked: secondCoverPicked,
pickedCoverUrl: secondCoverPickedUrl,
select: selectSecondCover,
togglePick: toggleSecondCoverPick,
} = useSecondCoverRow(
() =>
props.secondCover
? {
medium: props.secondCover.medium,
candidates: props.secondCover.candidates,
priority: props.secondCover.priority,
currentUrl: props.secondCover.currentUrl,
locked: secondCoverLocked.value,
}
: null,
coverShapeOf,
)
const activeProviderLabel = computed(() => getProviderLabel(activeProvider.value, props.providers))
const resultsForActiveProvider = computed(() => props.filteredResults.filter((candidate) => candidate.provider === activeProvider.value))
const bookSeed = computed(() => props.current.title ?? t('book.detail.editMetadata.diffPanel.untitled'))
const bookAuthorLine = computed(() => props.current.authors.join(', ') || null)
const candidateSeed = computed(() => activeCandidate.value.title ?? t('book.detail.editMetadata.diffPanel.untitled'))
const candidateAuthorLine = computed(() => activeCandidate.value.authors?.join(', ') || null)
const selectedFieldCount = computed(() => {
let total = secondCoverPicked.value ? 1 : 0
for (const count of picksPerProvider.value.values()) {
total += count
}
return total
})
const hasSelection = computed(() => hasCopied.value || secondCoverPicked.value !== null)
const selectedCountLabel = computed(() => {
if (!selectedFieldCount.value) return t('book.detail.editMetadata.diffPanel.noFieldsSelected')
return t('book.detail.editMetadata.diffPanel.fieldsSelected', { count: selectedFieldCount.value })
})
const providerResultMeta = computed(() => {
const meta = new Map<MetadataProviderKey, ProviderResultMeta>()
for (const candidate of representativeCandidates.value) {
const matches = props.filteredResults.filter((c) => c.provider === candidate.provider)
const selected = selectedPerProvider.get(candidate.provider) ?? candidate
const idx = matches.findIndex((match) => isSameCandidate(match, selected))
meta.set(candidate.provider, {
total: matches.length || 1,
selectedIndex: idx >= 0 ? idx + 1 : 1,
})
}
return meta
})
const activeResultMeta = computed(
() =>
providerResultMeta.value.get(activeProvider.value) ?? {
total: 1,
selectedIndex: 1,
},
)
const visibleFields = computed(() => fields.value.filter((field) => showUnchanged.value || field.hasDiff || !field.bookValue))
/** The second cover row always shows, right after the main cover row, since its results arrive on their own. */
const displayRows = computed<{ field: DiffField; second: boolean }[]>(() => {
const rows = visibleFields.value.map((field) => ({ field, second: false }))
const second = secondCoverRow.value
if (!second) return rows
const coverIndex = rows.findIndex((row) => row.field.key === 'coverUrl')
rows.splice(coverIndex + 1, 0, { field: second, second: true })
return rows
})
function isSameCandidate(a: MetadataCandidate, b: MetadataCandidate): boolean {
if (a.provider !== b.provider) return false
if (a.providerId && b.providerId) return a.providerId === b.providerId
return a === b
}
function isActiveProvider(provider: MetadataProviderKey): boolean {
return activeProvider.value === provider
}
function getProviderAvailableCount(provider: MetadataProviderKey): number {
return providerResultMeta.value.get(provider)?.total ?? 0
}
function isSelectedForTab(candidate: MetadataCandidate): boolean {
const selected = selectedPerProvider.get(candidate.provider)
if (!selected) return false
return isSameCandidate(selected, candidate)
}
function clearSwitchHintTimer() {
if (switchHintTimer) {
clearTimeout(switchHintTimer)
switchHintTimer = null
}
}
function showResultSwitchHint() {
showSwitchHint.value = true
clearSwitchHintTimer()
switchHintTimer = setTimeout(() => {
showSwitchHint.value = false
switchHintTimer = null
}, 2800)
}
function selectResultForTab(candidate: MetadataCandidate) {
const current = selectedPerProvider.get(candidate.provider)
if (current && isSameCandidate(current, candidate)) return
const hadSelections = (picksPerProvider.value.get(candidate.provider) ?? 0) > 0
selectedPerProvider.set(candidate.provider, candidate)
clearPicksForProvider(candidate.provider)
if (hadSelections) showResultSwitchHint()
}
function setActiveProvider(provider: MetadataProviderKey) {
activeProvider.value = provider
}
function handlePickFromProvider(key: DiffFieldKey, provider: MetadataProviderKey) {
pickFieldFromProvider(key, provider)
}
function handleSecondCoverToggle() {
toggleSecondCoverPick()
}
function handleSecondCoverSelect(candidate: MetadataCandidate) {
selectSecondCover(candidate)
}
function candidateCoverUrl(candidate: MetadataCandidate): string {
return toDisplayCoverUrl(candidate.coverUrl)
}
function handleCopyAll() {
copyAll()
}
function handleCopyMissing() {
copyMissing()
}
function toggleShowUnchanged() {
showUnchanged.value = !showUnchanged.value
}
function goBack() {
emit('back')
}
function apply() {
const { formPatch, coverUrl } = buildPatch()
const covers: Partial<Record<CoverMedium, string>> = {}
if (coverUrl) covers[mainMedium.value] = coverUrl
const secondUrl = secondCoverPickedUrl.value
if (props.secondCover && secondUrl && !secondCoverLocked.value) covers[props.secondCover.medium] = secondUrl
emit('apply', {
formPatch,
...(covers.ebook ? { coverUrl: covers.ebook } : {}),
...(covers.audio ? { audioCoverUrl: covers.audio } : {}),
})
}
onBeforeUnmount(() => {
clearSwitchHintTimer()
})
</script>
<template>
<div class="flex flex-col h-full">
<!-- Header -->
<div class="shrink-0 border-b border-border bg-card/50 px-4 py-3">
<div class="flex items-center gap-2">
<button
class="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors shrink-0 group h-8 px-2.5 rounded-lg border border-border bg-background"
@click="goBack"
>
<ArrowLeft class="size-4 transition-transform group-hover:-translate-x-0.5" />
{{ backLabel ?? t('book.detail.editMetadata.diffPanel.results') }}
</button>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 overflow-x-auto whitespace-nowrap pb-0.5">
<button
v-for="candidate in representativeCandidates"
:key="candidate.provider"
class="h-6 px-2.5 rounded-full border inline-flex items-center gap-1.5 text-xs font-medium transition-all active:scale-[0.99] shrink-0"
:class="
isActiveProvider(candidate.provider)
? 'text-foreground'
: 'border-border bg-background hover:bg-muted text-muted-foreground hover:text-foreground'
"
:style="isActiveProvider(candidate.provider) ? providerActivePillStyle(candidate.provider) : {}"
@click="setActiveProvider(candidate.provider)"
>
<span>{{ getProviderLabel(candidate.provider, providers) }}</span>
<span class="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full text-[10px] font-semibold bg-black/10">
{{ getProviderAvailableCount(candidate.provider) }}
</span>
</button>
</div>
</div>
</div>
<!-- Within-provider result picker -->
<div v-if="resultsForActiveProvider.length > 1" class="mt-2.5">
<div class="flex items-center justify-between gap-2 mb-1.5">
<p class="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold">
{{ t('book.detail.editMetadata.diffPanel.providerMatches', { provider: activeProviderLabel }) }}
</p>
<p class="text-[10px] text-muted-foreground">
{{ t('book.detail.editMetadata.diffPanel.selectedOf', { selected: activeResultMeta.selectedIndex, total: activeResultMeta.total }) }}
</p>
</div>
<div class="flex items-center gap-2 overflow-x-auto pb-0.5">
<button
v-for="(candidate, index) in resultsForActiveProvider"
:key="candidate.providerId ?? index"
class="shrink-0 w-52 rounded-lg border bg-background/70 px-2 py-1.5 flex items-center gap-2 text-left transition-all active:scale-[0.98]"
:class="isSelectedForTab(candidate) ? 'border-primary/50 bg-primary/6' : 'border-border/60 hover:border-border'"
:style="isSelectedForTab(candidate) ? { boxShadow: `0 0 0 1px ${getProviderColor(candidate.provider)}` } : {}"
@click="selectResultForTab(candidate)"
>
<span class="relative w-10 shrink-0 rounded-md overflow-hidden bg-muted ring-1 ring-border/70" :style="{ aspectRatio: coverAspectRatio }">
<img
v-if="candidateCoverUrl(candidate)"
:src="candidateCoverUrl(candidate)"
alt=""
class="w-full h-full object-cover"
@error="hideOnError"
/>
<span v-else class="block w-full h-full bg-muted-foreground/10" />
</span>
<span class="min-w-0 flex-1">
<span class="block text-xs font-medium text-foreground line-clamp-1">{{ resolveCandidateDisplayTitle(candidate) }}</span>
<span class="block text-[10px] text-muted-foreground line-clamp-1">
{{ candidate.publishedDate ?? candidate.publishedYear ?? t('book.detail.editMetadata.diffPanel.yearUnknown') }} -
{{ t('book.detail.editMetadata.diffPanel.indexOf', { index: index + 1, total: resultsForActiveProvider.length }) }}
</span>
</span>
</button>
</div>
</div>
<p v-if="showSwitchHint" class="mt-2 text-[11px] text-muted-foreground flex items-center gap-1.5">
<Info class="size-3.5" />
{{ t('book.detail.editMetadata.diffPanel.switchedResult') }}
</p>
</div>
<!-- Diff rows -->
<div class="flex-1 overflow-y-auto px-4">
<div class="sticky top-0 z-10 -mx-4 px-4 py-2.5 border-b border-border bg-background/95 backdrop-blur-sm">
<div class="flex items-center gap-1.5 pb-1">
<div class="min-w-0 flex items-center gap-1.5 overflow-x-auto whitespace-nowrap pr-2">
<p v-if="hasSelection" class="shrink-0 text-xs text-muted-foreground inline-flex items-center gap-1.5">
<CheckCheck class="size-3.5 text-primary" />
{{ selectedCountLabel }}
</p>
<p v-else class="shrink-0 text-xs text-muted-foreground">{{ t('book.detail.editMetadata.diffPanel.selectFieldsToApply') }}</p>
<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="handleCopyMissing"
>
<Copy class="size-3" />
{{ t('book.detail.editMetadata.diffPanel.copyMissing') }}
</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="handleCopyAll"
>
<CopyCheck class="size-3" />
{{ t('book.detail.editMetadata.diffPanel.copyAll') }}
</button>
</div>
<button
class="h-7 px-2.5 rounded-lg border border-border bg-background text-xs text-muted-foreground hover:text-foreground hover:bg-muted transition-all active:scale-95 inline-flex items-center gap-1.5 shrink-0 ml-auto"
@click="toggleShowUnchanged"
>
<EyeOff v-if="showUnchanged" class="size-3.5" />
<Eye v-else class="size-3.5" />
{{ showUnchanged ? t('book.detail.editMetadata.diffPanel.hideUnchanged') : t('book.detail.editMetadata.diffPanel.showUnchanged') }}
</button>
</div>
<div class="grid grid-cols-[1fr_auto_1fr] gap-2 mt-2.5">
<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">{{ t('book.detail.editMetadata.diffPanel.current') }}</p>
</div>
<div class="w-11" />
<div class="flex items-center gap-1.5 min-w-0 overflow-hidden">
<div class="size-1.5 rounded-full" :style="{ backgroundColor: getProviderColor(activeProvider) }" />
<p class="text-xs font-semibold tracking-wider shrink-0" :style="{ color: getProviderColor(activeProvider) }">
{{ activeProviderLabel }}
</p>
<p class="text-xs text-muted-foreground truncate min-w-0">({{ activeCandidate.title }})</p>
</div>
</div>
</div>
<template v-for="row in displayRows" :key="row.field.key">
<MetadataDiffRow
v-if="row.second"
:field="row.field"
:active-provider="secondCoverActive?.provider ?? activeProvider"
:providers="providers"
:book-seed="bookSeed"
:book-author-line="bookAuthorLine"
:candidate-seed="candidateSeed"
:candidate-author-line="candidateAuthorLine"
@toggle="handleSecondCoverToggle"
>
<template #coverResults>
<MetadataDiffCoverResults
v-if="secondCover"
:medium="secondCover.medium"
:choices="secondCoverChoices"
:active="secondCoverActive"
:picked="secondCoverPicked"
:providers="providers"
:searching="secondCover.searching"
@select="handleSecondCoverSelect"
/>
</template>
</MetadataDiffRow>
<MetadataDiffRow
v-else
:field="row.field"
:active-provider="activeProvider"
:providers="providers"
:book-seed="bookSeed"
:book-author-line="bookAuthorLine"
:candidate-seed="candidateSeed"
:candidate-author-line="candidateAuthorLine"
:genre-write-mode="genreWriteMode"
@toggle="toggleField"
@pick-from-provider="handlePickFromProvider"
@update:genre-write-mode="setGenreWriteMode"
/>
</template>
<p v-if="displayRows.length === 0" class="py-8 text-center text-sm text-muted-foreground">
{{ showUnchanged ? t('book.detail.editMetadata.diffPanel.noMetadata') : t('book.detail.editMetadata.diffPanel.noChangedFields') }}
</p>
</div>
<!-- Footer -->
<div class="flex flex-wrap items-center justify-end gap-2 px-4 py-3 border-t border-border shrink-0 bg-card/30">
<div class="flex flex-wrap items-center gap-2">
<button class="h-8 px-3 rounded-lg border border-border bg-background text-sm hover:bg-muted transition-all active:scale-95" @click="goBack">
{{ t('common.cancel') }}
</button>
<button
class="relative h-8 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium transition-all disabled:opacity-40 hover:opacity-90 active:scale-95 overflow-hidden group"
:disabled="!hasSelection"
@click="apply"
>
<span class="absolute inset-0 bg-white/10 opacity-0 group-hover:opacity-100 transition-opacity" />
{{ t('book.detail.editMetadata.diffPanel.applyToForm') }}
</button>
</div>
</div>
</div>
</template>
@@ -1,490 +0,0 @@
<script setup lang="ts">
import { computed, inject, ref, useId, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowLeft, RotateCcw, CheckCircle2, X, ZoomIn, Layers } from '@lucide/vue'
import type { MetadataProviderInfo, MetadataProviderKey } from '@bookorbit/types'
import type { DiffField, DiffFieldKey, GenreWriteMode } from '../../../composables/useMetadataDiff'
import { hideOnError, providerBadgeStyle } from '../../../lib/metadata-fetch'
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../../lib/cover-aspect-ratio'
import BookCoverPlaceholder from '@/features/book/components/BookCoverPlaceholder.vue'
const props = defineProps<{
field: DiffField
activeProvider: MetadataProviderKey
providers: MetadataProviderInfo[]
bookSeed?: string
bookAuthorLine?: string | null
candidateSeed?: string
candidateAuthorLine?: string | null
genreWriteMode?: GenreWriteMode
}>()
const emit = defineEmits<{
toggle: [DiffFieldKey]
pickFromProvider: [key: DiffFieldKey, provider: MetadataProviderKey]
'update:genreWriteMode': [mode: GenreWriteMode]
}>()
const { t } = useI18n()
const libraryAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
/** A cover row's frames take its slot's shape; without a slot they follow the library. */
const coverAspectRatio = computed(() => {
if (props.field.coverMedium === 'audio') return '1/1'
if (props.field.coverMedium === 'ebook') return '2/3'
return libraryAspectRatio.value
})
const isAudioCover = computed(() => props.field.coverMedium === 'audio')
const coverShapeHintId = `cover-shape-hint-${useId()}`
/** Art of the other shape for this slot: offered, but named, since a letterboxed cover is a downgrade from a fitting one. */
const coverShapeHint = computed(() => {
if (props.field.candidateCoverFit !== 'mismatch' || !props.field.candidateDisplay) return ''
return isAudioCover.value
? t('book.detail.editMetadata.diff.coverShapeHint.portraitForAudio')
: t('book.detail.editMetadata.diff.coverShapeHint.squareForBook')
})
const lightboxSrc = ref<string | null>(null)
function openCurrentCoverPreview() {
if (props.field.isPicked) lightboxSrc.value = props.field.pickedDisplay || props.field.candidateDisplay
else if (props.field.bookValue) lightboxSrc.value = props.field.bookValue
}
function openCandidateCoverPreview() {
if (props.field.candidateDisplay) lightboxSrc.value = props.field.candidateDisplay
}
function handleCoverPreviewOpenChange(open: boolean) {
if (!open) lightboxSrc.value = null
}
const isCurrentExpanded = ref(false)
const isCandidateExpanded = ref(false)
const pickedProviderLabel = computed(() => {
if (!props.field.pickedProvider) return ''
return props.field.providerValues.find((pv) => pv.provider === props.field.pickedProvider)?.label ?? props.field.pickedProvider
})
const CLAMPABLE_TEXT_FIELDS = new Set<DiffFieldKey>([
'title',
'subtitle',
'authors',
'description',
'genres',
'narrators',
'comicPencillers',
'comicInkers',
'comicColorists',
'comicLetterers',
'comicCoverArtists',
'comicCharacters',
'comicTeams',
'comicLocations',
'comicStoryArcs',
])
const toggleLabel = computed(() =>
props.field.isPicked
? t('book.detail.editMetadata.diff.keepCurrent', { field: t(props.field.labelKey) })
: t('book.detail.editMetadata.diff.useNew', { field: t(props.field.labelKey) }),
)
const canClampCurrent = computed(
() => CLAMPABLE_TEXT_FIELDS.has(props.field.key) && props.field.currentDisplay.length > 160 && props.field.key !== 'sourceUrl',
)
const canClampCandidate = computed(
() => CLAMPABLE_TEXT_FIELDS.has(props.field.key) && props.field.candidateDisplay.length > 160 && props.field.key !== 'sourceUrl',
)
const currentTextClass = computed(() => (canClampCurrent.value && !isCurrentExpanded.value ? 'line-clamp-5' : ''))
const candidateTextClass = computed(() => (canClampCandidate.value && !isCandidateExpanded.value ? 'line-clamp-5' : ''))
function handleToggle() {
emit('toggle', props.field.key)
}
function handlePickFromProvider(provider: MetadataProviderKey) {
emit('pickFromProvider', props.field.key, provider)
}
function mergeGenres() {
emit('update:genreWriteMode', 'merge')
}
function replaceGenres() {
emit('update:genreWriteMode', 'replace')
}
function toggleCurrentExpanded() {
isCurrentExpanded.value = !isCurrentExpanded.value
}
function toggleCandidateExpanded() {
isCandidateExpanded.value = !isCandidateExpanded.value
}
watch(
() => [props.field.key, props.field.currentDisplay, props.field.candidateDisplay],
() => {
isCurrentExpanded.value = false
isCandidateExpanded.value = false
},
)
</script>
<template>
<!-- Cover row -->
<div v-if="field.isCover" class="py-3.5 border-b border-border/40">
<div class="mb-2.5 flex items-center gap-2">
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">{{ t(field.labelKey) }}</p>
<span v-if="field.isLocked" class="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[9px] font-medium text-primary">
{{ t('book.detail.editMetadata.diff.locked') }}
</span>
</div>
<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.isPicked ? 'ring-primary ring-2' : field.bookValue ? 'ring-border cursor-zoom-in' : 'ring-border opacity-50'"
:style="{ aspectRatio: coverAspectRatio }"
@click="openCurrentCoverPreview"
>
<template v-if="field.isPicked && field.pickedDisplay">
<img
:src="field.pickedDisplay"
alt=""
aria-hidden="true"
class="absolute inset-0 w-full h-full object-cover scale-110 blur-md brightness-75"
/>
<img
:src="field.pickedDisplay"
:alt="t('book.detail.editMetadata.diff.previewAlt')"
class="relative w-full h-full object-contain"
@error="hideOnError"
/>
</template>
<template v-else-if="field.bookValue">
<img :src="field.bookValue" alt="" aria-hidden="true" class="absolute inset-0 w-full h-full object-cover scale-110 blur-md brightness-75" />
<img
:src="field.bookValue"
:alt="t('book.detail.editMetadata.diff.currentCoverAlt')"
class="relative w-full h-full object-contain"
@error="hideOnError"
/>
</template>
<div v-else class="absolute inset-0">
<BookCoverPlaceholder :title="bookSeed ?? null" :author-line="bookAuthorLine ?? null" :is-audio="isAudioCover" :seed="bookSeed ?? 'book'" />
</div>
<div
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>
<!-- 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.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'
"
:disabled="field.isLocked"
:aria-label="toggleLabel"
:aria-pressed="field.isPicked"
:aria-describedby="coverShapeHint ? coverShapeHintId : undefined"
@click="handleToggle"
>
<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 && !field.isLocked">
<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="t('book.detail.editMetadata.diff.pickCoverFromProvider')"
>
<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">
{{ t('book.detail.editMetadata.diff.pickCoverSource') }}
</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="pv.isPicked ? 'bg-primary/8 ring-1 ring-inset ring-primary/20' : ''"
@click="handlePickFromProvider(pv.provider)"
>
<span class="relative shrink-0 w-8 rounded overflow-hidden bg-muted" :style="{ aspectRatio: coverAspectRatio }">
<img :src="pv.display" alt="" class="w-full h-full object-contain" @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.isPicked && field.pickedFromActive
? 'ring-primary ring-2 cursor-zoom-in'
: 'ring-border cursor-zoom-in'
: 'ring-border opacity-50'
"
:style="{ aspectRatio: coverAspectRatio }"
@click="openCandidateCoverPreview"
>
<template v-if="field.candidateDisplay">
<img
:src="field.candidateDisplay"
alt=""
aria-hidden="true"
class="absolute inset-0 w-full h-full object-cover scale-110 blur-md brightness-75"
/>
<img
:src="field.candidateDisplay"
:alt="t('book.detail.editMetadata.diff.newCoverAlt')"
class="relative w-full h-full object-contain"
@error="hideOnError"
/>
</template>
<div v-else class="absolute inset-0">
<BookCoverPlaceholder
:title="candidateSeed ?? null"
:author-line="candidateAuthorLine ?? null"
:is-audio="isAudioCover"
:seed="candidateSeed ?? 'candidate'"
/>
</div>
<div
v-if="field.candidateDisplay"
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>
</div>
</div>
<div v-if="coverShapeHint" class="grid grid-cols-[1fr_auto_1fr] gap-2">
<p :id="coverShapeHintId" class="col-start-3 mt-1.5 min-w-0 text-[11px] leading-snug text-muted-foreground">{{ coverShapeHint }}</p>
</div>
<div v-if="$slots.coverResults" class="grid grid-cols-[1fr_auto_1fr] gap-2">
<div class="col-start-3 min-w-0">
<slot name="coverResults" />
</div>
</div>
</div>
<!-- Text row -->
<div v-else class="py-2.5 border-b border-border/40">
<div class="mb-1.5 flex items-center gap-2">
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">{{ t(field.labelKey) }}</p>
<span v-if="field.isLocked" class="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[9px] font-medium text-primary">
{{ t('book.detail.editMetadata.diff.locked') }}
</span>
<div
v-if="field.key === 'genres'"
class="ms-auto inline-flex rounded-md border border-input bg-muted p-0.5"
role="radiogroup"
:aria-label="t('book.detail.editMetadata.diff.genreWriteMode.label')"
>
<button
type="button"
role="radio"
:aria-checked="genreWriteMode !== 'replace'"
:disabled="field.isLocked"
class="h-6 rounded px-2 text-[10px] font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50"
:class="genreWriteMode !== 'replace' ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'"
@click="mergeGenres"
>
{{ t('book.detail.editMetadata.diff.genreWriteMode.merge') }}
</button>
<button
type="button"
role="radio"
:aria-checked="genreWriteMode === 'replace'"
:disabled="field.isLocked"
class="h-6 rounded px-2 text-[10px] font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50"
:class="genreWriteMode === 'replace' ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'"
@click="replaceGenres"
>
{{ t('book.detail.editMetadata.diff.genreWriteMode.replace') }}
</button>
</div>
</div>
<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.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">{{ t('book.detail.editMetadata.diffPanel.current') }}</p>
<p
class="wrap-break-word leading-snug text-sm w-full"
:class="[!field.currentDisplay ? 'text-muted-foreground italic' : 'text-foreground', currentTextClass]"
>
{{ field.currentDisplay || t('book.detail.editMetadata.diff.empty') }}
</p>
<button v-if="canClampCurrent" class="mt-1 text-[10px] font-medium text-primary hover:underline" @click="toggleCurrentExpanded">
{{ isCurrentExpanded ? t('book.detail.editMetadata.diff.showLess') : t('book.detail.editMetadata.diff.showMore') }}
</button>
<!-- 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>
<!-- 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.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'
"
:disabled="field.isLocked"
:aria-label="toggleLabel"
:aria-pressed="field.isPicked"
@click="handleToggle"
>
<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 shrink-0">
<CheckCircle2 class="size-3.5 text-muted-foreground" />
</div>
<div v-else class="size-8 shrink-0" />
<Popover v-if="field.isCopyable && field.providerValues.length >= 2 && !field.isLocked">
<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="t('book.detail.editMetadata.diff.pickFromProvider')"
>
<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">
{{ t('book.detail.editMetadata.diff.pickSource') }}
</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="pv.isPicked ? '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 -->
<div
class="min-w-0 rounded-lg px-3 py-2 transition-all duration-200"
:class="
!field.hasDiff ? 'bg-muted/30 opacity-50' : field.isPicked && field.pickedFromActive ? 'bg-primary/8 ring-1 ring-primary/20' : 'bg-muted/40'
"
>
<p class="text-[10px] font-medium text-muted-foreground mb-0.5 sm:hidden">{{ t('book.detail.editMetadata.diffPanel.new') }}</p>
<a
v-if="field.key === 'sourceUrl' && field.candidateDisplay"
:href="field.candidateDisplay"
target="_blank"
rel="noopener noreferrer"
class="wrap-break-word leading-snug text-sm w-full text-primary hover:underline"
>
{{ field.candidateDisplay }}
</a>
<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', candidateTextClass]"
>
{{ field.candidateDisplay }}
</p>
<button
v-if="canClampCandidate && field.key !== 'sourceUrl'"
class="mt-1 text-[10px] font-medium text-primary hover:underline"
@click="toggleCandidateExpanded"
>
{{ isCandidateExpanded ? t('book.detail.editMetadata.diff.showLess') : t('book.detail.editMetadata.diff.showMore') }}
</button>
</div>
</div>
</div>
<!-- A nested dialog, so a drawer or sheet around the diff treats it as its own child layer. -->
<DialogRoot :open="lightboxSrc !== null" @update:open="handleCoverPreviewOpenChange">
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-[60] bg-scrim-media data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 motion-reduce:animate-none"
/>
<DialogContent
:aria-describedby="undefined"
class="fixed left-1/2 top-1/2 z-[60] -translate-x-1/2 -translate-y-1/2 outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 motion-reduce:animate-none"
>
<DialogTitle class="sr-only">{{ t('book.detail.coverLightbox.title') }}</DialogTitle>
<img
v-if="lightboxSrc"
:src="lightboxSrc"
:alt="t('book.detail.editMetadata.diff.coverPreviewAlt')"
class="max-h-[85vh] max-w-[85vw] rounded-lg shadow-2xl object-contain"
@error="hideOnError"
/>
<DialogClose
class="absolute -top-3 -right-3 rounded-full border border-border bg-background p-1 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:aria-label="t('common.close')"
>
<X class="size-4" aria-hidden="true" />
</DialogClose>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
@@ -1,50 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type { MetadataCandidate, MetadataProviderInfo } from '@bookorbit/types'
import MetadataResultCard from './MetadataResultCard.vue'
const providers: MetadataProviderInfo[] = [{ key: 'comicvine', label: 'ComicVine', identifiable: true }]
function makeCandidate(overrides: Partial<MetadataCandidate> = {}): MetadataCandidate {
return {
provider: 'comicvine',
providerId: '777',
title: 'The Origin',
coverUrl: 'https://example.com/cover.jpg',
...overrides,
}
}
describe('MetadataResultCard', () => {
it('renders displayTitle as the headline when present', () => {
const wrapper = mount(MetadataResultCard, {
props: { candidate: makeCandidate({ displayTitle: 'Series Name #12.5 - The Origin' }), providers },
})
expect(wrapper.text()).toContain('Series Name #12.5 - The Origin')
})
it('falls back to title as the headline when displayTitle is absent', () => {
const wrapper = mount(MetadataResultCard, {
props: { candidate: makeCandidate({ title: 'The Way of Kings', displayTitle: undefined }), providers: [] },
})
expect(wrapper.text()).toContain('The Way of Kings')
})
it('still labels an unnamed comic issue, which has a displayTitle but no title', () => {
const wrapper = mount(MetadataResultCard, {
props: { candidate: makeCandidate({ title: undefined, displayTitle: 'Series Name #7' }), providers },
})
expect(wrapper.text()).toContain('Series Name #7')
})
it('hides the cover from assistive technology because the headline already names the result', () => {
const wrapper = mount(MetadataResultCard, {
props: { candidate: makeCandidate({ displayTitle: 'Series Name #12.5 - The Origin' }), providers },
})
expect(wrapper.find('img').attributes('alt')).toBe('')
})
})
@@ -1,72 +0,0 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import type { MetadataCandidate, MetadataProviderInfo } from '@bookorbit/types'
import { Star } from '@lucide/vue'
import { getProviderLabel, hideOnError, providerBadgeStyle, resolveCandidateDisplayTitle, toDisplayCoverUrl } from '../../../lib/metadata-fetch'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../../lib/cover-aspect-ratio'
import { formatCommunityRatingValue } from '../../../lib/community-rating'
import { displayPublishedDate } from '../../../lib/published-date'
import BookCoverPlaceholder from '@/features/book/components/BookCoverPlaceholder.vue'
const props = defineProps<{
candidate: MetadataCandidate
providers: MetadataProviderInfo[]
}>()
const emit = defineEmits<{ select: [MetadataCandidate] }>()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const providerLabel = computed(() => getProviderLabel(props.candidate.provider, props.providers))
const displayCoverUrl = computed(() => toDisplayCoverUrl(props.candidate.coverUrl))
const displayTitle = computed(() => resolveCandidateDisplayTitle(props.candidate))
const candidateSeed = computed(() => displayTitle.value ?? props.candidate.provider)
const candidateAuthorLine = computed(() => props.candidate.authors?.join(', ') || null)
const communityRatingDisplay = computed(() => formatCommunityRatingValue(props.candidate.communityRating, props.candidate.communityRatingCount))
const publishedDisplay = computed(() => displayPublishedDate(props.candidate.publishedDate, props.candidate.publishedYear))
function handleSelect() {
emit('select', props.candidate)
}
</script>
<template>
<button
class="group relative flex flex-row gap-3.5 p-3 rounded-2xl 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="handleSelect"
>
<!-- Cover -->
<span class="relative shrink-0 rounded-lg overflow-hidden bg-muted block shadow-sm" :style="{ width: '88px', aspectRatio: coverAspectRatio }">
<img
v-if="displayCoverUrl"
:src="displayCoverUrl"
alt=""
class="w-full h-full object-contain transition-transform duration-500 group-hover:scale-105"
@error="hideOnError"
/>
<BookCoverPlaceholder v-else :title="candidateSeed" :author-line="candidateAuthorLine" :is-audio="false" :seed="candidateSeed" />
</span>
<!-- Info -->
<span class="flex-1 min-w-0 flex flex-col justify-center gap-1.5 py-0.5">
<span class="text-sm font-semibold leading-snug line-clamp-2 text-foreground">{{ displayTitle }}</span>
<span v-if="candidate.authors?.length" class="text-xs text-muted-foreground line-clamp-1 block leading-tight">
{{ candidate.authors.join(', ') }}
</span>
<span v-if="candidate.subtitle" class="text-xs text-muted-foreground line-clamp-2 leading-snug">
{{ candidate.subtitle }}
</span>
<span class="flex items-center gap-1.5 flex-wrap">
<span class="inline-flex items-center text-[10px] font-bold px-1.5 py-0.5 rounded-md" :style="providerBadgeStyle(candidate.provider)">
{{ providerLabel }}
</span>
<span v-if="publishedDisplay" class="text-[10px] text-muted-foreground tabular-nums">{{ publishedDisplay }}</span>
<span v-if="candidate.pageCount" class="text-[10px] text-muted-foreground tabular-nums">{{ candidate.pageCount }}p</span>
<span v-if="communityRatingDisplay" class="inline-flex items-center gap-1 text-[10px] text-muted-foreground tabular-nums">
<Star class="size-3 text-primary" />
{{ communityRatingDisplay }}
</span>
</span>
</span>
</button>
</template>
@@ -1,5 +1,5 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { MetadataProviderKey, type BookDetail } from '@bookorbit/types'
import MetadataSearchDrawer from './MetadataSearchDrawer.vue'
@@ -8,10 +8,11 @@ type SearchCall = { title?: string; author?: string; isbn?: string; bookId?: num
const metadataSearchMocks = vi.hoisted(() => ({
instances: 0,
loadProviders: vi.fn<(bookId?: number) => void>(),
loadProviders: vi.fn<(bookId?: number) => Promise<void>>(async () => {}),
search: vi.fn<(params: SearchCall) => void>(),
secondSearch: vi.fn<(params: SearchCall) => void>(),
toggleProvider: vi.fn<(provider: string) => void>(),
retryProvider: vi.fn<(provider: string) => Promise<void>>(async () => {}),
selectFieldRuleProviders: vi.fn<() => void>(),
clearProviderFilter: vi.fn<() => void>(),
}))
@@ -33,6 +34,9 @@ vi.mock('../../../composables/useMetadataSearch', async () => {
results: vue.ref([]),
filteredResults: vue.ref([]),
providerCounts: vue.reactive({}),
interruptedProviders: vue.ref([]),
retryingProviders: vue.ref([]),
resultProviderOrder: vue.ref(['google']),
isStreaming: vue.ref(false),
hasSearched: vue.ref(true),
providers: vue.ref([{ key: 'google', label: 'Google Books', identifiable: true }]),
@@ -41,6 +45,7 @@ vi.mock('../../../composables/useMetadataSearch', async () => {
audioCoverProviderOrder: vue.ref(['audible', 'itunes']),
loadProviders: metadataSearchMocks.loadProviders,
search: first ? metadataSearchMocks.search : metadataSearchMocks.secondSearch,
retryProvider: metadataSearchMocks.retryProvider,
toggleProvider: metadataSearchMocks.toggleProvider,
selectFieldRuleProviders: metadataSearchMocks.selectFieldRuleProviders,
clearProviderFilter: metadataSearchMocks.clearProviderFilter,
@@ -49,36 +54,19 @@ vi.mock('../../../composables/useMetadataSearch', async () => {
}
})
const MetadataSearchPanelStub = defineComponent({
name: 'MetadataSearchPanel',
emits: ['search', 'toggleProvider', 'clearFilter', 'selectFieldRules'],
const WorkspaceStub = defineComponent({
name: 'MetadataMatchWorkspace',
props: ['coverMedium', 'currentCoverUrl', 'secondCover', 'searchDefaults', 'coverPriority'],
emits: ['search', 'toggleProvider', 'selectAll', 'selectFieldRules', 'retryProvider', 'apply', 'cancel'],
setup(_, { emit }) {
return () =>
h('div', { 'data-testid': 'metadata-search-panel' }, [
h(
'button',
{
'data-testid': 'search',
onClick: () => emit('search', { title: 'Dune', author: 'Frank Herbert', isbn: '' }),
},
'Search',
),
h(
'button',
{
'data-testid': 'toggle-google',
onClick: () => emit('toggleProvider', MetadataProviderKey.GOOGLE),
},
'Google Books',
),
h(
'button',
{
'data-testid': 'clear-filter',
onClick: () => emit('clearFilter'),
},
'All',
),
h('div', { 'data-testid': 'metadata-match-workspace' }, [
h('button', { 'data-testid': 'search', onClick: () => emit('search', { title: 'Dune', author: 'Frank Herbert', isbn: '' }) }, 'Search'),
h('button', { 'data-testid': 'toggle-google', onClick: () => emit('toggleProvider', MetadataProviderKey.GOOGLE) }, 'Google Books'),
h('button', { 'data-testid': 'select-all', onClick: () => emit('selectAll') }, 'All'),
h('button', { 'data-testid': 'retry-google', onClick: () => emit('retryProvider', MetadataProviderKey.GOOGLE) }, 'Retry'),
h('button', { 'data-testid': 'apply', onClick: () => emit('apply', { formPatch: { title: 'Dune' } }) }, 'Apply'),
h('button', { 'data-testid': 'cancel', onClick: () => emit('cancel') }, 'Cancel'),
])
},
})
@@ -120,8 +108,7 @@ function mountDrawer(files: BookDetail['files'] = [], overrides: Partial<BookDet
stubs: {
// The sheet portals its content to <body>; render it in place so the panel can be found.
DialogPortal: { template: '<div><slot /></div>' },
MetadataSearchPanel: MetadataSearchPanelStub,
MetadataDiffPanel: true,
MetadataMatchWorkspace: WorkspaceStub,
},
},
})
@@ -142,33 +129,64 @@ describe('MetadataSearchDrawer', () => {
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('filters provider tabs without re-running the last metadata search', async () => {
const wrapper = mountDrawer()
it("runs the search with the book's details as soon as the providers are loaded", async () => {
mountDrawer([], { isbn13: '9780441013593' } as Partial<BookDetail>)
await flushPromises()
await wrapper.find('[data-testid="search"]').trigger('click')
expect(metadataSearchMocks.search).toHaveBeenCalledTimes(1)
expect(metadataSearchMocks.search).toHaveBeenLastCalledWith({
expect(metadataSearchMocks.loadProviders).toHaveBeenCalledWith(42)
expect(metadataSearchMocks.search).toHaveBeenCalledWith({
title: 'Dune',
author: 'Frank Herbert',
isbn: '',
isbn: '9780441013593',
bookId: 42,
isAudiobook: false,
})
})
it('waits for a query when the book has neither a title nor an ISBN', async () => {
mountDrawer([], { title: null } as unknown as Partial<BookDetail>)
await flushPromises()
expect(metadataSearchMocks.search).not.toHaveBeenCalled()
})
it('filters sources and retries one without re-running the whole search', async () => {
const wrapper = mountDrawer()
await flushPromises()
metadataSearchMocks.search.mockClear()
await wrapper.find('[data-testid="search"]').trigger('click')
expect(metadataSearchMocks.search).toHaveBeenCalledTimes(1)
expect(metadataSearchMocks.search).toHaveBeenLastCalledWith({ title: 'Dune', author: 'Frank Herbert', isbn: '', bookId: 42, isAudiobook: false })
await wrapper.find('[data-testid="toggle-google"]').trigger('click')
await wrapper.find('[data-testid="clear-filter"]').trigger('click')
await wrapper.find('[data-testid="select-all"]').trigger('click')
await wrapper.find('[data-testid="retry-google"]').trigger('click')
expect(metadataSearchMocks.toggleProvider).toHaveBeenCalledWith(MetadataProviderKey.GOOGLE)
expect(metadataSearchMocks.clearProviderFilter).toHaveBeenCalledTimes(1)
expect(metadataSearchMocks.retryProvider).toHaveBeenCalledWith(MetadataProviderKey.GOOGLE)
expect(metadataSearchMocks.search).toHaveBeenCalledTimes(1)
})
it('hands the applied changes up and closes, and closes on cancel', async () => {
const wrapper = mountDrawer()
await wrapper.find('[data-testid="apply"]').trigger('click')
expect(wrapper.emitted('apply')).toEqual([[{ formPatch: { title: 'Dune' } }]])
expect(wrapper.emitted('close')).toHaveLength(1)
await wrapper.find('[data-testid="cancel"]').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(2)
})
it('searches ebook metadata for a book whose primary file is an ebook', async () => {
const wrapper = mountDrawer([makeFile('epub', 'primary'), makeFile('m4b', 'content')])
await wrapper.find('[data-testid="search"]').trigger('click')
expect(metadataSearchMocks.search).toHaveBeenLastCalledWith(expect.objectContaining({ isAudiobook: false }))
expect(wrapper.getComponent(WorkspaceStub).props('coverMedium')).toBe('ebook')
})
it('searches audiobook metadata for a book whose primary file is audio', async () => {
@@ -177,6 +195,8 @@ describe('MetadataSearchDrawer', () => {
await wrapper.find('[data-testid="search"]').trigger('click')
expect(metadataSearchMocks.search).toHaveBeenLastCalledWith(expect.objectContaining({ isAudiobook: true }))
expect(wrapper.getComponent(WorkspaceStub).props('coverMedium')).toBe('audio')
expect(wrapper.getComponent(WorkspaceStub).props('coverPriority')).toEqual(['audible', 'itunes'])
})
it('searches the other medium too for a book with both, without the ISBN and with its cover rule providers', async () => {
@@ -191,6 +211,7 @@ describe('MetadataSearchDrawer', () => {
mediaKind: 'audiobook',
providers: ['audible', 'itunes'],
})
expect(wrapper.getComponent(WorkspaceStub).props('secondCover')).toMatchObject({ medium: 'audio', priority: ['audible', 'itunes'] })
})
it('searches the book edition as the other medium for an audiobook-first book', async () => {
@@ -207,5 +228,6 @@ describe('MetadataSearchDrawer', () => {
await wrapper.find('[data-testid="search"]').trigger('click')
expect(metadataSearchMocks.secondSearch).not.toHaveBeenCalled()
expect(wrapper.getComponent(WorkspaceStub).props('secondCover')).toBeNull()
})
})
@@ -1,18 +1,19 @@
<script setup lang="ts">
import { computed, inject, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { X, Sparkles } from '@lucide/vue'
import { X } from '@lucide/vue'
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
import { getBookMediaProfile } from '@bookorbit/types'
import type { BookDetail, BookMetadataLockField, CoverMedium, MetadataCandidate, MetadataProviderKey, MetadataSource } from '@bookorbit/types'
import type { BookDetail, BookMetadataLockField, CoverMedium, MetadataProviderKey, MetadataSource } from '@bookorbit/types'
import { useMetadataSearch } from '../../../composables/useMetadataSearch'
import { useCoverVersions } from '../../../composables/useCoverVersions'
import type { MetadataDiffApply } from '../../../composables/useMetadataDiff'
import type { SecondCoverInput } from '../../../composables/useSecondCoverRow'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../../lib/cover-aspect-ratio'
import { coverFieldMedium, coverTileState, otherMedium } from '../../../lib/cover-slots'
import MetadataSearchPanel from './MetadataSearchPanel.vue'
import MetadataDiffPanel from './MetadataDiffPanel.vue'
import MetadataMatchWorkspace from '../../metadata-match/MetadataMatchWorkspace.vue'
import MetadataMatchShortcuts from '../../metadata-match/MetadataMatchShortcuts.vue'
import type { MetadataQuery } from '../../metadata-match/MetadataMatchQuery.vue'
const props = defineProps<{ book: BookDetail; lockedFields: BookMetadataLockField[] }>()
const emit = defineEmits<{
@@ -74,12 +75,15 @@ const {
filteredResults,
providerCounts,
interruptedProviders,
retryingProviders,
isStreaming,
hasSearched,
providers,
selectedProviders,
resultProviderOrder,
loadProviders,
search,
retryProvider,
toggleProvider,
selectFieldRuleProviders,
clearProviderFilter,
@@ -89,6 +93,8 @@ const {
const secondSearch = useMetadataSearch()
const { results: secondResults, isStreaming: secondSearching } = secondSearch
const secondCoverPriority = computed(() => (secondCoverMedium.value === 'audio' ? audioCoverProviderOrder.value : coverProviderOrder.value))
const mainCoverPriority = computed(() => (mainCoverMedium.value === 'audio' ? audioCoverProviderOrder.value : coverProviderOrder.value))
const secondCover = computed<SecondCoverInput | null>(() => {
const medium = secondCoverMedium.value
if (!medium) return null
@@ -100,26 +106,28 @@ const secondCover = computed<SecondCoverInput | null>(() => {
searching: secondSearching.value,
}
})
const secondCoverPriority = computed(() => (secondCoverMedium.value === 'audio' ? audioCoverProviderOrder.value : coverProviderOrder.value))
const view = ref<'search' | 'diff'>('search')
const selectedCandidate = ref<MetadataCandidate | null>(null)
const drawerTitle = computed(() =>
view.value === 'search' ? t('book.detail.editMetadata.searchDrawer.searchTitle') : t('book.detail.editMetadata.searchDrawer.compareTitle'),
)
const drawerSubtitle = computed(() =>
view.value === 'search' ? t('book.detail.editMetadata.searchDrawer.searchSubtitle') : t('book.detail.editMetadata.searchDrawer.compareSubtitle'),
const subtitle = computed(() =>
[
props.book.title,
props.book.authors.map((author) => author.name).join(', '),
t(isAudiobookSearch.value ? 'book.detail.editMetadata.match.mediumAudiobook' : 'book.detail.editMetadata.match.mediumEbook'),
]
.filter(Boolean)
.join(' \u00b7 '),
)
onMounted(() => {
loadProviders(props.book.id)
onMounted(async () => {
await loadProviders(props.book.id)
const defaults = searchDefaults.value
if (defaults.title || defaults.isbn) runMetadataSearch({ title: defaults.title ?? '', author: defaults.author ?? '', isbn: defaults.isbn ?? '' })
})
function handleOpenChange(open: boolean) {
if (!open) emit('close')
}
function runMetadataSearch(params: { title: string; author: string; isbn: string }) {
function runMetadataSearch(params: MetadataQuery) {
search({ ...params, bookId: props.book.id, isAudiobook: isAudiobookSearch.value })
const medium = secondCoverMedium.value
if (!medium) return
@@ -133,17 +141,11 @@ function runMetadataSearch(params: { title: string; author: string; isbn: string
})
}
function handleSearch(params: { title: string; author: string; isbn: string }) {
selectedCandidate.value = null
view.value = 'search'
runMetadataSearch(params)
}
function handleToggleProvider(provider: MetadataProviderKey) {
toggleProvider(provider)
}
function handleClearProviderFilter() {
function handleSelectAll() {
clearProviderFilter()
}
@@ -151,90 +153,75 @@ function handleSelectFieldRules() {
selectFieldRuleProviders()
}
function handleSelect(candidate: MetadataCandidate) {
selectedCandidate.value = candidate
view.value = 'diff'
}
function backToSearch() {
view.value = 'search'
function handleRetry(provider: MetadataProviderKey) {
void retryProvider(provider)
}
function handleApply(patch: MetadataDiffApply) {
emit('apply', patch)
emit('close')
}
function handleCancel() {
emit('close')
}
</script>
<template>
<Sheet :open="true" @update:open="handleOpenChange">
<SheetContent side="right" hide-close class="w-full gap-0 overflow-hidden border-border p-0 shadow-2xl sm:w-3/4 sm:max-w-5xl">
<!-- 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 -->
<div class="absolute top-0 right-0 w-64 h-32 bg-primary/5 rounded-full blur-3xl pointer-events-none" />
<SheetClose
class="absolute top-3 right-3 z-10 size-7 rounded-full flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-all hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-label="t('common.close')"
>
<X class="size-4" aria-hidden="true" />
</SheetClose>
<!-- Title bar -->
<div class="flex items-center gap-2.5 px-4 py-3 border-b border-border shrink-0 pr-12">
<div class="size-7 rounded-lg bg-primary/10 flex items-center justify-center ring-1 ring-primary/20 shrink-0">
<Sparkles class="size-3.5 text-primary" aria-hidden="true" />
</div>
<div class="min-w-0">
<SheetTitle class="text-sm font-semibold">{{ drawerTitle }}</SheetTitle>
<SheetDescription class="text-xs text-muted-foreground line-clamp-1">{{ drawerSubtitle }}</SheetDescription>
<SheetContent
side="right"
hide-close
class="w-full gap-0 overflow-hidden border-border p-0 shadow-2xl sm:w-[min(100vw-3rem,88rem)] sm:max-w-none"
>
<div class="h-px w-full shrink-0 bg-linear-to-r from-transparent via-primary to-transparent opacity-60" />
<header class="flex min-h-[3.625rem] shrink-0 items-center gap-3 border-b border-border py-2 ps-4 pe-3 sm:ps-[1.125rem]">
<span
class="w-7 shrink-0 overflow-hidden rounded-[4px] bg-muted ring-1 ring-border"
:style="{ aspectRatio: mainCoverMedium === 'audio' ? '1/1' : '2/3' }"
>
<img v-if="mainCoverUrl" :src="mainCoverUrl" alt="" class="size-full object-cover" />
</span>
<div class="min-w-0 flex-1">
<SheetTitle class="text-sm font-semibold">{{ t('book.detail.editMetadata.searchDrawer.searchTitle') }}</SheetTitle>
<SheetDescription class="text-xs leading-snug [overflow-wrap:anywhere] text-muted-foreground">{{ subtitle }}</SheetDescription>
</div>
<MetadataMatchShortcuts />
<SheetClose
class="grid size-8 place-items-center rounded-lg text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-label="t('common.close')"
>
<X class="size-4" aria-hidden="true" />
</SheetClose>
</header>
<!-- Step indicator -->
<div class="ml-auto flex items-center gap-1 shrink-0">
<div class="h-1.5 w-6 rounded-full transition-all duration-300" :class="view === 'search' ? 'bg-primary' : 'bg-border'" />
<div class="h-1.5 w-6 rounded-full transition-all duration-300" :class="view === 'diff' ? 'bg-primary' : 'bg-border'" />
</div>
</div>
<!-- Content -->
<div class="flex-1 min-h-0 relative">
<MetadataSearchPanel
v-if="view === 'search'"
:search-defaults="searchDefaults"
:providers="providers"
:filtered-results="filteredResults"
:provider-counts="providerCounts"
:selected-providers="selectedProviders"
:is-streaming="isStreaming"
:has-searched="hasSearched"
:interrupted-providers="interruptedProviders"
@search="handleSearch"
@toggle-provider="handleToggleProvider"
@clear-filter="handleClearProviderFilter"
@select-field-rules="handleSelectFieldRules"
@select="handleSelect"
/>
<MetadataDiffPanel
v-else-if="view === 'diff' && selectedCandidate"
:current="currentSource"
:candidates="filteredResults"
:initial-candidate="selectedCandidate"
:providers="providers"
:current-cover-url="mainCoverUrl"
:cover-medium="mainCoverMedium"
:second-cover="secondCover"
:provider-ids="book.providerIds"
:locked-fields="props.lockedFields"
:filtered-results="filteredResults"
:back-label="t('book.detail.editMetadata.diffPanel.results')"
@back="backToSearch"
@apply="handleApply"
/>
</div>
<MetadataMatchWorkspace
class="min-h-0 flex-1"
:current="currentSource"
:provider-ids="book.providerIds"
:locked-fields="lockedFields"
:current-cover-url="mainCoverUrl"
:cover-medium="mainCoverMedium"
:cover-priority="mainCoverPriority"
:second-cover="secondCover"
:search-defaults="searchDefaults"
:providers="providers"
:results="filteredResults"
:provider-counts="providerCounts"
:selected-providers="selectedProviders"
:interrupted-providers="interruptedProviders"
:retrying-providers="retryingProviders"
:provider-order="resultProviderOrder"
:is-streaming="isStreaming"
:has-searched="hasSearched"
@search="runMetadataSearch"
@toggle-provider="handleToggleProvider"
@select-all="handleSelectAll"
@select-field-rules="handleSelectFieldRules"
@retry-provider="handleRetry"
@apply="handleApply"
@cancel="handleCancel"
/>
</SheetContent>
</Sheet>
</template>
@@ -1,306 +0,0 @@
<script setup lang="ts">
import { computed, inject, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Search, BookOpen, Loader2, PencilLine, TriangleAlert } from '@lucide/vue'
import type { MetadataCandidate, MetadataProviderInfo, MetadataProviderKey, MetadataProviderSearchOutcome } from '@bookorbit/types'
import MetadataResultCard from './MetadataResultCard.vue'
import { providerActivePillStyle } from '../../../lib/metadata-fetch'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../../lib/cover-aspect-ratio'
const props = defineProps<{
searchDefaults: { title?: string; author?: string; isbn?: string }
providers: MetadataProviderInfo[]
filteredResults: MetadataCandidate[]
providerCounts: Partial<Record<MetadataProviderKey, number>>
selectedProviders: MetadataProviderKey[]
isStreaming: boolean
hasSearched: boolean
interruptedProviders: { provider: MetadataProviderKey; outcome: MetadataProviderSearchOutcome }[]
}>()
const INTERRUPTION_MESSAGE_KEYS: Record<MetadataProviderSearchOutcome, string> = {
timeout: 'book.detail.editMetadata.searchPanel.providerTimedOut',
throttled: 'book.detail.editMetadata.searchPanel.providerThrottled',
failed: 'book.detail.editMetadata.searchPanel.providerFailed',
}
const emit = defineEmits<{
search: [{ title: string; author: string; isbn: string }]
toggleProvider: [MetadataProviderKey]
clearFilter: []
selectFieldRules: []
select: [MetadataCandidate]
}>()
const { t } = useI18n()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const form = reactive({
title: props.searchDefaults.title ?? '',
author: props.searchDefaults.author ?? '',
isbn: props.searchDefaults.isbn ?? '',
})
const isFormCollapsed = ref(false)
const canSearch = computed(() => !!(form.title.trim() || form.isbn.trim()))
const searchSummary = computed(() => {
const parts = [form.title.trim(), form.author.trim(), form.isbn.trim()].filter(Boolean)
return parts.length ? parts.join(' - ') : t('book.detail.editMetadata.searchPanel.untitledQuery')
})
const hasFieldRuleScope = computed(() => props.providers.some((provider) => provider.selectedByFieldRules !== undefined))
const allProviderKeys = computed(() => props.providers.map((provider) => provider.key))
const fieldRuleProviderKeys = computed(() => props.providers.filter((provider) => provider.selectedByFieldRules).map((provider) => provider.key))
const providersOutsideFieldRules = computed(() => props.providers.filter((provider) => provider.selectedByFieldRules === false))
const providersOutsideFieldRuleText = computed(() => providersOutsideFieldRules.value.map((provider) => provider.label).join(', '))
const allProvidersSelected = computed(() => sameProviderSelection(allProviderKeys.value))
const fieldRuleProvidersSelected = computed(() => sameProviderSelection(fieldRuleProviderKeys.value))
const hasFieldRuleScopeOption = computed(
() => hasFieldRuleScope.value && providersOutsideFieldRules.value.length > 0 && fieldRuleProviderKeys.value.length > 0,
)
const customProviderSelection = computed(
() => props.providers.length > 0 && !allProvidersSelected.value && !(hasFieldRuleScopeOption.value && fieldRuleProvidersSelected.value),
)
const interruptionMessages = computed(() =>
props.interruptedProviders.map(({ provider, outcome }) => ({
provider,
message: t(INTERRUPTION_MESSAGE_KEYS[outcome], {
provider: props.providers.find((p) => p.key === provider)?.label ?? provider,
}),
})),
)
watch(
() => props.hasSearched,
(hasSearched) => {
if (hasSearched && !props.isStreaming) {
isFormCollapsed.value = true
}
},
{ immediate: true },
)
function runSearch() {
if (!canSearch.value) return
isFormCollapsed.value = true
emit('search', {
title: form.title.trim(),
author: form.author.trim(),
isbn: form.isbn.trim(),
})
}
function expandSearchForm() {
isFormCollapsed.value = false
}
function handleClearFilter() {
emit('clearFilter')
}
function handleSelectFieldRules() {
emit('selectFieldRules')
}
function handleToggleProvider(provider: MetadataProviderKey) {
emit('toggleProvider', provider)
}
function handleSelectCandidate(candidate: MetadataCandidate) {
emit('select', candidate)
}
function sameProviderSelection(keys: MetadataProviderKey[]) {
if (!keys.length) return false
const selected = new Set(props.selectedProviders)
return selected.size === keys.length && keys.every((key) => selected.has(key))
}
</script>
<template>
<div class="flex flex-col h-full">
<!-- Search form card -->
<div v-if="!isFormCollapsed" class="px-4 pt-4 pb-3 shrink-0">
<div class="rounded-lg border border-border bg-card p-3 shadow-sm">
<div class="grid grid-cols-[minmax(0,1fr)_auto] gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_11rem_auto]">
<div class="relative col-span-2 min-w-0 md:col-span-1">
<BookOpen class="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<input
v-model="form.title"
class="w-full h-8 rounded-lg border border-input bg-background pl-8 pr-3 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow"
:placeholder="t('book.detail.editMetadata.searchPanel.titlePlaceholder')"
@keydown.enter="runSearch"
/>
</div>
<input
v-model="form.author"
class="col-span-2 h-8 min-w-0 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow md:col-span-1"
:placeholder="t('book.detail.editMetadata.searchPanel.authorPlaceholder')"
@keydown.enter="runSearch"
/>
<input
v-model="form.isbn"
class="h-8 min-w-0 rounded-lg border border-input bg-background px-3 text-sm font-mono outline-none focus:ring-1 focus:ring-ring transition-shadow"
:placeholder="t('book.detail.editMetadata.searchPanel.isbnPlaceholder')"
@keydown.enter="runSearch"
/>
<button
class="relative flex items-center justify-center gap-1.5 h-8 w-28 rounded-lg bg-primary text-primary-foreground text-sm font-medium transition-all disabled:opacity-40 hover:opacity-90 active:scale-95 overflow-hidden group"
:disabled="!canSearch || isStreaming"
@click="runSearch"
>
<span class="absolute inset-0 bg-white/10 opacity-0 group-hover:opacity-100 transition-opacity" />
<Loader2 v-if="isStreaming" class="size-3.5 animate-spin" />
<Search v-else class="size-3.5" />
{{ isStreaming ? t('book.detail.editMetadata.searchPanel.searching') : t('common.search') }}
</button>
</div>
</div>
</div>
<div v-else class="px-4 pt-4 pb-3 shrink-0">
<div class="rounded-lg border border-border bg-card px-3 py-2.5 shadow-sm">
<div class="flex items-center gap-2">
<Search class="size-3.5 text-muted-foreground shrink-0" />
<div class="min-w-0 flex-1">
<p class="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold">
{{ t('book.detail.editMetadata.searchPanel.activeQuery') }}
</p>
<p class="text-sm font-medium text-foreground truncate">{{ searchSummary }}</p>
</div>
<button
class="h-7 px-2.5 rounded-lg border border-border bg-background text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-all active:scale-95 flex items-center gap-1.5 shrink-0"
@click="expandSearchForm"
>
<PencilLine class="size-3.5" />
{{ t('common.edit') }}
</button>
</div>
</div>
</div>
<!-- Provider filter pills -->
<div v-if="providers.length" class="flex items-center gap-1.5 px-4 pb-2 flex-wrap shrink-0">
<div
class="inline-flex h-6 items-stretch overflow-hidden rounded-full border border-border bg-background shadow-xs"
:aria-label="t('book.detail.editMetadata.searchPanel.searchScope')"
>
<button
class="h-full px-2.5 text-[11px] font-medium transition-colors"
:class="
allProvidersSelected ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
"
@click="handleClearFilter"
>
{{ hasFieldRuleScope ? t('book.detail.editMetadata.searchPanel.allEnabled') : t('book.detail.editMetadata.searchPanel.all') }}
</button>
<button
v-if="hasFieldRuleScopeOption"
class="h-full px-2.5 text-[11px] font-medium transition-colors"
:class="
fieldRuleProvidersSelected ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
"
@click="handleSelectFieldRules"
>
{{ t('book.detail.editMetadata.searchPanel.fieldRules') }}
</button>
<span
v-if="customProviderSelection"
class="h-full px-2.5 bg-primary text-primary-foreground text-[11px] font-medium shadow-sm flex items-center"
>
{{ t('book.detail.editMetadata.searchPanel.custom') }}
</span>
</div>
<button
v-for="p in providers"
:key="p.key"
class="h-6 px-2.5 rounded-full text-xs font-medium transition-all flex items-center gap-1 active:scale-95"
:title="p.selectedByFieldRules === false ? t('book.detail.editMetadata.searchPanel.providerNotInFieldRules', { provider: p.label }) : p.label"
:class="selectedProviders.includes(p.key) ? '' : 'bg-muted text-muted-foreground hover:text-foreground hover:bg-muted/80'"
:style="selectedProviders.includes(p.key) ? providerActivePillStyle(p.key) : {}"
@click="handleToggleProvider(p.key)"
>
{{ p.label }}
<span
v-if="providerCounts[p.key]"
class="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full text-[10px] font-semibold bg-black/10"
>
{{ providerCounts[p.key] }}
</span>
<span v-else-if="isStreaming" class="size-1.5 rounded-full bg-current animate-pulse" />
</button>
</div>
<div
v-if="providersOutsideFieldRules.length"
class="mx-4 mb-3 rounded-lg border border-border bg-muted/30 px-3 py-2 text-xs leading-relaxed text-muted-foreground shrink-0"
>
<span class="font-medium text-foreground">{{ t('book.detail.editMetadata.searchPanel.notInFieldRulesLabel') }}</span>
{{ t('book.detail.editMetadata.searchPanel.notInFieldRulesText', { providers: providersOutsideFieldRuleText }) }}
</div>
<!-- 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 lg:grid-cols-2 gap-3">
<div v-for="n in 8" :key="n" class="rounded-lg border border-border/40 bg-card overflow-hidden animate-pulse flex gap-3 p-2.5">
<div class="rounded-lg bg-muted shrink-0" :style="{ width: '88px', aspectRatio: coverAspectRatio }" />
<div class="flex-1 flex flex-col justify-center gap-2 py-1">
<div class="h-3 bg-muted rounded-md w-full" />
<div class="h-2.5 bg-muted rounded-md w-3/4" />
<div class="h-4 bg-muted rounded-md w-1/3 mt-1" />
</div>
</div>
</div>
<!-- Nothing found: say whether the search actually finished -->
<div
v-else-if="!isStreaming && !filteredResults.length && hasSearched"
role="status"
class="py-12 flex flex-col items-center gap-3 text-muted-foreground"
>
<div class="size-10 rounded-full bg-muted flex items-center justify-center">
<TriangleAlert v-if="interruptionMessages.length" class="size-5" />
<BookOpen v-else class="size-5" />
</div>
<div class="text-center">
<template v-if="interruptionMessages.length">
<p class="text-sm font-medium text-foreground">{{ t('book.detail.editMetadata.searchPanel.searchInterrupted') }}</p>
<p v-for="entry in interruptionMessages" :key="entry.provider" class="text-xs text-muted-foreground mt-0.5">
{{ entry.message }}
</p>
<p class="text-xs text-muted-foreground mt-0.5">{{ t('book.detail.editMetadata.searchPanel.searchInterruptedHint') }}</p>
</template>
<template v-else>
<p class="text-sm font-medium text-foreground">{{ t('book.detail.editMetadata.searchPanel.noResults') }}</p>
<p class="text-xs text-muted-foreground mt-0.5">{{ t('book.detail.editMetadata.searchPanel.noResultsHint') }}</p>
</template>
</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">{{ t('book.detail.editMetadata.searchPanel.searchPrompt') }}</p>
</div>
<!-- Results grid -->
<div v-else>
<div
v-if="interruptionMessages.length"
role="status"
class="mb-3 rounded-lg border border-border bg-muted/30 px-3 py-2 text-xs leading-relaxed text-muted-foreground"
>
<p class="font-medium text-foreground">{{ t('book.detail.editMetadata.searchPanel.searchIncomplete') }}</p>
<p v-for="entry in interruptionMessages" :key="entry.provider">{{ entry.message }}</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<MetadataResultCard
v-for="(candidate, i) in filteredResults"
:key="`${candidate.provider}-${candidate.providerId}-${i}`"
:candidate="candidate"
:providers="providers"
@select="handleSelectCandidate"
/>
</div>
</div>
</div>
</div>
</template>
@@ -1,159 +0,0 @@
import { describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import type { MetadataCandidate, MetadataProviderInfo, MetadataSource } from '@bookorbit/types'
import MetadataDiffPanel from '../MetadataDiffPanel.vue'
import type { SecondCoverInput } from '../../../../composables/useSecondCoverRow'
const current: MetadataSource = {
title: 'Dune',
subtitle: null,
description: null,
publisher: null,
publishedDate: null,
publishedYear: null,
language: null,
pageCount: null,
seriesName: null,
seriesIndex: null,
isbn10: null,
isbn13: null,
authors: ['Frank Herbert'],
genres: [],
narrators: [],
durationSeconds: null,
abridged: null,
hardcoverEditionId: null,
communityRatings: [],
}
const providers: MetadataProviderInfo[] = [
{ key: 'google', label: 'Google Books', identifiable: true },
{ key: 'audible', label: 'Audible', identifiable: true },
{ key: 'itunes', label: 'iTunes', identifiable: true },
]
const google: MetadataCandidate = { provider: 'google', providerId: 'g1', title: 'Dune', coverUrl: '/covers/google.jpg', coverShape: 'portrait' }
const audible: MetadataCandidate = { provider: 'audible', providerId: 'a1', title: 'Dune', coverUrl: '/covers/audible.jpg', coverShape: 'square' }
const itunes: MetadataCandidate = { provider: 'itunes', providerId: 'i1', title: 'Dune', coverUrl: '/covers/itunes.jpg', coverShape: 'square' }
function secondCover(overrides: Partial<SecondCoverInput> = {}): SecondCoverInput {
return { medium: 'audio', candidates: [itunes, audible], priority: ['audible', 'itunes'], currentUrl: '', searching: false, ...overrides }
}
function mountPanel(props: Record<string, unknown> = {}) {
return mount(MetadataDiffPanel, {
props: {
current,
candidates: [google],
initialCandidate: google,
providers,
filteredResults: [google],
currentCoverUrl: '',
coverMedium: 'ebook',
secondCover: secondCover(),
...props,
},
})
}
async function click(wrapper: VueWrapper, label: string) {
await wrapper.get(`button[aria-label="${label}"]`).trigger('click')
}
async function clickText(wrapper: VueWrapper, text: string) {
const button = wrapper.findAll('button').find((candidate) => candidate.text() === text)
if (!button) throw new Error(`No button reads "${text}"`)
await button.trigger('click')
}
function applied(wrapper: VueWrapper) {
return wrapper.emitted('apply')?.[0]?.[0] as { coverUrl?: string; audioCoverUrl?: string } | undefined
}
describe('MetadataDiffPanel', () => {
it('shows one cover row per medium for a book that has both', () => {
const wrapper = mountPanel()
expect(wrapper.text()).toContain('Book cover')
expect(wrapper.text()).toContain('Audiobook cover')
})
it('applies the audiobook cover the Audiobook cover rule ranks first, to the audio slot only', async () => {
const wrapper = mountPanel()
await click(wrapper, 'Use the new Audiobook cover')
await clickText(wrapper, 'Apply to form')
const payload = applied(wrapper)
expect(payload?.audioCoverUrl).toBe('/covers/audible.jpg')
expect(payload?.coverUrl).toBeUndefined()
})
it('lets the audiobook cover come from another result of its own search', async () => {
const wrapper = mountPanel()
await click(wrapper, 'Show the iTunes cover, 2 of 2')
await click(wrapper, 'Use the new Audiobook cover')
await clickText(wrapper, 'Apply to form')
expect(applied(wrapper)?.audioCoverUrl).toBe('/covers/itunes.jpg')
})
it('leaves the audiobook cover out of Copy All', async () => {
const wrapper = mountPanel()
await clickText(wrapper, 'Copy All')
await clickText(wrapper, 'Apply to form')
const payload = applied(wrapper)
expect(payload?.coverUrl).toBe('/covers/google.jpg')
expect(payload?.audioCoverUrl).toBeUndefined()
})
it('does not apply a pick for a locked audiobook cover', async () => {
const wrapper = mountPanel({ lockedFields: ['audioCover'] })
expect(wrapper.get('button[aria-label="Use the new Audiobook cover"]').attributes('disabled')).toBeDefined()
})
it('sends the one cover of a book that is only an audiobook to the audio slot', async () => {
const wrapper = mountPanel({
coverMedium: 'audio',
secondCover: null,
candidates: [audible],
initialCandidate: audible,
filteredResults: [audible],
})
expect(wrapper.text()).not.toContain('Audiobook cover')
await click(wrapper, 'Use the new Cover')
await clickText(wrapper, 'Apply to form')
expect(applied(wrapper)).toMatchObject({ audioCoverUrl: '/covers/audible.jpg' })
expect(applied(wrapper)?.coverUrl).toBeUndefined()
})
it('names portrait art offered for a filled audiobook slot, and keeps it out of Copy All', async () => {
const wrapper = mountPanel({
coverMedium: 'audio',
secondCover: null,
currentCoverUrl: '/api/v1/books/1/cover?medium=audio',
})
const hint = wrapper.findAll('p').find((paragraph) => paragraph.text() === 'Portrait image. Audiobook covers are square.')
expect(hint).toBeDefined()
expect(wrapper.get('button[aria-label="Use the new Cover"]').attributes('aria-describedby')).toBe(hint?.attributes('id'))
await clickText(wrapper, 'Copy All')
await clickText(wrapper, 'Apply to form')
expect(applied(wrapper)?.audioCoverUrl).toBeUndefined()
})
it('says when the other medium is still being searched, or found nothing', async () => {
const searching = mountPanel({ secondCover: secondCover({ candidates: [], searching: true }) })
expect(searching.get('[role="status"]').text()).toBe('Searching audiobook editions...')
const empty = mountPanel({ secondCover: secondCover({ candidates: [] }) })
expect(empty.text()).toContain('No audiobook covers found.')
})
})
@@ -0,0 +1,452 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowLeft, Barcode, CheckCheck, ChevronLeft, ChevronRight, Ellipsis, ExternalLink, Link, Loader2, Plus, Undo2 } from '@lucide/vue'
import type { CoverMedium, MetadataCandidate, MetadataProviderInfo } from '@bookorbit/types'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import type { CoverChoice, DiffField, FieldDecision, GenreWriteMode } from '../../composables/useMetadataDiff'
import { isActionable, isComicDiffFieldKey, isProviderIdPatchField, type DiffFieldKey } from '../../lib/metadata-diff-fields'
import type { MatchAssessment } from '../../lib/metadata-match'
import { displayPublishedDate } from '../../lib/published-date'
import {
getProviderColor,
getProviderLabel,
hideOnError,
providerBadgeStyle,
resolveCandidateDisplayTitle,
toDisplayCoverUrl,
} from '../../lib/metadata-fetch'
import MetadataMatchTier from './MetadataMatchTier.vue'
import MetadataMatchFieldRow from './MetadataMatchFieldRow.vue'
import MetadataMatchCoverCard from './MetadataMatchCoverCard.vue'
export interface CoverCardModel {
id: 'main' | 'second'
label: string
medium: CoverMedium
currentUrl: string
active: CoverChoice | null
picked: CoverChoice | null
choices: CoverChoice[]
locked: boolean
searching?: boolean
searchingText?: string
emptyText?: string
}
export type RowFilter = 'diff' | 'all'
type SectionId = 'identity' | 'description' | 'publication' | 'series' | 'genres' | 'comics' | 'identifiers' | 'ratings'
const SECTION_ORDER: SectionId[] = ['identity', 'description', 'publication', 'series', 'genres', 'comics', 'identifiers', 'ratings']
const SECTION_OF: Partial<Record<DiffFieldKey, SectionId>> = {
title: 'identity',
subtitle: 'identity',
authors: 'identity',
narrators: 'identity',
description: 'description',
publisher: 'publication',
publishedDate: 'publication',
language: 'publication',
pageCount: 'publication',
durationSeconds: 'publication',
abridged: 'publication',
seriesName: 'series',
seriesIndex: 'series',
genres: 'genres',
isbn13: 'identifiers',
isbn10: 'identifiers',
hardcoverEditionId: 'identifiers',
communityRating: 'ratings',
}
function sectionOf(key: DiffFieldKey): SectionId {
if (isComicDiffFieldKey(key)) return 'comics'
if (isProviderIdPatchField(key)) return 'identifiers'
return SECTION_OF[key] ?? 'identity'
}
const props = defineProps<{
candidate: MetadataCandidate | null
assessment: MatchAssessment | null
fields: DiffField[]
filter: RowFilter
genreMode: GenreWriteMode
providers: MetadataProviderInfo[]
covers: CoverCardModel[]
position: number
total: number
/** Providers still answering while the best match so far is shown. */
waitingOn: string[]
hasRail: boolean
bookTitle: string
searchFoundNothing: boolean
fillCount: number
takeCount: number
coverRatio: string
}>()
const emit = defineEmits<{
back: []
step: [delta: number]
'update:filter': [RowFilter]
fillEmpty: []
takeAll: []
clearAll: []
decide: [key: DiffFieldKey, decision: FieldDecision, from?: MetadataCandidate]
coverKeep: [id: CoverCardModel['id']]
coverUse: [id: CoverCardModel['id']]
coverPick: [id: CoverCardModel['id'], candidate: MetadataCandidate | null]
}>()
const { t } = useI18n()
const title = computed(() =>
props.candidate ? (resolveCandidateDisplayTitle(props.candidate) ?? t('book.detail.editMetadata.diffPanel.untitled')) : '',
)
const providerLabel = computed(() => (props.candidate ? getProviderLabel(props.candidate.provider, props.providers) : ''))
const coverUrl = computed(() => toDisplayCoverUrl(props.candidate?.coverUrl))
const meta = computed(() => {
const c = props.candidate
if (!c) return ''
const parts: string[] = []
if (c.authors?.length) parts.push(c.authors.join(', '))
const published = displayPublishedDate(c.publishedDate, c.publishedYear)
if (published) parts.push(published)
if (c.pageCount) parts.push(t('book.detail.editMetadata.match.results.pages', { count: c.pageCount }))
if (c.publisher) parts.push(c.publisher)
return parts.join(' · ')
})
const reviewCount = computed(() => props.fields.filter((field) => isActionable(field.kind)).length)
const visibleFields = computed(() => props.fields.filter((field) => props.filter === 'all' || isActionable(field.kind) || field.isPicked))
const sections = computed(() =>
SECTION_ORDER.map((id) => ({ id, fields: visibleFields.value.filter((field) => sectionOf(field.key) === id) })).filter(
(section) => section.fields.length,
),
)
const rowIndex = computed(() => new Map(visibleFields.value.map((field, index) => [field.key, index])))
function decisionOf(field: DiffField): FieldDecision {
if (!field.pickedFromActive) return 'keep'
return field.key === 'genres' ? props.genreMode : 'use'
}
function sectionSummary(fields: DiffField[]): string {
const review = fields.filter((field) => isActionable(field.kind)).length
const staged = fields.filter((field) => field.isPicked).length
const base = t('book.detail.editMetadata.match.compare.toReview', { count: review })
return staged ? `${base} · ${t('book.detail.editMetadata.match.compare.stagedInSection', { count: staged })}` : base
}
function handleBack() {
emit('back')
}
function handlePrevious() {
emit('step', -1)
}
function handleNext() {
emit('step', 1)
}
function showDifferences() {
emit('update:filter', 'diff')
}
function showAll() {
emit('update:filter', 'all')
}
function handleFillEmpty() {
emit('fillEmpty')
}
function handleTakeAll() {
emit('takeAll')
}
function handleClearAll() {
emit('clearAll')
}
function handleDecide(key: DiffFieldKey, decision: FieldDecision, from?: MetadataCandidate) {
emit('decide', key, decision, from)
}
function handleCoverKeep(id: CoverCardModel['id']) {
emit('coverKeep', id)
}
function handleCoverUse(id: CoverCardModel['id']) {
emit('coverUse', id)
}
function handleCoverPick(id: CoverCardModel['id'], candidate: MetadataCandidate | null) {
emit('coverPick', id, candidate)
}
</script>
<template>
<section class="@container/compare flex min-h-0 min-w-0 flex-col" :aria-label="t('book.detail.editMetadata.match.compare.label')">
<div v-if="!candidate" class="m-auto grid max-w-md justify-items-center gap-3 p-10 text-center">
<p class="text-[15px] font-semibold">
{{ searchFoundNothing ? t('book.detail.editMetadata.match.compare.noneTitle') : t('book.detail.editMetadata.match.compare.pickTitle') }}
</p>
<p class="text-[12.5px] text-muted-foreground">
{{
searchFoundNothing
? t('book.detail.editMetadata.match.compare.noneHint', { title: bookTitle })
: t('book.detail.editMetadata.match.compare.pickHint')
}}
</p>
</div>
<template v-else>
<header class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3.5 border-b border-border px-4 py-3 @3xl/compare:px-6">
<button
v-if="hasRail"
type="button"
class="grid size-8 place-items-center rounded-lg text-foreground hover:bg-muted @5xl/match:hidden"
:aria-label="t('book.detail.editMetadata.match.compare.backToMatches')"
@click="handleBack"
>
<ArrowLeft class="size-4 rtl:rotate-180" aria-hidden="true" />
</button>
<span
class="relative w-[2.625rem] overflow-hidden rounded-[5px] bg-muted ring-1 ring-border"
:class="hasRail ? 'hidden @5xl/match:block' : 'block'"
:style="{ aspectRatio: coverRatio }"
>
<img v-if="coverUrl" :src="coverUrl" alt="" class="size-full object-contain" @error="hideOnError" />
</span>
<div class="min-w-0">
<h3 class="text-[15px] leading-snug font-semibold [overflow-wrap:anywhere]">
{{ title }}
<MetadataMatchTier v-if="assessment" :tier="assessment.tier" class="ms-1.5 align-baseline" />
</h3>
<p v-if="meta" class="mt-0.5 text-xs leading-snug [overflow-wrap:anywhere] text-muted-foreground">{{ meta }}</p>
<div class="mt-1.5 flex flex-wrap gap-1.5">
<span
class="inline-flex h-[18px] items-center rounded-md px-1.5 text-[10.5px] font-bold"
:style="providerBadgeStyle(candidate.provider)"
>{{ providerLabel }}</span
>
<span
v-if="assessment?.linked"
class="inline-flex h-[18px] items-center gap-1 rounded-md bg-primary/15 px-1.5 text-[10.5px] font-semibold text-primary"
>
<Link class="size-2.5" aria-hidden="true" />{{ t('book.detail.editMetadata.match.compare.linkedToBook') }}
</span>
<span
v-if="assessment?.isbnMatch"
class="inline-flex h-[18px] items-center gap-1 rounded-md bg-primary/15 px-1.5 text-[10.5px] font-semibold text-primary"
>
<Barcode class="size-2.5" aria-hidden="true" />{{ t('book.detail.editMetadata.match.results.isbn') }}
</span>
<span
v-if="assessment && assessment.tier !== 'strong' && assessment.titleMatch === 'same' && assessment.authorMatch"
class="inline-flex h-[18px] items-center rounded-md bg-muted px-1.5 text-[10.5px] font-semibold text-muted-foreground"
>
{{ t('book.detail.editMetadata.match.compare.sameTitleAuthor') }}
</span>
<span
v-if="assessment && assessment.tier === 'weak' && !assessment.authorMatch"
class="inline-flex h-[18px] items-center rounded-md bg-warning/15 px-1.5 text-[10.5px] font-semibold text-warning"
>
{{ t('book.detail.editMetadata.match.results.differentAuthor') }}
</span>
<span
v-if="assessment && assessment.titleMatch !== 'same'"
class="inline-flex h-[18px] items-center rounded-md bg-warning/15 px-1.5 text-[10.5px] font-semibold text-warning"
>
{{ t('book.detail.editMetadata.match.results.titleDiffers') }}
</span>
</div>
</div>
<div class="grid justify-items-end gap-1.5">
<div v-if="total > 1" class="flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
<button
type="button"
class="grid size-7 place-items-center rounded-lg border border-border text-foreground hover:enabled:bg-muted disabled:text-muted-foreground"
:aria-label="t('book.detail.editMetadata.match.compare.previous')"
:disabled="position <= 1"
@click="handlePrevious"
>
<ChevronLeft class="size-4 rtl:rotate-180" aria-hidden="true" />
</button>
<span class="hidden @xl/compare:inline">{{ t('book.detail.editMetadata.match.compare.position', { index: position, total }) }}</span>
<button
type="button"
class="grid size-7 place-items-center rounded-lg border border-border text-foreground hover:enabled:bg-muted disabled:text-muted-foreground"
:aria-label="t('book.detail.editMetadata.match.compare.next')"
:disabled="position >= total"
@click="handleNext"
>
<ChevronRight class="size-4 rtl:rotate-180" aria-hidden="true" />
</button>
</div>
<a
v-if="candidate.sourceUrl"
:href="candidate.sourceUrl"
target="_blank"
rel="noopener noreferrer"
class="hidden items-center gap-1 text-xs font-medium text-primary hover:underline @xl/compare:inline-flex"
>
{{ t('book.detail.editMetadata.match.compare.viewOn', { provider: providerLabel }) }}<ExternalLink class="size-3" aria-hidden="true" />
</a>
</div>
</header>
<p
v-if="waitingOn.length"
role="status"
class="flex items-center gap-2 border-b border-border/60 bg-primary/5 px-4 py-2 text-xs text-muted-foreground @3xl/compare:px-6"
>
<Loader2 class="size-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
{{ t('book.detail.editMetadata.match.compare.streaming', { providers: waitingOn.join(', ') }) }}
</p>
<div class="flex flex-wrap items-center gap-2 border-b border-border px-4 py-2.5 @3xl/compare:px-6">
<div
class="inline-flex shrink-0 gap-0.5 rounded-lg border border-border/60 bg-muted p-0.5"
role="radiogroup"
:aria-label="t('book.detail.editMetadata.match.compare.rows')"
>
<button
type="button"
role="radio"
:aria-checked="filter === 'diff'"
class="inline-flex h-6 items-center gap-1.5 rounded-md px-2.5 text-[11.5px] font-medium whitespace-nowrap"
:class="filter === 'diff' ? 'bg-background shadow-xs ring-1 ring-border' : 'hover:bg-background/60'"
@click="showDifferences"
>
{{ t('book.detail.editMetadata.match.compare.differences')
}}<span class="text-[10.5px] font-bold text-muted-foreground tabular-nums">{{ reviewCount }}</span>
</button>
<button
type="button"
role="radio"
:aria-checked="filter === 'all'"
class="inline-flex h-6 items-center gap-1.5 rounded-md px-2.5 text-[11.5px] font-medium whitespace-nowrap"
:class="filter === 'all' ? 'bg-background shadow-xs ring-1 ring-border' : 'hover:bg-background/60'"
@click="showAll"
>
{{ t('book.detail.editMetadata.match.compare.allFields')
}}<span class="text-[10.5px] font-bold text-muted-foreground tabular-nums">{{ fields.length }}</span>
</button>
</div>
<span class="flex-1" />
<button
type="button"
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 text-xs font-medium whitespace-nowrap hover:enabled:bg-muted disabled:text-muted-foreground"
:disabled="!fillCount"
@click="handleFillEmpty"
>
<Plus class="size-3.5" aria-hidden="true" />{{ t('book.detail.editMetadata.match.compare.fillEmpty', { count: fillCount }) }}
</button>
<button
type="button"
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 text-xs font-medium whitespace-nowrap hover:enabled:bg-muted disabled:text-muted-foreground"
:disabled="!takeCount"
@click="handleTakeAll"
>
<CheckCheck class="size-3.5" aria-hidden="true" />{{ t('book.detail.editMetadata.match.compare.takeAll', { count: takeCount }) }}
</button>
<Popover>
<PopoverTrigger as-child>
<button
type="button"
class="grid size-7 shrink-0 place-items-center rounded-lg text-foreground hover:bg-muted"
:aria-label="t('book.detail.editMetadata.match.compare.more')"
>
<Ellipsis class="size-4" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent align="end" class="w-64 p-1.5">
<button
type="button"
class="grid w-full grid-cols-[1rem_minmax(0,1fr)] gap-2 rounded-md px-2 py-1.5 text-start text-[12.5px] hover:bg-muted"
@click="handleClearAll"
>
<Undo2 class="mt-0.5 size-3.5" aria-hidden="true" />
<span>
{{ t('book.detail.editMetadata.match.compare.clearAll') }}
<span class="block text-[11.5px] text-muted-foreground">{{ t('book.detail.editMetadata.match.compare.clearAllHint') }}</span>
</span>
</button>
<a
v-if="candidate.sourceUrl"
:href="candidate.sourceUrl"
target="_blank"
rel="noopener noreferrer"
class="grid grid-cols-[1rem_minmax(0,1fr)] gap-2 rounded-md px-2 py-1.5 text-[12.5px] hover:bg-muted"
>
<ExternalLink class="mt-0.5 size-3.5" aria-hidden="true" />
{{ t('book.detail.editMetadata.match.compare.viewOn', { provider: providerLabel }) }}
</a>
</PopoverContent>
</Popover>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-4 pb-7 @3xl/compare:px-6" data-match-ledger>
<div
class="sticky top-0 z-10 hidden grid-cols-2 gap-3 border-b border-border bg-background pt-2.5 pb-2 text-[10.5px] font-bold tracking-[0.12em] text-muted-foreground uppercase @2xl/compare:grid"
aria-hidden="true"
>
<span>{{ t('book.detail.editMetadata.diffPanel.current') }}</span>
<span class="flex items-center gap-1.5"
><span class="size-[7px] rounded-full" :style="{ backgroundColor: getProviderColor(candidate.provider) }" />{{ providerLabel }}</span
>
</div>
<div class="grid gap-3 pt-4">
<MetadataMatchCoverCard
v-for="cover in covers"
:key="cover.id"
:label="cover.label"
:medium="cover.medium"
:current-url="cover.currentUrl"
:active="cover.active"
:picked="cover.picked"
:choices="cover.choices"
:locked="cover.locked"
:providers="providers"
:searching="cover.searching"
:searching-text="cover.searchingText"
:empty-text="cover.emptyText"
@keep="handleCoverKeep(cover.id)"
@use="handleCoverUse(cover.id)"
@pick="handleCoverPick(cover.id, $event)"
/>
</div>
<template v-for="section in sections" :key="section.id">
<div class="flex items-center gap-2.5 pt-5 pb-1">
<template v-if="section.fields.length > 1">
<h4 class="text-[10.5px] font-bold tracking-[0.12em] text-muted-foreground uppercase">
{{ t(`book.detail.editMetadata.match.sections.${section.id}`) }}
</h4>
<span class="text-[11px] font-medium text-muted-foreground">{{ sectionSummary(section.fields) }}</span>
</template>
<span class="h-px flex-1 bg-border/60" />
</div>
<MetadataMatchFieldRow
v-for="field in section.fields"
:key="field.key"
:field="field"
:candidate="candidate"
:providers="providers"
:decision="decisionOf(field)"
:index="rowIndex.get(field.key) ?? 0"
@decide="handleDecide"
/>
</template>
<p v-if="!sections.length" class="py-8 text-center text-sm text-muted-foreground">
{{ t('book.detail.editMetadata.match.compare.allMatch') }}
</p>
</div>
</template>
</section>
</template>
@@ -0,0 +1,371 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowRight, Check, CircleCheck, ImageOff, Loader2, Maximize2, TriangleAlert, X } from '@lucide/vue'
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import type { CoverMedium, MetadataCandidate, MetadataProviderInfo } from '@bookorbit/types'
import type { CoverChoice, CoverSize } from '../../composables/useMetadataDiff'
import { getProviderColor, getProviderLabel, hideOnError, providerBadgeStyle } from '../../lib/metadata-fetch'
const props = defineProps<{
label: string
medium: CoverMedium
currentUrl: string
active: CoverChoice | null
picked: CoverChoice | null
choices: CoverChoice[]
locked: boolean
providers: MetadataProviderInfo[]
/** The strip's own search is still running, for the other medium's cover. */
searching?: boolean
searchingText?: string
/** Shown when the strip has nothing, for the other medium's cover. */
emptyText?: string
}>()
const emit = defineEmits<{
keep: []
use: []
pick: [MetadataCandidate | null]
}>()
const { t } = useI18n()
const previewOpen = ref(false)
const currentSize = ref<CoverSize | null>(null)
watch(
() => props.currentUrl,
() => {
currentSize.value = null
},
)
const ratio = computed(() => (props.medium === 'audio' ? '1/1' : '2/3'))
const frameWidth = computed(() => (props.medium === 'audio' ? 'w-[8.25rem]' : 'w-[6.5rem]'))
const shown = computed(() => props.picked ?? props.active)
const using = computed(() => props.picked !== null)
const pickedElsewhere = computed(
() =>
props.picked !== null && props.active !== null && props.picked.candidate !== props.active.candidate && !sameResult(props.picked, props.active),
)
const slotText = computed(() =>
props.medium === 'audio' ? t('book.detail.editMetadata.match.cover.slotAudio') : t('book.detail.editMetadata.match.cover.slotBook'),
)
function sameResult(a: CoverChoice, b: CoverChoice): boolean {
return a.candidate.provider === b.candidate.provider && a.candidate.providerId === b.candidate.providerId
}
function fitText(choice: CoverChoice): string {
if (choice.broken) return t('book.detail.editMetadata.match.cover.broken')
if (choice.fit === 'match') return t('book.detail.editMetadata.match.cover.fits')
if (choice.fit === 'unknown') return t('book.detail.editMetadata.match.cover.unknown')
return props.medium === 'audio'
? t('book.detail.editMetadata.match.cover.portraitInSquare')
: t('book.detail.editMetadata.match.cover.squareInPortrait')
}
function fitShort(choice: CoverChoice): string {
if (choice.broken) return t('book.detail.editMetadata.match.cover.brokenShort')
if (choice.fit === 'match') return t('book.detail.editMetadata.match.cover.fitShort')
if (choice.fit === 'unknown') return t('book.detail.editMetadata.match.cover.unknown')
return props.medium === 'audio' ? t('book.detail.editMetadata.match.cover.portraitShort') : t('book.detail.editMetadata.match.cover.squareShort')
}
function fitClass(choice: CoverChoice): string {
if (choice.broken) return 'text-destructive'
if (choice.fit === 'match') return 'text-success'
if (choice.fit === 'unknown') return 'text-muted-foreground'
return 'text-warning'
}
function sizeText(size: CoverSize | null): string {
return size ? t('book.detail.editMetadata.match.cover.size', { width: size.width, height: size.height }) : ''
}
function isPicked(choice: CoverChoice): boolean {
return props.picked !== null && sameResult(props.picked, choice)
}
function isViewed(choice: CoverChoice): boolean {
return props.picked === null && props.active !== null && sameResult(props.active, choice)
}
function choiceLabel(choice: CoverChoice): string {
const provider = getProviderLabel(choice.candidate.provider, props.providers)
return choice.size
? t('book.detail.editMetadata.match.cover.choiceSized', { provider, width: choice.size.width, height: choice.size.height })
: t('book.detail.editMetadata.match.cover.choice', { provider })
}
function providerLabel(candidate: MetadataCandidate): string {
return getProviderLabel(candidate.provider, props.providers)
}
function handleCurrentLoad(event: Event) {
const image = event.target as HTMLImageElement
currentSize.value = { width: image.naturalWidth, height: image.naturalHeight }
}
function openPreview() {
previewOpen.value = true
}
function handlePreviewOpenChange(open: boolean) {
previewOpen.value = open
}
function handleKeep() {
emit('keep')
}
function handleUse() {
emit('use')
}
function handlePick(choice: CoverChoice) {
emit('pick', isPicked(choice) ? null : choice.candidate)
}
</script>
<template>
<section class="overflow-hidden rounded-xl border border-border bg-card" :aria-label="label">
<header class="flex flex-wrap items-center gap-x-2.5 gap-y-1.5 border-b border-border/60 py-2 ps-3.5 pe-3">
<h4 class="text-[10.5px] font-bold tracking-[0.12em] text-muted-foreground uppercase">{{ label }}</h4>
<span class="hidden text-[11.5px] text-muted-foreground @xl/compare:inline">{{ slotText }}</span>
<span class="flex-1" />
<span v-if="pickedElsewhere && picked" class="flex items-center gap-1.5 text-[11px] text-muted-foreground">
{{ t('book.detail.editMetadata.match.cover.using') }}
<span
class="inline-flex h-[18px] items-center rounded-md px-1.5 text-[10.5px] font-bold"
:style="providerBadgeStyle(picked.candidate.provider)"
>{{ providerLabel(picked.candidate) }}</span
>
</span>
<div class="flex min-w-[8.5rem] flex-wrap gap-0.5 rounded-lg border border-border/60 bg-muted p-0.5" role="radiogroup" :aria-label="label">
<button
type="button"
role="radio"
:aria-checked="!using"
:disabled="locked"
class="inline-flex min-h-[26px] flex-auto items-center justify-center rounded-md px-2 py-0.5 text-xs font-medium whitespace-nowrap focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary disabled:cursor-not-allowed disabled:text-muted-foreground"
:class="!using ? 'bg-background shadow-xs ring-1 ring-border' : 'hover:enabled:bg-background/60'"
@click="handleKeep"
>
{{ t('book.detail.editMetadata.match.decision.keep') }}
</button>
<button
type="button"
role="radio"
:aria-checked="using"
:disabled="locked || !active"
class="inline-flex min-h-[26px] flex-auto items-center justify-center rounded-md px-2 py-0.5 text-xs font-medium whitespace-nowrap focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary disabled:cursor-not-allowed disabled:text-muted-foreground"
:class="using ? 'bg-primary text-primary-foreground' : 'hover:enabled:bg-background/60'"
@click="handleUse"
>
{{ t('book.detail.editMetadata.match.decision.use') }}
</button>
</div>
</header>
<div class="grid gap-4 p-3.5 @2xl/compare:grid-cols-[auto_minmax(0,1fr)]">
<div class="flex items-start gap-4">
<figure class="m-0 grid justify-items-start gap-1.5">
<button
type="button"
class="group relative block overflow-hidden rounded-lg bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-default ring-1 ring-border"
:class="[frameWidth, using ? 'grayscale-[0.6] brightness-[0.8]' : '']"
:style="{ aspectRatio: ratio }"
:disabled="!currentUrl"
:aria-label="t('book.detail.editMetadata.match.cover.preview')"
@click="openPreview"
>
<template v-if="currentUrl">
<img :src="currentUrl" alt="" aria-hidden="true" class="absolute inset-0 size-full scale-125 object-cover blur-md brightness-75" />
<img
:src="currentUrl"
:alt="t('book.detail.editMetadata.diff.currentCoverAlt')"
class="relative size-full object-contain"
@load="handleCurrentLoad"
@error="hideOnError"
/>
</template>
<span v-else class="grid size-full place-items-center text-muted-foreground"><ImageOff class="size-5" aria-hidden="true" /></span>
<span
v-if="currentUrl"
class="absolute inset-0 grid place-items-center bg-black/35 text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 motion-reduce:transition-none"
aria-hidden="true"
>
<Maximize2 class="size-5" />
</span>
</button>
<figcaption class="grid gap-0.5 text-[11.5px]">
<span class="text-[9.5px] font-bold tracking-[0.1em] text-muted-foreground uppercase">{{
t('book.detail.editMetadata.match.cover.current')
}}</span>
<span v-if="currentSize" class="font-mono text-[11px] tabular-nums">{{ sizeText(currentSize) }}</span>
<span v-else-if="!currentUrl" class="text-muted-foreground">{{ t('book.detail.editMetadata.match.cover.noCurrent') }}</span>
</figcaption>
</figure>
<ArrowRight class="mt-14 size-[18px] text-muted-foreground rtl:rotate-180" aria-hidden="true" />
<figure class="m-0 grid justify-items-start gap-1.5">
<button
type="button"
class="group relative block overflow-hidden rounded-lg bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-default"
:class="[frameWidth, using ? 'ring-2 ring-primary' : 'ring-1 ring-border']"
:style="{ aspectRatio: ratio }"
:disabled="!shown"
:aria-label="t('book.detail.editMetadata.match.cover.preview')"
@click="openPreview"
>
<template v-if="shown">
<img :src="shown.url" alt="" aria-hidden="true" class="absolute inset-0 size-full scale-125 object-cover blur-md brightness-75" />
<img
:src="shown.url"
:alt="t('book.detail.editMetadata.diff.newCoverAlt')"
class="relative size-full object-contain"
@error="hideOnError"
/>
</template>
<span v-else class="grid size-full place-items-center text-muted-foreground"><ImageOff class="size-5" aria-hidden="true" /></span>
<span
v-if="shown"
class="absolute inset-0 grid place-items-center bg-black/35 text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 motion-reduce:transition-none"
aria-hidden="true"
>
<Maximize2 class="size-5" />
</span>
</button>
<figcaption class="grid gap-0.5 text-[11.5px]">
<span class="text-[9.5px] font-bold tracking-[0.1em] text-muted-foreground uppercase">{{
using ? t('book.detail.editMetadata.match.cover.staged') : shown ? providerLabel(shown.candidate) : ''
}}</span>
<template v-if="shown">
<span v-if="shown.size" class="font-mono text-[11px] tabular-nums">{{ sizeText(shown.size) }}</span>
<span class="inline-flex items-center gap-1 text-[11px] font-semibold" :class="fitClass(shown)">
<CircleCheck v-if="shown.fit === 'match' && !shown.broken" class="size-3" aria-hidden="true" />
<TriangleAlert v-else class="size-3" aria-hidden="true" />
{{ fitText(shown) }}
</span>
</template>
<span v-else class="text-muted-foreground">{{ t('book.detail.editMetadata.match.cover.noCover') }}</span>
</figcaption>
</figure>
</div>
<div
class="grid min-w-0 content-start gap-2 border-t border-border/60 pt-3 @2xl/compare:border-t-0 @2xl/compare:border-s @2xl/compare:ps-4 @2xl/compare:pt-0"
>
<div class="flex flex-wrap items-baseline justify-between gap-2">
<span class="text-[10.5px] font-bold tracking-[0.12em] text-muted-foreground uppercase">{{
t('book.detail.editMetadata.match.cover.strip')
}}</span>
<span v-if="choices.length" class="text-[11px] text-muted-foreground tabular-nums">{{
t('book.detail.editMetadata.match.cover.stripMeta', { count: choices.length })
}}</span>
</div>
<div
v-if="choices.length"
class="flex gap-3 overflow-x-auto p-1.5 pb-2"
role="radiogroup"
:aria-label="t('book.detail.editMetadata.match.cover.choose')"
>
<button
v-for="choice in choices"
:key="`${choice.candidate.provider}:${choice.candidate.providerId}`"
type="button"
role="radio"
class="grid w-16 shrink-0 justify-items-center gap-1 rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed"
:aria-checked="isPicked(choice)"
:aria-label="choiceLabel(choice)"
:disabled="locked"
@click="handlePick(choice)"
>
<span
class="relative block w-16 overflow-hidden rounded-md bg-muted"
:class="
isPicked(choice)
? 'ring-1 ring-border outline-2 outline-offset-2 outline-primary outline-dashed'
: isViewed(choice)
? 'ring-1 ring-border outline-2 outline-offset-2 outline-muted-foreground outline-dashed'
: 'ring-1 ring-border'
"
:style="{ aspectRatio: ratio }"
>
<img :src="choice.url" alt="" class="size-full object-contain" loading="lazy" @error="hideOnError" />
<span class="absolute inset-x-0 bottom-0 h-[3px]" :style="{ backgroundColor: getProviderColor(choice.candidate.provider) }" />
<span
v-if="isPicked(choice)"
class="absolute end-1 top-1 grid size-4 place-items-center rounded-full bg-primary text-primary-foreground"
>
<Check class="size-2.5" aria-hidden="true" />
</span>
</span>
<span v-if="choice.size" class="font-mono text-[10px] whitespace-nowrap text-muted-foreground tabular-nums"
>{{ choice.size.width }}×{{ choice.size.height }}</span
>
<span class="text-[10px] font-semibold" :class="fitClass(choice)">{{ fitShort(choice) }}</span>
</button>
</div>
<p v-if="searching" role="status" class="inline-flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Loader2 class="size-3 animate-spin motion-reduce:animate-none" aria-hidden="true" />{{ searchingText }}
</p>
<p v-else-if="!choices.length" class="text-[11px] text-muted-foreground">
{{ emptyText ?? t('book.detail.editMetadata.match.cover.noCover') }}
</p>
</div>
</div>
<DialogRoot :open="previewOpen" @update:open="handlePreviewOpenChange">
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-[60] bg-scrim-media data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 motion-reduce:animate-none"
/>
<DialogContent
class="fixed top-1/2 left-1/2 z-[60] max-h-[94vh] w-[min(96vw,84rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 motion-reduce:animate-none"
>
<DialogTitle class="sr-only">{{ t('book.detail.editMetadata.match.cover.previewTitle') }}</DialogTitle>
<DialogDescription class="sr-only">{{ t('book.detail.editMetadata.match.cover.previewDescription') }}</DialogDescription>
<div class="grid items-end justify-items-center gap-6 p-2" :class="currentUrl && shown ? 'sm:grid-cols-2' : ''">
<figure v-if="currentUrl" class="m-0 grid justify-items-center gap-2">
<img
:src="currentUrl"
:alt="t('book.detail.editMetadata.diff.currentCoverAlt')"
class="max-h-[40vh] max-w-full rounded-md object-contain shadow-2xl sm:max-h-[80vh]"
:class="using ? 'grayscale-[0.4]' : ''"
@error="hideOnError"
/>
<figcaption class="grid justify-items-center gap-0.5 text-center text-sm text-white">
<span class="font-semibold">{{ t('book.detail.editMetadata.match.cover.current') }}</span>
<span v-if="currentSize" class="font-mono text-xs text-white/75 tabular-nums">{{ sizeText(currentSize) }}</span>
</figcaption>
</figure>
<figure v-if="shown" class="m-0 grid justify-items-center gap-2">
<img
:src="shown.url"
:alt="t('book.detail.editMetadata.diff.newCoverAlt')"
class="max-h-[40vh] max-w-full rounded-md object-contain shadow-2xl sm:max-h-[80vh]"
:class="using ? 'ring-2 ring-primary' : ''"
@error="hideOnError"
/>
<figcaption class="grid justify-items-center gap-0.5 text-center text-sm text-white">
<span class="font-semibold">{{ using ? t('book.detail.editMetadata.match.cover.staged') : providerLabel(shown.candidate) }}</span>
<span class="text-xs text-white/75">
<span v-if="shown.size" class="font-mono tabular-nums">{{ sizeText(shown.size) }} · </span>{{ fitText(shown) }}
</span>
</figcaption>
</figure>
</div>
<DialogClose
class="absolute end-2 top-2 grid size-9 place-items-center rounded-full border border-border bg-background text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:aria-label="t('common.close')"
>
<X class="size-4" aria-hidden="true" />
</DialogClose>
</DialogContent>
</DialogPortal>
</DialogRoot>
</section>
</template>
@@ -0,0 +1,299 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Check, ChevronDown, ChevronUp, Layers, Lock } from '@lucide/vue'
import type { MetadataCandidate, MetadataProviderInfo } from '@bookorbit/types'
import type { DiffField, FieldDecision, OtherValue } from '../../composables/useMetadataDiff'
import { isActionable, type DiffFieldKey } from '../../lib/metadata-diff-fields'
import { getProviderLabel, providerBadgeStyle } from '../../lib/metadata-fetch'
import MetadataMatchValue from './MetadataMatchValue.vue'
const props = defineProps<{
field: DiffField
candidate: MetadataCandidate
providers: MetadataProviderInfo[]
decision: FieldDecision
index: number
}>()
const emit = defineEmits<{
decide: [key: DiffFieldKey, decision: FieldDecision, from?: MetadataCandidate]
}>()
const { t } = useI18n()
const othersOpen = ref(false)
const expandedEntries = ref(new Set<string>())
/** Only a description is long enough to need folding. */
const LONG_ENTRY_CHARACTERS = 240
watch(
() => props.field.key,
() => {
othersOpen.value = false
expandedEntries.value = new Set()
},
)
const label = computed(() => t(props.field.labelKey, props.field.labelParams ?? {}))
const actionable = computed(() => isActionable(props.field.kind))
const otherCandidate = computed(() => (props.field.isPicked && !props.field.pickedFromActive ? props.field.pickedCandidate : null))
const showControl = computed(() => actionable.value || otherCandidate.value !== null)
const kindLabel = computed(() => {
if (props.field.kind === 'minor' && props.field.minorReason) return t(`book.detail.editMetadata.match.minor.${props.field.minorReason}`)
return t(`book.detail.editMetadata.match.kind.${props.field.kind === 'minor' ? 'change' : props.field.kind}`)
})
const kindClass = computed(() => {
if (props.field.kind === 'fill') return 'bg-success/15 text-success'
if (props.field.kind === 'change') return 'bg-warning/15 text-warning'
return 'bg-muted text-muted-foreground'
})
const options = computed<{ value: FieldDecision; label: string }[]>(() => {
const keep = { value: 'keep' as const, label: t('book.detail.editMetadata.match.decision.keep') }
if (props.field.mergeable) {
return [
keep,
{ value: 'merge', label: t('book.detail.editMetadata.match.decision.merge') },
{ value: 'replace', label: t('book.detail.editMetadata.match.decision.replace') },
]
}
return [keep, { value: 'use', label: t('book.detail.editMetadata.match.decision.use') }]
})
/** The option shown as chosen: a value staged from another result still reads as not kept. */
const shown = computed<FieldDecision>(() => {
if (props.decision !== 'keep' || !otherCandidate.value) return props.decision
return props.field.mergeable ? 'merge' : 'use'
})
const using = computed(() => shown.value !== 'keep' && props.field.pickedFromActive)
const currentReplaced = computed(() => using.value && (shown.value === 'use' || shown.value === 'replace') && props.field.kind !== 'fill')
const activeLabel = computed(() => getProviderLabel(props.candidate.provider, props.providers))
function providerLabel(candidate: MetadataCandidate): string {
return getProviderLabel(candidate.provider, props.providers)
}
function uniqueProviders(entry: OtherValue): MetadataCandidate[] {
const seen = new Set<string>()
return entry.candidates.filter((candidate) => (seen.has(candidate.provider) ? false : (seen.add(candidate.provider), true)))
}
function choose(value: FieldDecision) {
if (props.field.isLocked) return
emit('decide', props.field.key, value)
}
function handleOptionKeydown(event: KeyboardEvent) {
const step = event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1 : event.key === 'ArrowLeft' || event.key === 'ArrowUp' ? -1 : 0
if (!step) return
event.preventDefault()
event.stopPropagation()
const enabled = options.value.filter((option) => option.value === 'keep' || actionable.value)
const index = enabled.findIndex((option) => option.value === shown.value)
const next = enabled[(index + step + enabled.length) % enabled.length]
if (!next) return
choose(next.value)
const group = (event.currentTarget as HTMLElement).closest('[role="radiogroup"]')
requestAnimationFrame(() => group?.querySelector<HTMLElement>(`[data-option="${next.value}"]`)?.focus())
}
function useOther(entry: OtherValue) {
const from = entry.candidates[0]
if (!from || props.field.isLocked) return
emit('decide', props.field.key, props.field.mergeable ? (props.decision === 'replace' ? 'replace' : 'merge') : 'use', from)
}
function switchToActive() {
emit('decide', props.field.key, props.field.mergeable ? 'merge' : 'use', props.candidate)
}
function isLongEntry(entry: OtherValue): boolean {
return !entry.list && entry.display.length > LONG_ENTRY_CHARACTERS
}
function wordCount(text: string): number {
return text.split(/\s+/).filter(Boolean).length
}
function toggleEntry(entry: OtherValue) {
const next = new Set(expandedEntries.value)
if (next.has(entry.display)) next.delete(entry.display)
else next.add(entry.display)
expandedEntries.value = next
}
function toggleOthers() {
othersOpen.value = !othersOpen.value
}
</script>
<template>
<div
class="relative grid grid-cols-1 gap-x-3 gap-y-2 border-b border-border/60 py-3 outline-none @2xl/compare:grid-cols-2 focus-visible:before:pointer-events-none focus-visible:before:absolute focus-visible:before:-inset-x-2.5 focus-visible:before:inset-y-0.5 focus-visible:before:rounded-[10px] focus-visible:before:ring-[1.5px] focus-visible:before:ring-primary/60"
tabindex="-1"
:data-field-row="index"
:data-field-key="field.key"
>
<div class="flex flex-wrap items-start gap-x-3 gap-y-1.5 @2xl/compare:col-span-2">
<div class="flex min-h-[30px] flex-wrap items-center gap-x-2 gap-y-1">
<span class="text-[10.5px] font-bold tracking-[0.12em] text-muted-foreground uppercase">{{ label }}</span>
<span
v-if="field.isLocked"
class="inline-flex h-[18px] items-center gap-1 rounded-md bg-primary/15 px-1.5 text-[10.5px] font-semibold text-primary"
>
<Lock class="size-2.5" aria-hidden="true" />{{ t('book.detail.editMetadata.diff.locked') }}
</span>
<span v-else class="inline-flex h-[18px] items-center rounded-md px-1.5 text-[10.5px] font-semibold" :class="kindClass">{{ kindLabel }}</span>
</div>
<div class="ms-auto grid justify-items-end gap-1.5">
<template v-if="showControl">
<div
class="flex min-w-[8.5rem] flex-wrap justify-end gap-0.5 rounded-lg border border-border/60 bg-muted p-0.5"
role="radiogroup"
:aria-label="label"
:aria-disabled="field.isLocked || undefined"
>
<button
v-for="option in options"
:key="option.value"
type="button"
role="radio"
:data-option="option.value"
:aria-checked="shown === option.value"
:tabindex="shown === option.value ? 0 : -1"
:disabled="field.isLocked || (option.value !== 'keep' && !actionable && !otherCandidate)"
class="inline-flex min-h-[26px] flex-auto items-center justify-center gap-1 rounded-md px-1.5 py-0.5 text-xs font-medium whitespace-nowrap text-foreground transition-colors focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary disabled:cursor-not-allowed disabled:text-muted-foreground"
:class="
shown === option.value
? option.value === 'keep'
? 'bg-background shadow-xs ring-1 ring-border'
: 'bg-primary text-primary-foreground'
: 'hover:enabled:bg-background/60'
"
@click="choose(option.value)"
@keydown="handleOptionKeydown"
>
<Lock v-if="field.isLocked && option.value === 'keep'" class="size-3" aria-hidden="true" />
<span>{{ option.label }}</span>
</button>
</div>
<p v-if="otherCandidate" class="grid justify-items-end gap-1 text-end text-[11px] leading-snug text-muted-foreground">
<span class="flex flex-wrap items-center justify-end gap-1">
{{ t('book.detail.editMetadata.match.decision.usingFrom') }}
<span
class="inline-flex h-[18px] items-center rounded-md px-1.5 text-[10.5px] font-bold"
:style="providerBadgeStyle(otherCandidate.provider)"
>{{ providerLabel(otherCandidate) }}</span
>
</span>
<button
v-if="actionable && !field.isLocked"
type="button"
class="-mx-1 rounded-md px-1 py-0.5 text-[11.5px] font-medium text-foreground hover:bg-muted"
@click="switchToActive"
>
{{ t('book.detail.editMetadata.match.decision.switchTo', { provider: activeLabel }) }}
</button>
</p>
<p v-else-if="field.isLocked" class="max-w-[16rem] text-end text-[11px] leading-snug text-muted-foreground">
{{ t('book.detail.editMetadata.match.decision.lockedHint') }}
</p>
</template>
<p v-else class="py-1.5 text-end text-[11.5px] text-muted-foreground">
{{
field.kind === 'same'
? t('book.detail.editMetadata.match.decision.alreadyMatches')
: t('book.detail.editMetadata.match.decision.nothingToApply')
}}
</p>
</div>
</div>
<div
class="min-w-0 rounded-lg border px-2.5 py-[7px] text-[13px] leading-normal"
:class="currentReplaced ? 'border-border text-muted-foreground' : 'border-border'"
>
<span class="mb-0.5 block text-[9.5px] font-bold tracking-[0.1em] text-muted-foreground uppercase @2xl/compare:hidden">{{
t('book.detail.editMetadata.diffPanel.current')
}}</span>
<MetadataMatchValue :field="field" side="current" :decision="shown" />
</div>
<div
class="min-w-0 rounded-lg border px-2.5 py-[7px] text-[13px] leading-normal"
:class="using ? 'border-primary/50 bg-primary/10' : 'border-transparent bg-muted/55'"
>
<span class="mb-0.5 block text-[9.5px] font-bold tracking-[0.1em] text-muted-foreground uppercase @2xl/compare:hidden">{{ activeLabel }}</span>
<MetadataMatchValue :field="field" side="incoming" :decision="shown" />
<div v-if="field.otherValues.length" class="mt-1.5">
<button
type="button"
class="-mx-1 inline-flex items-center gap-1 rounded-md px-1 py-0.5 text-[11.5px] font-medium text-foreground hover:bg-muted"
:aria-expanded="othersOpen"
@click="toggleOthers"
>
<Layers class="size-3" aria-hidden="true" />
{{ t('book.detail.editMetadata.match.value.otherValues', { count: field.otherValues.length }) }}
<ChevronUp v-if="othersOpen" class="size-3" aria-hidden="true" />
<ChevronDown v-else class="size-3" aria-hidden="true" />
</button>
</div>
</div>
<div v-if="othersOpen && field.otherValues.length" class="overflow-hidden rounded-[10px] border border-border bg-card @2xl/compare:col-span-2">
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 px-2.5 py-[7px]">
<span class="text-[10.5px] font-bold tracking-[0.12em] text-muted-foreground uppercase">{{
t('book.detail.editMetadata.match.value.otherValuesHeading', { field: label })
}}</span>
<span class="text-[11px] text-muted-foreground">{{ t('book.detail.editMetadata.match.value.otherValuesHint') }}</span>
</div>
<div
v-for="entry in field.otherValues"
:key="entry.display"
class="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2.5 gap-y-1 border-b border-border/60 px-2.5 py-[7px] text-[12.5px] last:border-b-0 @2xl/compare:grid-cols-[9.5rem_minmax(0,1fr)_auto]"
>
<span class="col-span-2 flex flex-wrap gap-1 @2xl/compare:col-span-1">
<span
v-for="source in uniqueProviders(entry)"
:key="source.provider"
class="inline-flex h-[18px] items-center rounded-md px-1.5 text-[10.5px] font-bold"
:style="providerBadgeStyle(source.provider)"
>{{ providerLabel(source) }}</span
>
<span
v-if="entry.matchesCurrent"
class="inline-flex h-[18px] items-center rounded-md bg-muted px-1.5 text-[10.5px] font-semibold text-muted-foreground"
>{{ t('book.detail.editMetadata.match.value.sameAsCurrent') }}</span
>
</span>
<span class="min-w-0">
<span
class="block [overflow-wrap:anywhere]"
:class="isLongEntry(entry) && !expandedEntries.has(entry.display) ? 'line-clamp-4' : 'whitespace-pre-line'"
>{{ entry.list ? entry.list.join(', ') : entry.display }}</span
>
<button
v-if="isLongEntry(entry)"
type="button"
class="-mx-1 mt-0.5 inline-flex items-center gap-1 rounded-md px-1 py-0.5 text-[11.5px] font-medium text-foreground hover:bg-muted"
:aria-expanded="expandedEntries.has(entry.display)"
@click="toggleEntry(entry)"
>
{{
expandedEntries.has(entry.display)
? t('book.detail.editMetadata.diff.showLess')
: t('book.detail.editMetadata.match.value.showAll', { count: wordCount(entry.display) })
}}
</button>
</span>
<button
type="button"
class="inline-flex h-6 items-center gap-1 rounded-md border px-2 text-[11.5px] font-medium whitespace-nowrap transition-colors disabled:cursor-not-allowed disabled:text-muted-foreground"
:class="entry.isPicked ? 'border-transparent bg-primary text-primary-foreground' : 'border-border bg-background hover:enabled:bg-muted'"
:disabled="field.isLocked || entry.matchesCurrent"
@click="useOther(entry)"
>
<Check v-if="entry.isPicked" class="size-3" aria-hidden="true" />
{{ entry.isPicked ? t('book.detail.editMetadata.match.value.using') : t('book.detail.editMetadata.match.value.useThis') }}
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowRight, ChevronUp, ListChecks, Undo2 } from '@lucide/vue'
import type { MetadataProviderInfo, MetadataProviderKey } from '@bookorbit/types'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import type { StagedChange } from '../../composables/useMetadataDiff'
import { getProviderColor, getProviderLabel, hideOnError, providerBadgeStyle } from '../../lib/metadata-fetch'
const props = defineProps<{
staged: StagedChange[]
providers: MetadataProviderInfo[]
/** What Apply does with the changes, when it is not filling a form that still needs saving. */
hint?: string
}>()
const emit = defineEmits<{
apply: []
cancel: []
unstage: [key: string]
clearAll: []
}>()
const { t } = useI18n()
const count = computed(() => props.staged.length)
const sources = computed<MetadataProviderKey[]>(() => [...new Set(props.staged.map((change) => change.candidate.provider))])
function providerLabel(provider: MetadataProviderKey): string {
return getProviderLabel(provider, props.providers)
}
function labelOf(change: StagedChange): string {
return t(change.labelKey, change.labelParams ?? {})
}
function handleApply() {
emit('apply')
}
function handleCancel() {
emit('cancel')
}
function handleUnstage(key: string) {
emit('unstage', key)
}
function handleClearAll() {
emit('clearAll')
}
</script>
<template>
<footer class="flex items-center gap-3 border-t border-border bg-card/60 px-4 py-2.5 @3xl/match:ps-[1.125rem]">
<span class="sr-only" aria-live="polite">{{ t('book.detail.editMetadata.match.footer.live', { count }) }}</span>
<div class="flex min-w-0 flex-1 items-center gap-2.5">
<Popover v-if="count">
<PopoverTrigger as-child>
<button
type="button"
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 text-xs font-medium hover:bg-muted"
>
<ListChecks class="size-3.5" aria-hidden="true" />
{{ t('book.detail.editMetadata.match.footer.changes', { count }) }}
<ChevronUp class="size-3" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent side="top" align="start" :side-offset="8" class="w-[min(31rem,calc(100vw-2rem))] overflow-hidden p-0">
<div class="flex items-center justify-between border-b border-border/60 px-3 py-2.5">
<span class="text-[10.5px] font-bold tracking-[0.12em] text-muted-foreground uppercase">{{
t('book.detail.editMetadata.match.footer.stagedHeading')
}}</span>
<button type="button" class="-mx-1 rounded-md px-1 py-0.5 text-[11.5px] font-medium hover:bg-muted" @click="handleClearAll">
{{ t('book.detail.editMetadata.match.footer.clearAll') }}
</button>
</div>
<ul class="max-h-80 overflow-y-auto">
<li
v-for="change in staged"
:key="change.key"
class="grid grid-cols-[7rem_minmax(0,1fr)_1.75rem] items-start gap-2.5 border-b border-border/60 px-3 py-2 text-[12.5px] last:border-b-0"
>
<span class="grid justify-items-start gap-0.5">
<span class="font-semibold">{{ labelOf(change) }}</span>
<span
class="inline-flex h-[18px] items-center rounded-md px-1.5 text-[10.5px] font-bold"
:style="providerBadgeStyle(change.candidate.provider)"
>{{ providerLabel(change.candidate.provider) }}</span
>
</span>
<span v-if="change.isCover" class="flex items-center gap-2">
<span class="block h-10 w-7 overflow-hidden rounded-sm bg-muted ring-1 ring-border">
<img v-if="change.before" :src="change.before" alt="" class="size-full object-cover" @error="hideOnError" />
</span>
<ArrowRight class="size-3 shrink-0 text-muted-foreground rtl:rotate-180" aria-hidden="true" />
<span class="block h-10 w-7 overflow-hidden rounded-sm bg-muted ring-1 ring-border">
<img :src="change.after" alt="" class="size-full object-cover" @error="hideOnError" />
</span>
</span>
<span v-else class="grid min-w-0 gap-0.5">
<span
class="text-muted-foreground [overflow-wrap:anywhere]"
:class="[change.before ? 'line-through' : 'italic', change.field === 'description' ? 'line-clamp-3' : '']"
:title="change.field === 'description' ? change.before : undefined"
>{{ change.before || t('book.detail.editMetadata.match.value.empty') }}</span
>
<span class="flex min-w-0 items-start gap-1.5">
<ArrowRight class="mt-[3px] size-3 shrink-0 text-muted-foreground rtl:rotate-180" aria-hidden="true" />
<span
class="min-w-0 [overflow-wrap:anywhere]"
:class="change.field === 'description' ? 'line-clamp-3' : ''"
:title="change.field === 'description' ? change.after : undefined"
>
{{ change.after
}}<span v-if="change.merged" class="text-muted-foreground"> ({{ t('book.detail.editMetadata.match.footer.merged') }})</span>
</span>
</span>
</span>
<button
type="button"
class="grid size-7 place-items-center rounded-md text-foreground hover:bg-muted"
:aria-label="t('book.detail.editMetadata.match.footer.undo', { field: labelOf(change) })"
@click="handleUnstage(change.key)"
>
<Undo2 class="size-3.5" aria-hidden="true" />
</button>
</li>
</ul>
</PopoverContent>
</Popover>
<span v-if="count" class="hidden min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground @2xl/match:flex">
{{ t('book.detail.editMetadata.match.footer.from') }}
<span v-for="provider in sources" :key="provider" class="inline-flex items-center gap-1">
<span class="size-[7px] rounded-full" :style="{ backgroundColor: getProviderColor(provider) }" />{{ providerLabel(provider) }}
</span>
</span>
<span v-else class="min-w-0 text-[12.5px] leading-snug text-muted-foreground">
<span class="@2xl/match:hidden">{{ t('book.detail.editMetadata.match.footer.nothingStagedShort') }}</span>
<span class="hidden @2xl/match:inline">{{ t('book.detail.editMetadata.match.footer.nothingStaged') }}</span>
</span>
</div>
<span class="hidden text-[11.5px] whitespace-nowrap text-muted-foreground @4xl/match:inline">{{
hint ?? t('book.detail.editMetadata.match.footer.formHint')
}}</span>
<button
type="button"
class="hidden h-8 items-center rounded-lg border border-border bg-background px-3 text-sm font-medium hover:bg-muted @2xl/match:inline-flex"
@click="handleCancel"
>
{{ t('common.cancel') }}
</button>
<button
type="button"
class="inline-flex h-8 items-center rounded-lg bg-primary px-3.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
:disabled="!count"
@click="handleApply"
>
{{ t('book.detail.editMetadata.match.footer.apply', { count }) }}
</button>
</footer>
</template>
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { computed, reactive, ref, useId, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Loader2, RotateCcw, Search } from '@lucide/vue'
export interface MetadataQuery {
title: string
author: string
isbn: string
}
const props = defineProps<{
defaults: { title?: string; author?: string; isbn?: string }
busy: boolean
}>()
const emit = defineEmits<{ search: [MetadataQuery] }>()
const { t } = useI18n()
const id = useId()
const titleInput = ref<HTMLTextAreaElement | null>(null)
const initial = (): MetadataQuery => ({ title: props.defaults.title ?? '', author: props.defaults.author ?? '', isbn: props.defaults.isbn ?? '' })
const form = reactive<MetadataQuery>(initial())
watch(
() => props.defaults,
() => Object.assign(form, initial()),
)
const canSearch = computed(() => !!(form.title.trim() || form.isbn.trim()))
const edited = computed(() => {
const base = initial()
return form.title !== base.title || form.author !== base.author || form.isbn !== base.isbn
})
function submit() {
if (!canSearch.value) return
emit('search', { title: form.title.trim(), author: form.author.trim(), isbn: form.isbn.trim() })
}
function useBookDetails() {
Object.assign(form, initial())
submit()
}
function focusTitle() {
titleInput.value?.focus()
titleInput.value?.select()
}
defineExpose({ focusTitle })
</script>
<template>
<form
class="grid gap-[7px] rounded-[10px] border border-border bg-card p-2.5"
role="search"
:aria-label="t('book.detail.editMetadata.match.query.label')"
@submit.prevent="submit"
>
<label
:for="`${id}-title`"
class="flex min-h-8 min-w-0 items-start gap-2 rounded-lg border border-input bg-background px-2.5 py-[5px] focus-within:border-primary focus-within:ring-3 focus-within:ring-primary/20"
>
<span class="shrink-0 pt-[3px] text-[9.5px] font-bold tracking-[0.1em] text-muted-foreground uppercase">{{
t('book.detail.editMetadata.match.query.title')
}}</span>
<textarea
:id="`${id}-title`"
ref="titleInput"
v-model="form.title"
rows="1"
class="max-h-[4.5rem] min-w-0 flex-1 resize-none bg-transparent text-sm leading-5 outline-none [field-sizing:content]"
autocomplete="off"
@keydown.enter.prevent="submit"
/>
</label>
<label
:for="`${id}-author`"
class="flex h-8 min-w-0 items-center gap-2 rounded-lg border border-input bg-background px-2.5 focus-within:border-primary focus-within:ring-3 focus-within:ring-primary/20"
>
<span class="shrink-0 text-[9.5px] font-bold tracking-[0.1em] text-muted-foreground uppercase">{{
t('book.detail.editMetadata.match.query.author')
}}</span>
<input :id="`${id}-author`" v-model="form.author" class="h-full min-w-0 flex-1 bg-transparent text-sm outline-none" autocomplete="off" />
</label>
<div class="grid grid-cols-[minmax(0,1fr)_auto] gap-[7px]">
<label
:for="`${id}-isbn`"
class="flex h-8 min-w-0 items-center gap-2 rounded-lg border border-input bg-background px-2.5 focus-within:border-primary focus-within:ring-3 focus-within:ring-primary/20"
>
<span class="shrink-0 text-[9.5px] font-bold tracking-[0.1em] text-muted-foreground uppercase">{{
t('book.detail.editMetadata.match.query.isbn')
}}</span>
<input
:id="`${id}-isbn`"
v-model="form.isbn"
class="h-full min-w-0 flex-1 bg-transparent font-mono text-xs outline-none"
autocomplete="off"
/>
</label>
<button
type="submit"
class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary px-3 text-sm font-semibold whitespace-nowrap text-primary-foreground transition-colors hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
:disabled="!canSearch"
>
<Loader2 v-if="busy" class="size-4 animate-spin motion-reduce:animate-none" aria-hidden="true" />
<Search v-else class="size-4" aria-hidden="true" />
{{ t('common.search') }}
</button>
</div>
<div v-if="edited" class="flex items-center justify-between gap-2">
<span class="text-[11.5px] text-muted-foreground">{{ t('book.detail.editMetadata.match.query.edited') }}</span>
<button
type="button"
class="-mx-1 inline-flex items-center gap-1 rounded-md px-1 py-0.5 text-[11.5px] font-medium text-foreground hover:bg-muted"
@click="useBookDetails"
>
<RotateCcw class="size-3" aria-hidden="true" />{{ t('book.detail.editMetadata.match.query.useBookDetails') }}
</button>
</div>
</form>
</template>
@@ -0,0 +1,274 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChevronDown, ChevronUp, RefreshCw, RotateCcw, ScanSearch, TriangleAlert } from '@lucide/vue'
import type { MetadataCandidate, MetadataProviderInfo, MetadataProviderKey, MetadataProviderSearchOutcome } from '@bookorbit/types'
import { candidateKey, type MatchGroups } from '../../lib/metadata-match'
import type { DifferenceSummary } from '../../lib/metadata-diff-fields'
import MetadataMatchQuery, { type MetadataQuery } from './MetadataMatchQuery.vue'
import MetadataMatchSources from './MetadataMatchSources.vue'
import MetadataMatchResultRow from './MetadataMatchResultRow.vue'
const props = defineProps<{
searchDefaults: { title?: string; author?: string; isbn?: string }
providers: MetadataProviderInfo[]
selectedProviders: MetadataProviderKey[]
providerCounts: Partial<Record<MetadataProviderKey, number>>
interruptedProviders: { provider: MetadataProviderKey; outcome: MetadataProviderSearchOutcome }[]
retryingProviders: MetadataProviderKey[]
isStreaming: boolean
hasSearched: boolean
groups: MatchGroups
summaries: Map<string, DifferenceSummary>
selectedKey: string | null
stagedByCandidate: Map<string, number>
coverRatio: string
}>()
const emit = defineEmits<{
search: [MetadataQuery]
toggleProvider: [MetadataProviderKey]
selectAll: []
selectFieldRules: []
retryProvider: [MetadataProviderKey]
select: [MetadataCandidate]
}>()
const { t } = useI18n()
const query = ref<InstanceType<typeof MetadataMatchQuery> | null>(null)
const otherBooksOpen = defineModel<boolean>('otherBooksOpen', { default: false })
const INTERRUPTION_KEYS: Record<MetadataProviderSearchOutcome, string> = {
timeout: 'book.detail.editMetadata.searchPanel.providerTimedOut',
throttled: 'book.detail.editMetadata.searchPanel.providerThrottled',
failed: 'book.detail.editMetadata.searchPanel.providerFailed',
}
const total = computed(() => props.groups.strong.length + props.groups.possible.length + props.groups.weak.length)
const interruptions = computed(() =>
props.interruptedProviders
.filter((entry) => !props.retryingProviders.includes(entry.provider))
.map(({ provider, outcome }) => {
const label = props.providers.find((p) => p.key === provider)?.label ?? provider
return { provider, label, message: t(INTERRUPTION_KEYS[outcome], { provider: label }) }
}),
)
const noResults = computed(() => props.hasSearched && !props.isStreaming && total.value === 0 && !props.retryingProviders.length)
const sourceCount = computed(() => props.selectedProviders.length || props.providers.length)
const SUMMARY_NONE: DifferenceSummary = { fills: 0, changes: 0 }
function summaryOf(candidate: MetadataCandidate): DifferenceSummary {
return props.summaries.get(candidateKey(candidate)) ?? SUMMARY_NONE
}
function stagedOf(candidate: MetadataCandidate): number {
return props.stagedByCandidate.get(candidateKey(candidate)) ?? 0
}
function isSelected(candidate: MetadataCandidate): boolean {
return props.selectedKey === candidateKey(candidate)
}
function handleSearch(value: MetadataQuery) {
emit('search', value)
}
function handleSearchTitleOnly() {
emit('search', { title: props.searchDefaults.title ?? '', author: '', isbn: '' })
}
function handleUseBookDetails() {
emit('search', { title: props.searchDefaults.title ?? '', author: props.searchDefaults.author ?? '', isbn: props.searchDefaults.isbn ?? '' })
}
function handleToggle(key: MetadataProviderKey) {
emit('toggleProvider', key)
}
function handleSelectAll() {
emit('selectAll')
}
function handleSelectFieldRules() {
emit('selectFieldRules')
}
function handleRetry(provider: MetadataProviderKey) {
emit('retryProvider', provider)
}
function handleSelect(candidate: MetadataCandidate) {
emit('select', candidate)
}
function toggleOtherBooks() {
otherBooksOpen.value = !otherBooksOpen.value
}
function focusQuery() {
query.value?.focusTitle()
}
defineExpose({ focusQuery })
</script>
<template>
<aside class="flex min-h-0 flex-col bg-card/50" :aria-label="t('book.detail.editMetadata.match.results.label')">
<div class="grid gap-3 border-b border-border/60 px-3.5 pt-3.5 pb-3">
<MetadataMatchQuery ref="query" :defaults="searchDefaults" :busy="isStreaming" @search="handleSearch" />
<MetadataMatchSources
v-if="providers.length"
:providers="providers"
:selected-providers="selectedProviders"
:provider-counts="providerCounts"
:interrupted-providers="interruptedProviders"
:retrying-providers="retryingProviders"
:is-streaming="isStreaming"
@toggle="handleToggle"
@select-all="handleSelectAll"
@select-field-rules="handleSelectFieldRules"
/>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-2 pt-1 pb-4" data-match-results>
<p v-if="!hasSearched" class="px-3 py-10 text-center text-sm text-muted-foreground">{{ t('book.detail.editMetadata.match.results.prompt') }}</p>
<div
v-else-if="noResults"
role="status"
class="mx-2 mt-4 grid justify-items-center gap-2.5 rounded-xl border border-dashed border-border px-3 py-6 text-center"
>
<span class="grid size-10 place-items-center rounded-full bg-muted text-muted-foreground"
><ScanSearch class="size-[18px]" aria-hidden="true"
/></span>
<p class="text-[13px] font-semibold">{{ t('book.detail.editMetadata.match.results.noMatches') }}</p>
<p class="max-w-[36ch] text-xs text-muted-foreground">
{{ t('book.detail.editMetadata.match.results.noMatchesHint', { count: sourceCount }) }}
</p>
<div v-for="entry in interruptions" :key="entry.provider" class="text-xs text-muted-foreground">{{ entry.message }}</div>
<div class="flex flex-wrap justify-center gap-1.5">
<button
type="button"
class="inline-flex h-6 items-center gap-1 rounded-md border border-border bg-background px-2 text-[11.5px] font-medium hover:bg-muted"
@click="handleUseBookDetails"
>
<RotateCcw class="size-3" aria-hidden="true" />{{ t('book.detail.editMetadata.match.query.useBookDetails') }}
</button>
<button
type="button"
class="inline-flex h-6 items-center rounded-md border border-border bg-background px-2 text-[11.5px] font-medium hover:bg-muted"
@click="handleSearchTitleOnly"
>
{{ t('book.detail.editMetadata.match.results.searchTitleOnly') }}
</button>
</div>
</div>
<template v-else>
<template v-if="groups.strong.length">
<h3 class="flex items-center gap-2 px-2 pt-3 pb-1.5 text-[10.5px] font-bold tracking-[0.12em] text-muted-foreground uppercase">
{{ t('book.detail.editMetadata.match.results.best')
}}<span class="text-[11px] font-semibold tracking-normal tabular-nums">{{ groups.strong.length }}</span>
</h3>
<MetadataMatchResultRow
v-for="match in groups.strong"
:key="candidateKey(match.candidate)"
:candidate="match.candidate"
:assessment="match.assessment"
:summary="summaryOf(match.candidate)"
:providers="providers"
:selected="isSelected(match.candidate)"
:staged-count="stagedOf(match.candidate)"
:cover-ratio="coverRatio"
@select="handleSelect"
/>
</template>
<template v-if="groups.possible.length">
<h3 class="flex items-center gap-2 px-2 pt-3 pb-1.5 text-[10.5px] font-bold tracking-[0.12em] text-muted-foreground uppercase">
{{ t('book.detail.editMetadata.match.results.otherEditions')
}}<span class="text-[11px] font-semibold tracking-normal tabular-nums">{{ groups.possible.length }}</span>
</h3>
<MetadataMatchResultRow
v-for="match in groups.possible"
:key="candidateKey(match.candidate)"
:candidate="match.candidate"
:assessment="match.assessment"
:summary="summaryOf(match.candidate)"
:providers="providers"
:selected="isSelected(match.candidate)"
:staged-count="stagedOf(match.candidate)"
:cover-ratio="coverRatio"
@select="handleSelect"
/>
</template>
<template v-if="isStreaming">
<h3 class="px-2 pt-3 pb-1.5 text-[10.5px] font-bold tracking-[0.12em] text-muted-foreground uppercase">
{{ t('book.detail.editMetadata.match.results.stillSearching') }}
</h3>
<div v-for="n in total ? 2 : 5" :key="n" class="grid grid-cols-[2.875rem_minmax(0,1fr)] gap-2.5 p-2" aria-hidden="true">
<span class="block w-full animate-pulse rounded-[5px] bg-muted motion-reduce:animate-none" :style="{ aspectRatio: coverRatio }" />
<span class="grid content-start gap-1.5 pt-1">
<span class="h-3 w-4/5 animate-pulse rounded bg-muted motion-reduce:animate-none" />
<span class="h-2.5 w-3/5 animate-pulse rounded bg-muted motion-reduce:animate-none" />
<span class="h-4 w-2/5 animate-pulse rounded bg-muted motion-reduce:animate-none" />
</span>
</div>
</template>
<div
v-for="entry in interruptions"
:key="entry.provider"
role="status"
class="mx-2 my-2 grid grid-cols-[1rem_minmax(0,1fr)] gap-2 rounded-lg border border-dashed border-warning/50 px-2.5 py-2 text-xs"
>
<TriangleAlert class="mt-0.5 size-3.5 text-warning" aria-hidden="true" />
<span>
<span class="font-semibold">{{ entry.message }}</span>
{{ t('book.detail.editMetadata.match.results.mayBeMissing') }}
<button
type="button"
class="mt-1.5 flex h-6 items-center gap-1 rounded-md border border-border bg-background px-2 text-[11.5px] font-medium hover:bg-muted"
@click="handleRetry(entry.provider)"
>
<RefreshCw class="size-3" aria-hidden="true" />{{ t('book.detail.editMetadata.match.results.retry', { provider: entry.label }) }}
</button>
</span>
</div>
<template v-if="groups.weak.length">
<button
type="button"
class="flex w-full items-center gap-2 rounded-lg px-2 pt-3 pb-1.5 text-start hover:bg-muted"
:aria-expanded="otherBooksOpen"
@click="toggleOtherBooks"
>
<span class="text-[10.5px] font-bold tracking-[0.12em] text-muted-foreground uppercase">{{
t('book.detail.editMetadata.match.results.otherBooks')
}}</span>
<span class="text-[11px] font-semibold text-muted-foreground tabular-nums">{{ groups.weak.length }}</span>
<span class="flex-1" />
<ChevronUp v-if="otherBooksOpen" class="size-3.5 text-foreground" aria-hidden="true" />
<ChevronDown v-else class="size-3.5 text-foreground" aria-hidden="true" />
</button>
<template v-if="otherBooksOpen">
<MetadataMatchResultRow
v-for="match in groups.weak"
:key="candidateKey(match.candidate)"
:candidate="match.candidate"
:assessment="match.assessment"
:summary="summaryOf(match.candidate)"
:providers="providers"
:selected="isSelected(match.candidate)"
:staged-count="stagedOf(match.candidate)"
:cover-ratio="coverRatio"
@select="handleSelect"
/>
</template>
</template>
</template>
</div>
</aside>
</template>
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Barcode, Link } from '@lucide/vue'
import type { MetadataCandidate, MetadataProviderInfo } from '@bookorbit/types'
import BookCoverPlaceholder from '@/features/book/components/BookCoverPlaceholder.vue'
import { getProviderLabel, hideOnError, providerBadgeStyle, resolveCandidateDisplayTitle, toDisplayCoverUrl } from '../../lib/metadata-fetch'
import type { MatchAssessment } from '../../lib/metadata-match'
import type { DifferenceSummary } from '../../lib/metadata-diff-fields'
import MetadataMatchTier from './MetadataMatchTier.vue'
const props = defineProps<{
candidate: MetadataCandidate
assessment: MatchAssessment
summary: DifferenceSummary
providers: MetadataProviderInfo[]
selected: boolean
stagedCount: number
coverRatio: string
}>()
const emit = defineEmits<{ select: [MetadataCandidate] }>()
const { t } = useI18n()
const title = computed(() => resolveCandidateDisplayTitle(props.candidate) ?? t('book.detail.editMetadata.diffPanel.untitled'))
const coverUrl = computed(() => toDisplayCoverUrl(props.candidate.coverUrl))
const authorLine = computed(() => props.candidate.authors?.join(', ') || null)
const meta = computed(() => {
const c = props.candidate
const parts: string[] = []
if (authorLine.value) parts.push(authorLine.value)
const year = c.publishedDate?.slice(0, 4) || (c.publishedYear != null ? String(c.publishedYear) : '')
if (year) parts.push(year)
if (c.pageCount) parts.push(t('book.detail.editMetadata.match.results.pages', { count: c.pageCount }))
if (c.publisher) parts.push(c.publisher)
return parts.join(' · ')
})
const providerLabel = computed(() => getProviderLabel(props.candidate.provider, props.providers))
const showDifferentAuthor = computed(() => props.assessment.tier === 'weak' && !props.assessment.authorMatch)
const showTitleDiffers = computed(() => props.assessment.tier === 'weak' && props.assessment.authorMatch && props.assessment.titleMatch !== 'same')
const inSync = computed(() => props.assessment.tier !== 'weak' && props.summary.fills === 0 && props.summary.changes === 0)
function handleSelect() {
emit('select', props.candidate)
}
</script>
<template>
<button
type="button"
class="relative grid w-full grid-cols-[2.875rem_minmax(0,1fr)_auto] gap-x-2.5 rounded-lg border p-2 text-start transition-colors focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
:class="selected ? 'border-primary/45 bg-primary/10' : 'border-transparent hover:bg-muted/70'"
:aria-current="selected ? 'true' : undefined"
@click="handleSelect"
>
<span class="relative block w-full self-start overflow-hidden rounded-[5px] bg-muted ring-1 ring-border" :style="{ aspectRatio: coverRatio }">
<img v-if="coverUrl" :src="coverUrl" alt="" class="size-full object-contain" loading="lazy" @error="hideOnError" />
<BookCoverPlaceholder v-else :title="title" :author-line="authorLine" :is-audio="coverRatio === '1/1'" :seed="title" />
</span>
<span class="min-w-0">
<span class="block text-[13px] leading-snug font-semibold [overflow-wrap:anywhere] text-foreground">{{ title }}</span>
<span v-if="meta" class="mt-0.5 block text-[11.5px] leading-snug [overflow-wrap:anywhere] text-muted-foreground">{{ meta }}</span>
<span class="mt-1.5 flex flex-wrap gap-1">
<span class="inline-flex h-[18px] items-center rounded-md px-1.5 text-[10.5px] font-bold" :style="providerBadgeStyle(candidate.provider)">
{{ providerLabel }}
</span>
<span
v-if="assessment.linked"
class="inline-flex h-[18px] items-center gap-1 rounded-md bg-primary/15 px-1.5 text-[10.5px] font-semibold text-primary"
:title="t('book.detail.editMetadata.match.results.linkedHint')"
>
<Link class="size-2.5" aria-hidden="true" />{{ t('book.detail.editMetadata.match.results.linked') }}
</span>
<span
v-if="assessment.isbnMatch"
class="inline-flex h-[18px] items-center gap-1 rounded-md bg-primary/15 px-1.5 text-[10.5px] font-semibold text-primary"
>
<Barcode class="size-2.5" aria-hidden="true" />{{ t('book.detail.editMetadata.match.results.isbn') }}
</span>
<span
v-if="summary.fills"
class="inline-flex h-[18px] items-center rounded-md bg-success/15 px-1.5 text-[10.5px] font-semibold text-success tabular-nums"
:title="t('book.detail.editMetadata.match.results.fillsHint', { count: summary.fills })"
>
{{ t('book.detail.editMetadata.match.results.fills', { count: summary.fills }) }}
</span>
<span
v-if="summary.changes"
class="inline-flex h-[18px] items-center rounded-md bg-muted px-1.5 text-[10.5px] font-semibold text-muted-foreground tabular-nums"
:title="t('book.detail.editMetadata.match.results.differHint', { count: summary.changes })"
>
{{ t('book.detail.editMetadata.match.results.differ', { count: summary.changes }) }}
</span>
<span
v-if="inSync"
class="inline-flex h-[18px] items-center rounded-md bg-muted px-1.5 text-[10.5px] font-semibold text-muted-foreground"
:title="t('book.detail.editMetadata.match.results.inSyncHint')"
>
{{ t('book.detail.editMetadata.match.results.inSync') }}
</span>
<span
v-if="showDifferentAuthor"
class="inline-flex h-[18px] items-center rounded-md bg-warning/15 px-1.5 text-[10.5px] font-semibold text-warning"
>
{{ t('book.detail.editMetadata.match.results.differentAuthor') }}
</span>
<span
v-else-if="showTitleDiffers"
class="inline-flex h-[18px] items-center rounded-md bg-warning/15 px-1.5 text-[10.5px] font-semibold text-warning"
>
{{ t('book.detail.editMetadata.match.results.titleDiffers') }}
</span>
<span
v-if="assessment.sparse"
class="inline-flex h-[18px] items-center rounded-md bg-muted px-1.5 text-[10.5px] font-semibold text-muted-foreground"
:title="t('book.detail.editMetadata.match.results.sparseHint', { count: assessment.filledFields })"
>
{{ t('book.detail.editMetadata.match.results.sparse') }}
</span>
</span>
</span>
<span class="flex flex-col items-end justify-between gap-1.5">
<MetadataMatchTier :tier="assessment.tier" />
<span
v-if="stagedCount"
class="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-primary px-1 text-[10.5px] font-bold text-primary-foreground tabular-nums"
:title="t('book.detail.editMetadata.match.results.staged', { count: stagedCount })"
>
{{ stagedCount }}
<span class="sr-only">{{ t('book.detail.editMetadata.match.results.staged', { count: stagedCount }) }}</span>
</span>
</span>
</button>
</template>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Keyboard } from '@lucide/vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
const { t } = useI18n()
const SHORTCUTS = [
{ keys: ['↑', '↓'], label: 'book.detail.editMetadata.match.keyboard.results' },
{ keys: ['J', 'K'], label: 'book.detail.editMetadata.match.keyboard.fields' },
{ keys: ['Space'], label: 'book.detail.editMetadata.match.keyboard.toggle' },
{ keys: ['/'], label: 'book.detail.editMetadata.match.keyboard.search' },
{ keys: ['Esc'], label: 'book.detail.editMetadata.match.keyboard.close' },
] as const
</script>
<template>
<Popover>
<PopoverTrigger as-child>
<button
type="button"
class="hidden size-8 place-items-center rounded-lg text-foreground hover:bg-muted md:grid"
:aria-label="t('book.detail.editMetadata.match.keyboard.title')"
>
<Keyboard class="size-4" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent align="end" class="w-72 p-3">
<p class="mb-2 text-[10.5px] font-bold tracking-[0.12em] text-muted-foreground uppercase">
{{ t('book.detail.editMetadata.match.keyboard.title') }}
</p>
<dl class="grid gap-1.5 text-[12.5px]">
<div v-for="shortcut in SHORTCUTS" :key="shortcut.label" class="flex items-center justify-between gap-3">
<dt>{{ t(shortcut.label) }}</dt>
<dd class="flex gap-1">
<kbd v-for="key in shortcut.keys" :key="key" class="rounded border border-border bg-card px-1.5 font-mono text-[10.5px]">{{ key }}</kbd>
</dd>
</div>
</dl>
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Check, ChevronDown, Loader2, TriangleAlert } from '@lucide/vue'
import type { MetadataProviderInfo, MetadataProviderKey, MetadataProviderSearchOutcome } from '@bookorbit/types'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { formatList } from '@/i18n/formatters'
import { getProviderColor, providerActivePillStyle } from '../../lib/metadata-fetch'
const props = defineProps<{
providers: MetadataProviderInfo[]
selectedProviders: MetadataProviderKey[]
providerCounts: Partial<Record<MetadataProviderKey, number>>
interruptedProviders: { provider: MetadataProviderKey; outcome: MetadataProviderSearchOutcome }[]
retryingProviders: MetadataProviderKey[]
isStreaming: boolean
}>()
const emit = defineEmits<{
toggle: [MetadataProviderKey]
selectAll: []
selectFieldRules: []
}>()
const { t } = useI18n()
const allKeys = computed(() => props.providers.map((provider) => provider.key))
const ruleKeys = computed(() => props.providers.filter((provider) => provider.selectedByFieldRules).map((provider) => provider.key))
const outside = computed(() => props.providers.filter((provider) => provider.selectedByFieldRules === false))
const hasRuleScope = computed(() => outside.value.length > 0 && ruleKeys.value.length > 0)
const outsideOn = computed(() => outside.value.filter((provider) => props.selectedProviders.includes(provider.key)))
const outsideNames = computed(() => formatList(outside.value.map((provider) => provider.label)))
function sameSelection(keys: MetadataProviderKey[]): boolean {
if (!keys.length) return false
const selected = new Set(props.selectedProviders)
return selected.size === keys.length && keys.every((key) => selected.has(key))
}
const scope = computed<'all' | 'rules' | 'custom'>(() => {
if (sameSelection(allKeys.value)) return 'all'
if (hasRuleScope.value && sameSelection(ruleKeys.value)) return 'rules'
return 'custom'
})
const scopeLabel = computed(() => {
if (scope.value === 'all') return t('book.detail.editMetadata.searchPanel.allEnabled')
if (scope.value === 'rules') return t('book.detail.editMetadata.searchPanel.fieldRules')
return t('book.detail.editMetadata.searchPanel.custom')
})
const stoppedProviders = computed(() => new Set(props.interruptedProviders.map((entry) => entry.provider)))
function isOn(key: MetadataProviderKey): boolean {
return props.selectedProviders.includes(key)
}
function statusOf(provider: MetadataProviderInfo): 'searching' | 'stopped' | 'done' {
if (props.retryingProviders.includes(provider.key)) return 'searching'
if (stoppedProviders.value.has(provider.key)) return 'stopped'
if (props.isStreaming && isOn(provider.key) && !props.providerCounts[provider.key]) return 'searching'
return 'done'
}
function chipLabel(provider: MetadataProviderInfo): string {
const status = statusOf(provider)
if (status === 'searching') return t('book.detail.editMetadata.match.sources.searching', { provider: provider.label })
if (status === 'stopped') return t('book.detail.editMetadata.match.sources.stopped', { provider: provider.label })
return t('book.detail.editMetadata.match.sources.count', { provider: provider.label, count: props.providerCounts[provider.key] ?? 0 })
}
function chipStyle(key: MetadataProviderKey): Record<string, string> {
return isOn(key) ? providerActivePillStyle(key) : {}
}
function dotStyle(key: MetadataProviderKey): Record<string, string> {
return isOn(key) ? { backgroundColor: getProviderColor(key) } : {}
}
function handleToggle(key: MetadataProviderKey) {
emit('toggle', key)
}
function handleSelectAll() {
emit('selectAll')
}
function handleSelectFieldRules() {
emit('selectFieldRules')
}
</script>
<template>
<div class="grid gap-2">
<div class="flex items-center justify-between gap-2">
<h3 class="text-[10.5px] font-bold tracking-[0.12em] text-muted-foreground uppercase">
{{ t('book.detail.editMetadata.match.sources.heading') }}
</h3>
<Popover>
<PopoverTrigger as-child>
<button
type="button"
class="-mx-1 inline-flex items-center gap-1 rounded-md px-1 py-0.5 text-xs font-medium text-foreground hover:bg-muted"
:aria-label="`${t('book.detail.editMetadata.searchPanel.searchScope')}: ${scopeLabel}`"
>
{{ scopeLabel }}<ChevronDown class="size-3" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent align="end" class="w-72 p-1.5">
<div role="group" :aria-label="t('book.detail.editMetadata.searchPanel.searchScope')" class="grid gap-0.5">
<button
type="button"
class="grid grid-cols-[1rem_minmax(0,1fr)] items-start gap-2 rounded-md px-2 py-1.5 text-start text-[12.5px] hover:bg-muted"
:aria-pressed="scope === 'all'"
@click="handleSelectAll"
>
<Check class="mt-0.5 size-3.5 text-primary" :class="scope === 'all' ? '' : 'invisible'" aria-hidden="true" />
<span>
{{ t('book.detail.editMetadata.searchPanel.allEnabled') }}
<span class="block text-[11.5px] text-muted-foreground">{{
t('book.detail.editMetadata.match.sources.allEnabledHint', { count: allKeys.length })
}}</span>
</span>
</button>
<button
v-if="hasRuleScope"
type="button"
class="grid grid-cols-[1rem_minmax(0,1fr)] items-start gap-2 rounded-md px-2 py-1.5 text-start text-[12.5px] hover:bg-muted"
:aria-pressed="scope === 'rules'"
@click="handleSelectFieldRules"
>
<Check class="mt-0.5 size-3.5 text-primary" :class="scope === 'rules' ? '' : 'invisible'" aria-hidden="true" />
<span>
{{ t('book.detail.editMetadata.searchPanel.fieldRules') }}
<span class="block text-[11.5px] text-muted-foreground">{{
t('book.detail.editMetadata.match.sources.fieldRulesHint', { count: ruleKeys.length })
}}</span>
</span>
</button>
</div>
<template v-if="outside.length">
<div class="mx-0.5 my-1.5 h-px bg-border" />
<p class="px-2 pt-0.5 pb-1 text-[11.5px] leading-snug text-muted-foreground">
{{ t('book.detail.editMetadata.match.sources.outsideRules', { providers: outsideNames, count: outside.length }) }}
</p>
</template>
</PopoverContent>
</Popover>
</div>
<div class="flex flex-wrap gap-1.5" role="group" :aria-label="t('book.detail.editMetadata.match.sources.heading')">
<button
v-for="provider in providers"
:key="provider.key"
type="button"
class="inline-flex h-[26px] items-center gap-1.5 rounded-full border ps-2.5 pe-1.5 text-xs font-medium transition-colors"
:class="[
isOn(provider.key) ? 'border-transparent' : 'border-border text-foreground hover:bg-muted',
provider.selectedByFieldRules === false ? 'border-dashed' : '',
]"
:style="chipStyle(provider.key)"
:aria-pressed="isOn(provider.key)"
:aria-label="chipLabel(provider)"
:title="
provider.selectedByFieldRules === false
? t('book.detail.editMetadata.searchPanel.providerNotInFieldRules', { provider: provider.label })
: undefined
"
@click="handleToggle(provider.key)"
>
<span
class="size-[7px] shrink-0 rounded-full"
:class="isOn(provider.key) ? '' : 'ring-[1.5px] ring-muted-foreground ring-inset'"
:style="dotStyle(provider.key)"
/>
{{ provider.label }}
<Loader2 v-if="statusOf(provider) === 'searching'" class="size-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
<span v-else-if="statusOf(provider) === 'stopped'" class="grid size-[18px] place-items-center rounded-full bg-warning/20 text-warning">
<TriangleAlert class="size-2.5" aria-hidden="true" />
</span>
<span v-else class="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-current/15 px-1 text-[10.5px] font-bold tabular-nums">
<span class="text-foreground">{{ providerCounts[provider.key] ?? 0 }}</span>
</span>
</button>
</div>
<p v-if="outsideOn.length" class="text-[11.5px] leading-snug text-muted-foreground">
{{ t('book.detail.editMetadata.match.sources.manualOnly', { providers: formatList(outsideOn.map((provider) => provider.label)) }) }}
</p>
</div>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MatchTier } from '../../lib/metadata-match'
const props = defineProps<{ tier: MatchTier }>()
const { t } = useI18n()
const label = computed(() => t(`book.detail.editMetadata.match.tier.${props.tier}`))
const FILLED: Record<MatchTier, number> = { strong: 3, possible: 2, weak: 1 }
const FILL_CLASS: Record<MatchTier, string> = { strong: 'bg-success', possible: 'bg-warning', weak: 'bg-muted-foreground' }
const HEIGHTS = ['h-[5px]', 'h-[8.5px]', 'h-3']
function barClass(index: number): string {
return `${HEIGHTS[index]} ${index < FILLED[props.tier] ? FILL_CLASS[props.tier] : 'bg-border'}`
}
</script>
<template>
<span class="inline-flex h-3 shrink-0 items-end gap-0.5" role="img" :aria-label="label" :title="label">
<span v-for="(_, index) in HEIGHTS" :key="index" class="w-[3px] rounded-[1px]" :class="barClass(index)" />
</span>
</template>
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChevronDown, ChevronUp } from '@lucide/vue'
import { formatLanguageName } from '@/i18n/formatters'
import type { DiffField, FieldDecision } from '../../composables/useMetadataDiff'
import { normalizeForMatch, type DiffFieldKey } from '../../lib/metadata-diff-fields'
import { formatPublishedDate } from '../../lib/published-date'
import { wordDiff, type WordDiffPart } from '../../lib/word-diff'
const props = defineProps<{
field: DiffField
side: 'current' | 'incoming'
decision: FieldDecision
}>()
const { t } = useI18n()
/** Text fields worth a word-level diff; identifiers and numbers read better whole. */
const WORD_DIFF_KEYS = new Set<DiffFieldKey>(['title', 'subtitle', 'publisher', 'seriesName', 'description', 'comicVolumeName'])
/** Below this share of common words the texts are different, and highlighting every word helps no one. */
const MIN_SIMILARITY = 0.34
const CLAMP_AFTER_CHARACTERS = 240
const expanded = ref(false)
watch(
() => [props.field.key, props.field.candidateDisplay],
() => {
expanded.value = false
},
)
function formatValue(key: DiffFieldKey, raw: string): string {
if (!raw) return ''
switch (key) {
case 'publishedDate':
return formatPublishedDate(raw) ?? raw
case 'language':
return /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i.test(raw) ? formatLanguageName(raw) : raw
case 'durationSeconds': {
const seconds = Number(raw)
if (!Number.isFinite(seconds)) return raw
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
return hours > 0 ? t('book.detail.details.durationHm', { hours, minutes }) : t('book.detail.details.durationM', { minutes })
}
case 'abridged':
return raw === 'true' ? t('common.yes') : t('common.no')
default:
return raw
}
}
const own = computed(() => (props.side === 'current' ? props.field.bookValue : props.field.candidateDisplay))
const other = computed(() => (props.side === 'current' ? props.field.candidateDisplay : props.field.bookValue))
const text = computed(() => formatValue(props.field.key, own.value))
const isMono = computed(() => props.field.key.endsWith('Id') || props.field.key === 'isbn13' || props.field.key === 'isbn10')
const chips = computed(() => {
const mine = props.side === 'current' ? props.field.bookList : props.field.candidateList
if (!mine || !mine.length) return null
const theirs = props.side === 'current' ? props.field.candidateList : props.field.bookList
const showDifference = props.field.kind === 'change' && !!theirs?.length
const theirSet = new Set((theirs ?? []).map(normalizeForMatch))
const replaced = props.decision === 'use' || props.decision === 'replace'
return mine.map((value) => {
const shared = theirSet.has(normalizeForMatch(value))
const state = !showDifference || shared ? 'same' : props.side === 'incoming' ? 'added' : replaced ? 'removed' : 'same'
return { value, state }
})
})
const parts = computed<WordDiffPart[] | null>(() => {
if (props.field.kind !== 'change' || !WORD_DIFF_KEYS.has(props.field.key) || !own.value || !other.value) return null
const before = props.side === 'current' ? own.value : other.value
const after = props.side === 'current' ? other.value : own.value
const diff = wordDiff(before, after)
if (!diff || diff.similarity < MIN_SIMILARITY) return null
return props.side === 'current' ? diff.before : diff.after
})
const clampable = computed(() => props.field.key === 'description' && text.value.length > CLAMP_AFTER_CHARACTERS)
const wordCount = computed(() => text.value.split(/\s+/).filter(Boolean).length)
function chipClass(state: string): string {
if (state === 'added') return 'bg-success/15 ring-1 ring-success/40 ring-inset'
if (state === 'removed') return 'bg-transparent ring-1 ring-border ring-inset text-muted-foreground line-through'
return 'bg-muted'
}
function toggleExpanded() {
expanded.value = !expanded.value
}
</script>
<template>
<span v-if="!text" class="text-muted-foreground italic">
{{ side === 'current' ? t('book.detail.editMetadata.match.value.empty') : t('book.detail.editMetadata.match.value.notProvided') }}
</span>
<span v-else-if="chips" class="flex flex-wrap gap-1">
<span
v-for="chip in chips"
:key="chip.value"
class="inline-flex min-h-[22px] items-center rounded-md px-[7px] py-0.5 text-xs leading-tight"
:class="chipClass(chip.state)"
>
<span v-if="chip.state === 'added'" class="me-0.5 font-bold text-success">+</span>
{{ chip.value }}
</span>
</span>
<span v-else class="block">
<span
class="[overflow-wrap:anywhere]"
:class="[isMono ? 'font-mono text-xs' : '', clampable && !expanded ? 'line-clamp-4' : 'block whitespace-pre-line']"
>
<template v-if="parts">
<template v-for="(part, index) in parts" :key="index">
<del
v-if="part.kind === 'removed'"
class="rounded-[3px] bg-destructive/20 decoration-destructive/70 [box-decoration-break:clone] [-webkit-box-decoration-break:clone]"
>{{ part.text }}</del
><ins
v-else-if="part.kind === 'added'"
class="rounded-[3px] bg-success/25 no-underline [box-decoration-break:clone] [-webkit-box-decoration-break:clone]"
>{{ part.text }}</ins
><template v-else>{{ part.text }}</template>
</template>
</template>
<template v-else>{{ text }}</template>
</span>
<button
v-if="clampable"
type="button"
class="-mx-1 mt-1 inline-flex items-center gap-1 rounded-md px-1 py-0.5 text-[11.5px] font-medium text-foreground hover:bg-muted"
:aria-expanded="expanded"
@click.stop="toggleExpanded"
>
{{ expanded ? t('book.detail.editMetadata.diff.showLess') : t('book.detail.editMetadata.match.value.showAll', { count: wordCount }) }}
<ChevronUp v-if="expanded" class="size-3" aria-hidden="true" />
<ChevronDown v-else class="size-3" aria-hidden="true" />
</button>
</span>
</template>
@@ -0,0 +1,184 @@
import { describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import type { MetadataCandidate, MetadataProviderInfo, MetadataSource } from '@bookorbit/types'
import MetadataMatchWorkspace from './MetadataMatchWorkspace.vue'
import type { MetadataDiffApply } from '../../composables/useMetadataDiff'
const current: MetadataSource = {
title: 'Artemis Fowl',
subtitle: null,
authors: ['Eoin Colfer'],
genres: ['Fantasy'],
description: null,
publisher: 'Miramax',
publishedDate: '2003-04-01',
publishedYear: 2003,
language: 'English',
pageCount: 396,
seriesName: 'Artemis Fowl',
seriesIndex: '1',
isbn10: null,
isbn13: null,
narrators: [],
durationSeconds: null,
abridged: null,
hardcoverEditionId: null,
communityRatings: [],
}
const providers: MetadataProviderInfo[] = [
{ key: 'amazon', label: 'Amazon', identifiable: true },
{ key: 'goodreads', label: 'Goodreads', identifiable: true },
{ key: 'google', label: 'Google Books', identifiable: true },
]
const amazon: MetadataCandidate = {
provider: 'amazon',
providerId: 'B002KP6DXQ',
title: 'Artemis Fowl',
authors: ['Eoin Colfer'],
publisher: 'Disney Hyperion',
isbn13: '9781423132172',
seriesName: 'Artemis Fowl',
seriesIndex: '1',
}
const goodreads: MetadataCandidate = {
provider: 'goodreads',
providerId: 'gr-other',
title: 'Artemis Fowl',
authors: ['Eoin Colfer'],
pageCount: 420,
}
const collection: MetadataCandidate = { provider: 'google', providerId: 'set', title: 'Artemis Fowl: Books 1-4', authors: ['Eoin Colfer'] }
function mountWorkspace(props: Partial<InstanceType<typeof MetadataMatchWorkspace>['$props']> = {}) {
return mount(MetadataMatchWorkspace, {
attachTo: document.body,
props: {
current,
providerIds: { amazon: 'B002KP6DXQ' },
coverMedium: 'ebook',
searchDefaults: { title: 'Artemis Fowl', author: 'Eoin Colfer' },
providers,
results: [collection, goodreads, amazon],
providerCounts: { amazon: 1, goodreads: 1, google: 1 },
selectedProviders: ['amazon', 'goodreads', 'google'],
interruptedProviders: [],
isStreaming: false,
hasSearched: true,
...props,
},
})
}
function resultRows(wrapper: VueWrapper) {
return wrapper.findAll('[data-match-results] button[class*="grid"]')
}
function fieldRow(wrapper: VueWrapper, key: string) {
return wrapper.get(`[data-field-key="${key}"]`)
}
async function choose(wrapper: VueWrapper, key: string, option: string) {
const button = fieldRow(wrapper, key)
.findAll('[role="radio"]')
.find((radio) => radio.text() === option)
await button!.trigger('click')
}
describe('MetadataMatchWorkspace', () => {
it('groups results by how likely they are this book and opens the linked record', () => {
const wrapper = mountWorkspace()
const headings = wrapper.findAll('[data-match-results] h3').map((heading) => heading.text())
expect(headings).toEqual([expect.stringContaining('Best matches'), expect.stringContaining('Other editions')])
expect(wrapper.text()).toContain('Probably different books')
expect(wrapper.text()).not.toContain('Artemis Fowl: Books 1-4')
const selected = wrapper.get('[data-match-results] [aria-current="true"]')
expect(selected.text()).toContain('Amazon')
expect(selected.text()).toContain('Linked')
wrapper.unmount()
})
it('moves to a better match that arrives later, until someone picks a result', async () => {
const wrapper = mountWorkspace({ results: [goodreads], isStreaming: true })
await wrapper.setProps({ results: [goodreads, amazon], isStreaming: false })
expect(wrapper.get('[data-match-results] [aria-current="true"]').text()).toContain('Amazon')
const other = resultRows(wrapper).find((row) => row.text().includes('Goodreads'))!
await other.trigger('click')
await wrapper.setProps({ results: [goodreads, amazon, { ...amazon, providerId: 'isbn', isbn13: undefined }] })
expect(wrapper.get('[data-match-results] [aria-current="true"]').text()).toContain('Goodreads')
wrapper.unmount()
})
it('applies the staged fields with the id of every result they came from', async () => {
const wrapper = mountWorkspace()
await choose(wrapper, 'publisher', 'Use')
await choose(wrapper, 'isbn13', 'Use')
expect(wrapper.get('footer').text()).toContain('Apply 2 changes')
const apply = wrapper
.get('footer')
.findAll('button')
.find((button) => button.text().startsWith('Apply'))!
await apply.trigger('click')
const [patch] = wrapper.emitted('apply')![0] as [MetadataDiffApply]
expect(patch.formPatch).toMatchObject({ publisher: 'Disney Hyperion', isbn13: '9781423132172', amazonId: 'B002KP6DXQ' })
wrapper.unmount()
})
it('keeps a staged value when another result is opened, and names where it came from', async () => {
const wrapper = mountWorkspace()
await choose(wrapper, 'isbn13', 'Use')
await resultRows(wrapper)
.find((row) => row.text().includes('Goodreads'))!
.trigger('click')
expect(wrapper.get('footer').text()).toContain('Apply 1 change')
const amazonRow = resultRows(wrapper).find((row) => row.text().includes('Amazon'))!
expect(amazonRow.text()).toContain('1 change staged from this result')
wrapper.unmount()
})
it('only lists fields that differ until all fields are asked for', async () => {
const wrapper = mountWorkspace()
expect(wrapper.find('[data-field-key="title"]').exists()).toBe(false)
const all = wrapper.findAll('[role="radio"]').find((radio) => radio.text().startsWith('All fields'))!
await all.trigger('click')
expect(fieldRow(wrapper, 'title').text()).toContain('Already matches')
wrapper.unmount()
})
it('offers a retry for a source that stopped early', async () => {
const wrapper = mountWorkspace({ interruptedProviders: [{ provider: 'google', outcome: 'timeout' }] })
const retry = wrapper.findAll('button').find((button) => button.text() === 'Retry Google Books')!
await retry.trigger('click')
expect(wrapper.emitted('retryProvider')).toEqual([['google']])
wrapper.unmount()
})
it('compares a fixed result on its own, without the results list', () => {
const fetched: MetadataCandidate = {
provider: 'auto' as MetadataCandidate['provider'],
providerId: '',
title: 'Artemis Fowl',
publisher: 'Viking',
}
const wrapper = mountWorkspace({ results: [], fixedCandidate: fetched })
expect(wrapper.find('[data-match-results]').exists()).toBe(false)
expect(fieldRow(wrapper, 'publisher').text()).toContain('Viking')
expect(wrapper.text()).toContain('Fetched')
wrapper.unmount()
})
})
@@ -0,0 +1,481 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
BookMetadataLockField,
CoverMedium,
MetadataCandidate,
MetadataProviderInfo,
MetadataProviderKey,
MetadataProviderSearchOutcome,
MetadataSource,
ProviderIds,
} from '@bookorbit/types'
import {
coverLooksBroken,
useMetadataDiff,
type CoverChoice,
type FieldDecision,
type MetadataDiffApply,
type StagedChange,
} from '../../composables/useMetadataDiff'
import { useSecondCoverRow, type SecondCoverInput } from '../../composables/useSecondCoverRow'
import { useCoverShapes } from '../../composables/useCoverShapes'
import { coverFit, coverLockField } from '../../lib/cover-slots'
import { summarizeDifferences, type DiffFieldKey } from '../../lib/metadata-diff-fields'
import { candidateKey, groupMatches, rankMatches, type MatchSubject } from '../../lib/metadata-match'
import { toDisplayCoverUrl } from '../../lib/metadata-fetch'
import MetadataMatchRail from './MetadataMatchRail.vue'
import MetadataMatchCompare, { type CoverCardModel, type RowFilter } from './MetadataMatchCompare.vue'
import MetadataMatchFooter from './MetadataMatchFooter.vue'
import type { MetadataQuery } from './MetadataMatchQuery.vue'
const props = defineProps<{
current: MetadataSource
providerIds?: ProviderIds
lockedFields?: BookMetadataLockField[]
currentCoverUrl?: string
/** The slot the main cover fills. */
coverMedium: CoverMedium
/** Providers in the order the main slot's cover rule ranks them. */
coverPriority?: MetadataProviderKey[]
/** The other medium's cover, for a book that has both; fed by its own search. */
secondCover?: SecondCoverInput | null
searchDefaults: { title?: string; author?: string; isbn?: string }
providers: MetadataProviderInfo[]
results: MetadataCandidate[]
providerCounts: Partial<Record<MetadataProviderKey, number>>
selectedProviders: MetadataProviderKey[]
interruptedProviders: { provider: MetadataProviderKey; outcome: MetadataProviderSearchOutcome }[]
retryingProviders?: MetadataProviderKey[]
providerOrder?: MetadataProviderKey[]
isStreaming: boolean
hasSearched: boolean
/** One result to compare with no search, like metadata fetched earlier. Hides the results rail. */
fixedCandidate?: MetadataCandidate | null
/** What Apply does, when it does more than fill a form. */
applyHint?: string
}>()
const emit = defineEmits<{
search: [MetadataQuery]
toggleProvider: [MetadataProviderKey]
selectAll: []
selectFieldRules: []
retryProvider: [MetadataProviderKey]
apply: [MetadataDiffApply]
cancel: []
}>()
const { t } = useI18n()
const rail = ref<InstanceType<typeof MetadataMatchRail> | null>(null)
const root = ref<HTMLElement | null>(null)
const screen = ref<'matches' | 'compare'>(props.fixedCandidate ? 'compare' : 'matches')
const filter = ref<RowFilter>('diff')
const otherBooksOpen = ref(false)
const selectedKey = ref<string | null>(null)
const subject = computed<MatchSubject>(() => ({
title: props.current.title,
authors: props.current.authors,
isbn13: props.current.isbn13,
isbn10: props.current.isbn10,
seriesName: props.current.seriesName,
seriesIndex: props.current.seriesIndex,
providerIds: props.providerIds,
}))
const pool = computed<MetadataCandidate[]>(() => (props.fixedCandidate ? [props.fixedCandidate] : props.results))
const ranked = computed(() => rankMatches(pool.value, subject.value, props.providerOrder ?? []))
const groups = computed(() => groupMatches(ranked.value))
const assessments = computed(() => new Map(ranked.value.map((match) => [candidateKey(match.candidate), match.assessment])))
const summaries = computed(
() => new Map(pool.value.map((candidate) => [candidateKey(candidate), summarizeDifferences(props.current, candidate, props.providerIds)])),
)
const navList = computed(() =>
[...groups.value.strong, ...groups.value.possible, ...(otherBooksOpen.value ? groups.value.weak : [])].map((match) => match.candidate),
)
const selected = computed(() => pool.value.find((candidate) => candidateKey(candidate) === selectedKey.value) ?? null)
const selectedAssessment = computed(() => (selectedKey.value ? (assessments.value.get(selectedKey.value) ?? null) : null))
/** Likely matches only: other books' values and covers would be noise beside this book's. */
const alternatives = computed(() => [...groups.value.strong, ...groups.value.possible].map((match) => match.candidate))
const coverRatio = computed(() => (props.coverMedium === 'audio' ? '1/1' : '2/3'))
/** A fixed result can come from no search provider, like the automatic fetch; it still needs a name. */
const knownProviders = computed<MetadataProviderInfo[]>(() => {
const fixed = props.fixedCandidate
if (!fixed || props.providers.some((provider) => provider.key === fixed.provider)) return props.providers
return [...props.providers, { key: fixed.provider, label: t('book.detail.editMetadata.match.results.fetched'), identifiable: false }]
})
/** Set once someone picks a result or stages a value, so a better match arriving later never pulls the view away. */
const settled = ref(false)
// The best match opens as soon as one arrives. Until someone settles on it, a better one that arrives later replaces it.
watch(
[ranked, () => props.isStreaming, () => props.fixedCandidate],
() => {
if (props.fixedCandidate) {
selectedKey.value = candidateKey(props.fixedCandidate)
return
}
if (selected.value && settled.value) return
const best = groups.value.strong[0] ?? (props.isStreaming && !selected.value ? undefined : ranked.value[0])
if (best) selectedKey.value = candidateKey(best.candidate)
else if (!selected.value) selectedKey.value = null
},
{ immediate: true },
)
watch(
() => props.hasSearched && props.isStreaming,
(started) => {
if (started && !diff.hasCopied.value) settled.value = false
},
)
const { shapeOf, sizeOf } = useCoverShapes(() => [
...alternatives.value,
...(selected.value ? [selected.value] : []),
...(props.secondCover?.candidates ?? []),
])
const diff = useMetadataDiff({
current: () => props.current,
active: selected,
alternatives,
providers: knownProviders,
currentCoverUrl: () => props.currentCoverUrl,
providerIds: () => props.providerIds,
lockedFields: () => props.lockedFields,
coverMedium: () => props.coverMedium,
coverPriority: () => props.coverPriority,
coverLabelKey: () =>
props.secondCover ? `book.detail.editMetadata.diff.fields.${props.coverMedium === 'audio' ? 'audioCover' : 'bookCover'}` : undefined,
coverShapeOf: shapeOf,
coverSizeOf: sizeOf,
})
const { fields, cover, genreWriteMode, fillEmptyCount, takeAllCount, stagedByCandidate } = diff
const secondLocked = computed(() => !!props.secondCover && (props.lockedFields ?? []).includes(coverLockField(props.secondCover.medium)))
const second = useSecondCoverRow(
() =>
props.secondCover
? {
medium: props.secondCover.medium,
candidates: props.secondCover.candidates,
priority: props.secondCover.priority,
currentUrl: props.secondCover.currentUrl,
locked: secondLocked.value,
}
: null,
shapeOf,
)
function secondChoice(candidate: MetadataCandidate | null, medium: CoverMedium): CoverChoice | null {
if (!candidate) return null
const size = sizeOf(candidate)
return { candidate, url: toDisplayCoverUrl(candidate.coverUrl), fit: coverFit(shapeOf(candidate), medium), size, broken: coverLooksBroken(size) }
}
const secondLabelKey = computed(() => `book.detail.editMetadata.diff.fields.${props.secondCover?.medium === 'audio' ? 'audioCover' : 'bookCover'}`)
const covers = computed<CoverCardModel[]>(() => {
const main = cover.value
const models: CoverCardModel[] = [
{
id: 'main',
label: t(
props.secondCover
? `book.detail.editMetadata.diff.fields.${props.coverMedium === 'audio' ? 'audioCover' : 'bookCover'}`
: 'book.detail.editMetadata.diff.fields.coverUrl',
),
medium: main.medium,
currentUrl: main.currentUrl,
active: main.active,
picked: main.picked,
choices: main.choices,
locked: main.locked,
},
]
const other = props.secondCover
if (other) {
const audio = other.medium === 'audio'
models.push({
id: 'second',
label: t(secondLabelKey.value),
medium: other.medium,
currentUrl: other.currentUrl,
active: secondChoice(second.active.value, other.medium),
picked: secondChoice(second.picked.value, other.medium),
choices: second.choices.value
.map((candidate) => secondChoice(candidate, other.medium))
.filter((choice): choice is CoverChoice => choice !== null),
locked: secondLocked.value,
searching: other.searching,
searchingText: t(`book.detail.editMetadata.diffPanel.secondCover.${audio ? 'searchingAudio' : 'searchingBook'}`),
emptyText: t(`book.detail.editMetadata.diffPanel.secondCover.${audio ? 'noAudio' : 'noBook'}`),
})
}
return models
})
const staged = computed<StagedChange[]>(() => {
const list = [...diff.staged.value]
const picked = second.picked.value
if (props.secondCover && picked) {
list.push({
key: 'secondCover',
field: 'coverUrl',
labelKey: secondLabelKey.value,
candidate: picked,
before: props.secondCover.currentUrl,
after: toDisplayCoverUrl(picked.coverUrl),
merged: false,
isCover: true,
})
}
return list
})
const position = computed(() => navList.value.findIndex((candidate) => candidateKey(candidate) === selectedKey.value) + 1)
const waitingOn = computed(() =>
props.isStreaming && selected.value
? props.providers
.filter((provider) => props.selectedProviders.includes(provider.key) && !props.providerCounts[provider.key])
.map((provider) => provider.label)
: [],
)
const searchFoundNothing = computed(() => props.hasSearched && !props.isStreaming && pool.value.length === 0)
function select(candidate: MetadataCandidate) {
settled.value = true
selectedKey.value = candidateKey(candidate)
screen.value = 'compare'
}
function step(delta: number) {
const list = navList.value
if (!list.length) return
const index = list.findIndex((candidate) => candidateKey(candidate) === selectedKey.value)
const next = list[Math.max(0, Math.min(list.length - 1, index + delta))]
if (!next) return
settled.value = true
selectedKey.value = candidateKey(next)
}
function decide(key: DiffFieldKey, decision: FieldDecision, from?: MetadataCandidate) {
settled.value = true
diff.setField(key, decision, from ?? selected.value)
}
function coverKeep(id: CoverCardModel['id']) {
settled.value = true
if (id === 'main') diff.pickCover(null)
else if (second.picked.value) second.pick(null)
}
function coverUse(id: CoverCardModel['id']) {
settled.value = true
if (id === 'main') {
if (selected.value) diff.pickCover(selected.value)
return
}
const active = second.active.value
if (active && second.picked.value !== active) second.pick(active)
}
function coverPick(id: CoverCardModel['id'], candidate: MetadataCandidate | null) {
settled.value = true
if (id === 'main') diff.pickCover(candidate)
else second.pick(candidate)
}
function unstage(key: string) {
if (key === 'secondCover') second.pick(null)
else diff.unstage(key)
}
function clearAll() {
diff.clearAll()
if (second.picked.value) second.pick(null)
}
function apply() {
const { formPatch, coverUrl } = diff.buildPatch()
const slots: Partial<Record<CoverMedium, string>> = {}
if (coverUrl) slots[props.coverMedium] = coverUrl
const secondUrl = second.pickedCoverUrl.value
if (props.secondCover && secondUrl && !secondLocked.value) slots[props.secondCover.medium] = secondUrl
emit('apply', {
formPatch,
...(slots.ebook ? { coverUrl: slots.ebook } : {}),
...(slots.audio ? { audioCoverUrl: slots.audio } : {}),
})
}
function handleSearch(query: MetadataQuery) {
emit('search', query)
}
function handleToggleProvider(key: MetadataProviderKey) {
emit('toggleProvider', key)
}
function handleSelectAll() {
emit('selectAll')
}
function handleSelectFieldRules() {
emit('selectFieldRules')
}
function handleRetry(provider: MetadataProviderKey) {
emit('retryProvider', provider)
}
function handleCancel() {
emit('cancel')
}
function showMatches() {
screen.value = 'matches'
}
function setFilter(value: RowFilter) {
filter.value = value
}
function fillEmpty() {
settled.value = true
diff.fillEmpty()
}
function takeAll() {
settled.value = true
diff.takeAll()
}
function focusFieldRow(delta: number) {
const rows = [...(root.value?.querySelectorAll<HTMLElement>('[data-field-row]') ?? [])]
if (!rows.length) return
const current = rows.findIndex((row) => row === document.activeElement || row.contains(document.activeElement))
const next = rows[current < 0 ? (delta > 0 ? 0 : rows.length - 1) : Math.max(0, Math.min(rows.length - 1, current + delta))]
next?.focus()
next?.scrollIntoView({ block: 'nearest' })
}
/**
* Arrow keys step through results from the results list, J and K move between fields anywhere
* outside a text box, and Space on a focused field keeps or uses it. A slash jumps to the search.
*/
function handleKeydown(event: KeyboardEvent) {
if (event.defaultPrevented || event.altKey || event.metaKey || event.ctrlKey) return
const target = event.target as HTMLElement
if (target.closest('input, textarea, select, [contenteditable="true"]')) return
if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && target.closest('[data-match-results]')) {
event.preventDefault()
step(event.key === 'ArrowDown' ? 1 : -1)
nextTick(() => root.value?.querySelector<HTMLElement>('[data-match-results] [aria-current="true"]')?.focus())
return
}
if (event.key === 'j' || event.key === 'k') {
event.preventDefault()
focusFieldRow(event.key === 'j' ? 1 : -1)
return
}
if ((event.key === ' ' || event.key === 'Enter') && target.dataset.fieldKey) {
event.preventDefault()
const key = target.dataset.fieldKey as DiffFieldKey
const field = fields.value.find((entry) => entry.key === key)
if (field && !field.isLocked && (field.hasDiff || field.isPicked)) diff.toggleField(key)
return
}
if (event.key === '/' && rail.value) {
event.preventDefault()
screen.value = 'matches'
nextTick(() => rail.value?.focusQuery())
}
}
defineExpose({ focusQuery: () => rail.value?.focusQuery() })
</script>
<template>
<div ref="root" class="@container/match flex min-h-0 flex-col" @keydown="handleKeydown">
<div
class="grid min-h-0 flex-1"
:class="fixedCandidate ? 'grid-cols-1' : 'grid-cols-1 @5xl/match:grid-cols-[21rem_minmax(0,1fr)] @7xl/match:grid-cols-[24rem_minmax(0,1fr)]'"
>
<div
v-if="!fixedCandidate"
class="min-h-0 flex-col border-border @5xl/match:flex @5xl/match:border-e"
:class="screen === 'compare' ? 'hidden' : 'flex'"
>
<MetadataMatchRail
ref="rail"
v-model:other-books-open="otherBooksOpen"
class="flex-1"
:search-defaults="searchDefaults"
:providers="providers"
:selected-providers="selectedProviders"
:provider-counts="providerCounts"
:interrupted-providers="interruptedProviders"
:retrying-providers="retryingProviders ?? []"
:is-streaming="isStreaming"
:has-searched="hasSearched"
:groups="groups"
:summaries="summaries"
:selected-key="selectedKey"
:staged-by-candidate="stagedByCandidate"
:cover-ratio="coverRatio"
@search="handleSearch"
@toggle-provider="handleToggleProvider"
@select-all="handleSelectAll"
@select-field-rules="handleSelectFieldRules"
@retry-provider="handleRetry"
@select="select"
/>
</div>
<div class="min-h-0 flex-col @5xl/match:flex" :class="screen === 'matches' && !fixedCandidate ? 'hidden' : 'flex'">
<MetadataMatchCompare
class="flex-1"
:candidate="selected"
:assessment="selectedAssessment"
:fields="fields"
:filter="filter"
:genre-mode="genreWriteMode"
:providers="knownProviders"
:covers="covers"
:position="position"
:total="navList.length"
:waiting-on="waitingOn"
:has-rail="!fixedCandidate"
:book-title="current.title ?? ''"
:search-found-nothing="searchFoundNothing"
:fill-count="fillEmptyCount"
:take-count="takeAllCount"
:cover-ratio="coverRatio"
@back="showMatches"
@step="step"
@update:filter="setFilter"
@fill-empty="fillEmpty"
@take-all="takeAll"
@clear-all="clearAll"
@decide="decide"
@cover-keep="coverKeep"
@cover-use="coverUse"
@cover-pick="coverPick"
/>
</div>
</div>
<MetadataMatchFooter
:staged="staged"
:providers="knownProviders"
:hint="applyHint"
@apply="apply"
@cancel="handleCancel"
@unstage="unstage"
@clear-all="clearAll"
/>
</div>
</template>
@@ -37,29 +37,40 @@ describe('useCoverShapes', () => {
vi.unstubAllGlobals()
})
it('measures only covers whose provider states no shape, once per image', async () => {
it('measures each cover once, and trusts a shape the provider states', async () => {
const unsized = candidate('unsized')
const stated = candidate('stated', { coverShape: 'square' })
const list = ref<MetadataCandidate[]>([unsized, stated])
const { shapeOf } = useCoverShapes(list)
expect(FakeImage.loads.map((image) => image.src)).toEqual(['/covers/unsized.jpg'])
expect(FakeImage.loads.map((image) => image.src)).toEqual(['/covers/unsized.jpg', '/covers/stated.jpg'])
expect(shapeOf(unsized)).toBe('unknown')
FakeImage.loads[0]!.finish(336, 500)
FakeImage.loads[1]!.finish(300, 500)
expect(shapeOf(unsized)).toBe('portrait')
expect(shapeOf(stated)).toBe('square')
list.value = [...list.value, candidate('unsized')]
await nextTick()
expect(FakeImage.loads).toHaveLength(1)
expect(FakeImage.loads).toHaveLength(2)
})
it('reports the measured size over a stated one, which can describe a thumbnail', () => {
const thumb = candidate('thumb', { coverWidth: 355, coverHeight: 522 })
const { sizeOf } = useCoverShapes([thumb])
expect(sizeOf(thumb)).toEqual({ width: 355, height: 522 })
FakeImage.loads[0]!.finish(1740, 2560)
expect(sizeOf(thumb)).toEqual({ width: 1740, height: 2560 })
})
it('leaves a cover that fails to load as unknown', () => {
const broken = candidate('broken')
const { shapeOf } = useCoverShapes([broken])
const { shapeOf, sizeOf } = useCoverShapes([broken])
FakeImage.loads[0]!.onerror?.()
expect(shapeOf(broken)).toBe('unknown')
expect(sizeOf(broken)).toBeNull()
})
})
@@ -1,7 +1,15 @@
// @vitest-environment node
import { describe, it, expect } from 'vitest'
import { ref } from 'vue'
import type { MetadataCandidate, MetadataProviderKey, MetadataSource } from '@bookorbit/types'
import { useMetadataDiff } from '../useMetadataDiff'
import { ref, type Ref } from 'vue'
import type {
BookMetadataLockField,
MetadataCandidate,
MetadataProviderInfo,
MetadataProviderKey,
MetadataSource,
ProviderIds,
} from '@bookorbit/types'
import { useMetadataDiff, type MetadataDiffInput } from '../useMetadataDiff'
describe('useMetadataDiff', () => {
const mockCurrent: MetadataSource = {
@@ -26,7 +34,7 @@ describe('useMetadataDiff', () => {
communityRatings: [],
}
const mockCandidate1: MetadataCandidate = {
const google: MetadataCandidate = {
provider: 'google',
providerId: 'g1',
title: 'Google Title',
@@ -35,7 +43,7 @@ describe('useMetadataDiff', () => {
coverUrl: 'http://google.com/cover.jpg',
}
const mockCandidate2: MetadataCandidate = {
const goodreads: MetadataCandidate = {
provider: 'goodreads',
providerId: 'gr1',
title: 'Goodreads Title',
@@ -43,246 +51,247 @@ describe('useMetadataDiff', () => {
genres: ['Genre 2'],
}
const providers = [
{ key: 'google' as MetadataProviderKey, label: 'Google Books', identifiable: true },
{ key: 'goodreads' as MetadataProviderKey, label: 'Goodreads', identifiable: true },
const providers: MetadataProviderInfo[] = [
{ key: 'google', label: 'Google Books', identifiable: true },
{ key: 'goodreads', label: 'Goodreads', identifiable: true },
{ key: 'hardcover', label: 'Hardcover', identifiable: true },
{ key: 'amazon', label: 'Amazon', identifiable: true },
{ key: 'audible', label: 'Audible', identifiable: true },
]
const communityRatingProviders = [
{ key: 'hardcover' as MetadataProviderKey, label: 'Hardcover', identifiable: true },
{ key: 'amazon' as MetadataProviderKey, label: 'Amazon', identifiable: true },
]
const hardcoverProviders = [communityRatingProviders[0]]
function setup(
active: MetadataCandidate | Ref<MetadataCandidate | null>,
options: Partial<Omit<MetadataDiffInput, 'active'>> & { lockedFields?: readonly BookMetadataLockField[]; providerIds?: ProviderIds } = {},
) {
const activeRef = 'value' in active ? active : ref<MetadataCandidate | null>(active)
return useMetadataDiff({
current: mockCurrent,
alternatives: [],
providers,
...options,
active: activeRef,
})
}
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 field = (diff: ReturnType<typeof setup>, key: string) => diff.fields.value.find((f) => f.key === key)
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('lines up the active result against the book', () => {
const diff = setup(google)
expect(field(diff, 'title')).toMatchObject({ candidateDisplay: 'Google Title', bookValue: 'Original Title', kind: 'change' })
})
it('switches fields when active provider changes', () => {
const candidates = ref([mockCandidate1, mockCandidate2])
const activeProvider = ref<MetadataProviderKey>('google')
const { fields } = useMetadataDiff(mockCurrent, candidates, activeProvider, providers)
it('follows the active result when it changes', () => {
const active = ref<MetadataCandidate | null>(google)
const diff = setup(active)
activeProvider.value = 'goodreads'
const titleField = fields.value.find((f) => f.key === 'title')
expect(titleField?.candidateDisplay).toBe('Goodreads Title')
active.value = goodreads
expect(field(diff, 'title')?.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('has no rows without an active result', () => {
expect(setup(ref(null)).fields.value).toEqual([])
})
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)
it('names how each field differs, and marks a reordered list as minor', () => {
const diff = setup({
provider: 'goodreads',
providerId: 'gr1',
title: 'original title',
genres: ['Genre 1'],
authors: ['Original Author'],
publisher: 'Other House',
language: 'English',
isbn13: '978-1234567890',
})
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')
expect(field(diff, 'title')).toMatchObject({ kind: 'minor', minorReason: 'capitalization' })
expect(field(diff, 'authors')?.kind).toBe('same')
expect(field(diff, 'publisher')?.kind).toBe('change')
expect(field(diff, 'language')).toMatchObject({ kind: 'minor', minorReason: 'languageCode' })
expect(field(diff, 'goodreadsId')?.kind).toBe('fill')
})
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)
it('shows a description as plain text and compares it without markup', () => {
const current = { ...mockCurrent, description: '<p>A <b>bold</b> start.</p>' }
const diff = setup({ ...goodreads, description: 'A bold start.' }, { current })
toggleField('title') // google
pickFieldFromProvider('authors', 'goodreads')
expect(field(diff, 'description')).toMatchObject({ bookValue: 'A bold start.', kind: 'minor', minorReason: 'formatting' })
})
const { formPatch } = buildPatch()
it('stages a field from the active result', () => {
const diff = setup(google)
diff.toggleField('title')
expect(field(diff, 'title')).toMatchObject({ isPicked: true, pickedFromActive: true, pickedProvider: 'google' })
expect(diff.decisionOf(field(diff, 'title')!)).toBe('use')
})
it('keeps a pick when another result becomes active, and says where it came from', () => {
const active = ref<MetadataCandidate | null>(google)
const diff = setup(active)
diff.setField('authors', 'use')
active.value = goodreads
expect(field(diff, 'authors')).toMatchObject({ isPicked: true, pickedFromActive: false, pickedProvider: 'google' })
expect(diff.decisionOf(field(diff, 'authors')!)).toBe('keep')
expect(diff.buildPatch().formPatch.authors).toEqual(['Google Author'])
})
it('keeps picks from two results of the same provider apart', () => {
const second: MetadataCandidate = { provider: 'google', providerId: 'g2', title: 'Second Edition', pageCount: 412 }
const active = ref<MetadataCandidate | null>(google)
const diff = setup(active)
diff.setField('title', 'use')
active.value = second
diff.setField('pageCount', 'use')
expect(diff.buildPatch().formPatch).toMatchObject({ title: 'Google Title', pageCount: 412 })
expect(diff.stagedByCandidate.value.get('google:g1')).toBe(1)
expect(diff.stagedByCandidate.value.get('google:g2')).toBe(1)
})
it('stages a field from another result', () => {
const diff = setup(google, { alternatives: [google, goodreads] })
diff.setField('authors', 'use', goodreads)
expect(field(diff, 'authors')).toMatchObject({ isPicked: true, pickedFromActive: false, pickedProvider: 'goodreads' })
})
it('builds a patch reading each field from its own result, and links every provider that contributed', () => {
const diff = setup(google)
diff.toggleField('title')
diff.setField('authors', 'use', goodreads)
const { formPatch } = diff.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('groups the values other results offer, once per value', () => {
const agreeing: MetadataCandidate = { provider: 'amazon', providerId: 'a1', title: 'Goodreads Title' }
const same: MetadataCandidate = { provider: 'hardcover', providerId: 'h1', title: 'Original Title' }
const diff = setup(google, { alternatives: [google, goodreads, agreeing, same] })
const others = field(diff, 'title')?.otherValues ?? []
expect(others.map((entry) => [entry.display, entry.candidates.map((c) => c.provider), entry.matchesCurrent])).toEqual([
['Goodreads Title', ['goodreads', 'amazon'], false],
['Original Title', ['hardcover'], true],
])
diff.setField('title', 'use', agreeing)
expect(field(diff, 'title')?.otherValues[0]?.isPicked).toBe(true)
})
it('merges provider genres into the current list by default', () => {
const candidate: MetadataCandidate = {
provider: 'goodreads',
providerId: 'gr1',
title: 'Goodreads Title',
genres: [' genre 1 ', 'Genre 2', 'GENRE 2', ''],
}
const candidates = ref([candidate])
const activeProvider = ref<MetadataProviderKey>('goodreads')
const { fields, toggleField, buildPatch } = useMetadataDiff(mockCurrent, candidates, activeProvider, providers)
const diff = setup({ provider: 'goodreads', providerId: 'gr1', title: 'Goodreads Title', genres: [' genre 1 ', 'Genre 2', 'GENRE 2', ''] })
toggleField('genres')
expect(field(diff, 'genres')?.mergeable).toBe(true)
diff.toggleField('genres')
expect(fields.value.find((field) => field.key === 'genres')?.currentDisplay).toBe('Genre 1, Genre 2')
expect(buildPatch().formPatch.genres).toEqual(['Genre 1', 'Genre 2'])
expect(diff.decisionOf(field(diff, 'genres')!)).toBe('merge')
expect(diff.buildPatch().formPatch.genres).toEqual(['Genre 1', 'Genre 2'])
expect(diff.staged.value[0]).toMatchObject({ after: 'Genre 1, Genre 2', merged: true })
})
it('can replace current genres explicitly', () => {
const candidate: MetadataCandidate = {
provider: 'goodreads',
providerId: 'gr1',
title: 'Goodreads Title',
genres: ['Genre 2', 'genre 2', 'Genre 3'],
}
const candidates = ref([candidate])
const activeProvider = ref<MetadataProviderKey>('goodreads')
const { toggleField, buildPatch, setGenreWriteMode } = useMetadataDiff(mockCurrent, candidates, activeProvider, providers)
const diff = setup({ provider: 'goodreads', providerId: 'gr1', title: 'Goodreads Title', genres: ['Genre 2', 'genre 2', 'Genre 3'] })
toggleField('genres')
setGenreWriteMode('replace')
diff.setField('genres', 'replace')
expect(buildPatch().formPatch.genres).toEqual(['Genre 2', 'Genre 3'])
expect(diff.decisionOf(field(diff, 'genres')!)).toBe('replace')
expect(diff.buildPatch().formPatch.genres).toEqual(['Genre 2', 'Genre 3'])
})
it('shows and applies the Libro.fm ISBN as a provider ID', () => {
const candidate: MetadataCandidate = {
provider: 'librofm',
providerId: '9781234567890',
title: 'Libro.fm Title',
}
const candidates = ref([candidate])
const activeProvider = ref<MetadataProviderKey>('librofm')
const { fields, toggleField, buildPatch } = useMetadataDiff(mockCurrent, candidates, activeProvider, [
{ key: 'librofm', label: 'Libro.fm', identifiable: true },
])
const diff = setup(
{ provider: 'librofm', providerId: '9781234567890', title: 'Libro.fm Title' },
{ providers: [{ key: 'librofm', label: 'Libro.fm', identifiable: true }] },
)
expect(fields.value.find((field) => field.key === 'librofmId')).toMatchObject({
expect(field(diff, 'librofmId')).toMatchObject({
labelKey: 'book.detail.editMetadata.diff.providerIds.librofm',
candidateDisplay: '9781234567890',
})
toggleField('title')
expect(buildPatch().formPatch.librofmId).toBe('9781234567890')
diff.toggleField('title')
expect(diff.buildPatch().formPatch.librofmId).toBe('9781234567890')
})
it('shows and applies an AudNexus ASIN as the Audible ID', () => {
const candidate: MetadataCandidate = {
provider: 'audnexus',
providerId: 'B0TEST12345',
audibleId: 'B0TEST12345',
title: 'AudNexus Title',
}
const candidates = ref([candidate])
const activeProvider = ref<MetadataProviderKey>('audnexus')
const providerIds = ref({ audible: 'B0OLD12345' })
const { fields, toggleField, buildPatch } = useMetadataDiff(
mockCurrent,
candidates,
activeProvider,
[{ key: 'audnexus', label: 'AudNexus', identifiable: false }],
undefined,
providerIds,
const diff = setup(
{ provider: 'audnexus', providerId: 'B0TEST12345', audibleId: 'B0TEST12345', title: 'AudNexus Title' },
{ providers: [{ key: 'audnexus', label: 'AudNexus', identifiable: false }], providerIds: { audible: 'B0OLD12345' } },
)
expect(fields.value.find((field) => field.key === 'audibleId')).toMatchObject({
expect(field(diff, 'audibleId')).toMatchObject({
labelKey: 'book.detail.editMetadata.diff.providerIds.audible',
bookValue: 'B0OLD12345',
candidateDisplay: 'B0TEST12345',
kind: 'change',
})
toggleField('title')
expect(buildPatch().formPatch.audibleId).toBe('B0TEST12345')
diff.toggleField('title')
expect(diff.buildPatch().formPatch.audibleId).toBe('B0TEST12345')
})
it('builds a date and derived year patch when published date is picked', () => {
const candidate: MetadataCandidate = {
provider: 'google',
providerId: 'g1',
title: 'Google Title',
publishedDate: '1965-08-01',
publishedYear: 1965,
}
const candidates = ref([candidate])
const activeProvider = ref<MetadataProviderKey>('google')
const { toggleField, buildPatch } = useMetadataDiff(mockCurrent, candidates, activeProvider, providers)
const diff = setup({ provider: 'google', providerId: 'g1', title: 'Google Title', publishedDate: '1965-08-01', publishedYear: 1965 })
toggleField('publishedDate')
diff.toggleField('publishedDate')
expect(buildPatch().formPatch).toMatchObject({
publishedDate: '1965-08-01',
publishedYear: 1965,
})
expect(diff.buildPatch().formPatch).toMatchObject({ publishedDate: '1965-08-01', publishedYear: 1965 })
})
it('uses the publishedYear lock for publishedDate picks', () => {
const candidate: MetadataCandidate = {
provider: 'google',
providerId: 'g1',
title: 'Google Title',
publishedDate: '1965-08-01',
publishedYear: 1965,
}
const candidates = ref([candidate])
const activeProvider = ref<MetadataProviderKey>('google')
const { fields, toggleField, buildPatch } = useMetadataDiff(
mockCurrent,
candidates,
activeProvider,
providers,
undefined,
undefined,
ref(['publishedYear']),
const diff = setup(
{ provider: 'google', providerId: 'g1', title: 'Google Title', publishedDate: '1965-08-01', publishedYear: 1965 },
{ lockedFields: ['publishedYear'] },
)
const field = fields.value.find((f) => f.key === 'publishedDate')
expect(field?.isLocked).toBe(true)
expect(field(diff, 'publishedDate')?.isLocked).toBe(true)
diff.toggleField('publishedDate')
toggleField('publishedDate')
expect(buildPatch().formPatch.publishedDate).toBeUndefined()
expect(buildPatch().formPatch.publishedYear).toBeUndefined()
expect(diff.buildPatch().formPatch.publishedDate).toBeUndefined()
expect(diff.buildPatch().formPatch.publishedYear).toBeUndefined()
})
it('builds a provider-specific community ratings patch', () => {
const hardcoverCandidate: MetadataCandidate = {
const hardcover: MetadataCandidate = {
provider: 'hardcover',
providerId: 'hardcover-book',
title: 'Hardcover Title',
communityRating: 4.25,
communityRatingCount: 12345,
}
const amazonCandidate: MetadataCandidate = {
const amazon: MetadataCandidate = {
provider: 'amazon',
providerId: 'B00X47ZVXM',
title: 'Amazon Title',
communityRating: 4.8,
communityRatingCount: 104451,
}
const candidates = ref([hardcoverCandidate, amazonCandidate])
const activeProvider = ref<MetadataProviderKey>('hardcover')
const { fields, toggleField, pickFieldFromProvider, buildPatch } = useMetadataDiff(
mockCurrent,
candidates,
activeProvider,
communityRatingProviders,
)
const active = ref<MetadataCandidate | null>(hardcover)
const diff = setup(active)
const ratingField = fields.value.find((f) => f.key === 'communityRating')
expect(ratingField).toEqual(
expect.objectContaining({
bookValue: '',
candidateDisplay: '4.3 / 5 (12,345 ratings)',
}),
)
expect(field(diff, 'communityRating')).toMatchObject({
labelKey: 'book.detail.editMetadata.match.providerRating',
labelParams: { provider: 'Hardcover' },
bookValue: '',
candidateDisplay: '4.3 / 5 (12,345 ratings)',
kind: 'fill',
})
toggleField('communityRating')
pickFieldFromProvider('communityRating', 'amazon')
diff.toggleField('communityRating')
active.value = amazon
diff.toggleField('communityRating')
expect(buildPatch().formPatch).toMatchObject({
expect(diff.buildPatch().formPatch).toMatchObject({
communityRatings: [
{ provider: 'hardcover', rating: 4.25, ratingCount: 12345 },
{ provider: 'amazon', rating: 4.8, ratingCount: 104451 },
@@ -292,330 +301,234 @@ describe('useMetadataDiff', () => {
})
})
it('shows and applies Hardcover edition IDs as lockable metadata fields', () => {
const candidate: MetadataCandidate = {
provider: 'hardcover',
providerId: 'the-name-of-the-wind',
hardcoverEditionId: '1001',
title: 'Hardcover Title',
it('treats a refreshed rating count as a minor difference', () => {
const current = {
...mockCurrent,
communityRatings: [{ provider: 'amazon' as MetadataProviderKey, rating: 4.4, ratingCount: 9846, updatedAt: null }],
}
const candidates = ref([candidate])
const activeProvider = ref<MetadataProviderKey>('hardcover')
const { fields, toggleField, buildPatch } = useMetadataDiff(mockCurrent, candidates, activeProvider, hardcoverProviders)
const diff = setup({ provider: 'amazon', providerId: 'a1', title: 'Amazon Title', communityRating: 4.4, communityRatingCount: 9771 }, { current })
const editionField = fields.value.find((f) => f.key === 'hardcoverEditionId')
expect(editionField).toEqual(
expect.objectContaining({
bookValue: '',
candidateDisplay: '1001',
isLocked: false,
}),
)
expect(field(diff, 'communityRating')).toMatchObject({ kind: 'minor', minorReason: 'ratingCount' })
})
toggleField('hardcoverEditionId')
expect(buildPatch().formPatch).toEqual({
hardcoverEditionId: '1001',
hardcoverId: 'the-name-of-the-wind',
})
it('shows and applies Hardcover edition IDs as lockable metadata fields', () => {
const diff = setup({ provider: 'hardcover', providerId: 'the-name-of-the-wind', hardcoverEditionId: '1001', title: 'Hardcover Title' })
expect(field(diff, 'hardcoverEditionId')).toMatchObject({ bookValue: '', candidateDisplay: '1001', isLocked: false })
diff.toggleField('hardcoverEditionId')
expect(diff.buildPatch().formPatch).toEqual({ hardcoverEditionId: '1001', hardcoverId: 'the-name-of-the-wind' })
})
it('auto-includes unlocked Hardcover edition IDs when applying other Hardcover fields', () => {
const candidate: MetadataCandidate = {
provider: 'hardcover',
providerId: 'the-name-of-the-wind',
hardcoverEditionId: '1001',
title: 'Hardcover Title',
}
const candidates = ref([candidate])
const activeProvider = ref<MetadataProviderKey>('hardcover')
const { toggleField, buildPatch } = useMetadataDiff(mockCurrent, candidates, activeProvider, hardcoverProviders)
const diff = setup({ provider: 'hardcover', providerId: 'the-name-of-the-wind', hardcoverEditionId: '1001', title: 'Hardcover Title' })
toggleField('title')
diff.toggleField('title')
expect(buildPatch().formPatch).toMatchObject({
title: 'Hardcover Title',
hardcoverId: 'the-name-of-the-wind',
hardcoverEditionId: '1001',
})
expect(diff.buildPatch().formPatch).toMatchObject({ title: 'Hardcover Title', hardcoverId: 'the-name-of-the-wind', hardcoverEditionId: '1001' })
})
it('does not auto-include locked Hardcover edition IDs', () => {
const candidate: MetadataCandidate = {
provider: 'hardcover',
providerId: 'the-name-of-the-wind',
hardcoverEditionId: '1001',
title: 'Hardcover Title',
}
const candidates = ref([candidate])
const activeProvider = ref<MetadataProviderKey>('hardcover')
const { toggleField, buildPatch } = useMetadataDiff(
mockCurrent,
candidates,
activeProvider,
hardcoverProviders,
undefined,
undefined,
ref(['hardcoverEditionId'] as const),
const diff = setup(
{ provider: 'hardcover', providerId: 'the-name-of-the-wind', hardcoverEditionId: '1001', title: 'Hardcover Title' },
{ lockedFields: ['hardcoverEditionId'] },
)
toggleField('title')
diff.toggleField('title')
expect(buildPatch().formPatch).toMatchObject({
title: 'Hardcover Title',
hardcoverId: 'the-name-of-the-wind',
})
expect(buildPatch().formPatch.hardcoverEditionId).toBeUndefined()
expect(diff.buildPatch().formPatch).toMatchObject({ title: 'Hardcover Title', hardcoverId: 'the-name-of-the-wind' })
expect(diff.buildPatch().formPatch.hardcoverEditionId).toBeUndefined()
})
it('builds series memberships when series name and index are picked from the same provider', () => {
const audibleCandidate: MetadataCandidate = {
provider: 'audible',
providerId: 'B002V1NSN2',
title: 'Confessor',
seriesName: 'Sword of Truth',
seriesIndex: '11',
seriesMemberships: [
{ seriesName: 'Sword of Truth', seriesIndex: '11' },
{ seriesName: 'Chainfire Trilogy', seriesIndex: '3' },
],
}
const candidates = ref([audibleCandidate])
const activeProvider = ref<MetadataProviderKey>('audible')
const { toggleField, buildPatch } = useMetadataDiff(mockCurrent, candidates, activeProvider, [
{ key: 'audible' as MetadataProviderKey, label: 'Audible', identifiable: true },
])
const audible: MetadataCandidate = {
provider: 'audible',
providerId: 'B002V1NSN2',
title: 'Confessor',
seriesName: 'Sword of Truth',
seriesIndex: '11',
seriesMemberships: [
{ seriesName: 'Sword of Truth', seriesIndex: '11' },
{ seriesName: 'Chainfire Trilogy', seriesIndex: '3' },
],
}
toggleField('seriesName')
toggleField('seriesIndex')
it('builds series memberships when series name and index are picked from the same result', () => {
const diff = setup(audible)
expect(buildPatch().formPatch.seriesMemberships).toEqual([
diff.toggleField('seriesName')
diff.toggleField('seriesIndex')
expect(diff.buildPatch().formPatch.seriesMemberships).toEqual([
{ seriesName: 'Sword of Truth', seriesIndex: '11' },
{ seriesName: 'Chainfire Trilogy', seriesIndex: '3' },
])
})
it('does not replace series memberships when only the series name is picked', () => {
const audibleCandidate: MetadataCandidate = {
provider: 'audible',
providerId: 'B002V1NSN2',
title: 'Confessor',
seriesName: 'Sword of Truth',
seriesIndex: '11',
seriesMemberships: [
{ seriesName: 'Sword of Truth', seriesIndex: '11' },
{ seriesName: 'Chainfire Trilogy', seriesIndex: '3' },
],
}
const candidates = ref([audibleCandidate])
const activeProvider = ref<MetadataProviderKey>('audible')
const { toggleField, buildPatch } = useMetadataDiff(mockCurrent, candidates, activeProvider, [
{ key: 'audible' as MetadataProviderKey, label: 'Audible', identifiable: true },
const diff = setup(audible)
diff.toggleField('seriesName')
expect(diff.buildPatch().formPatch.seriesMemberships).toBeUndefined()
})
it('does not build memberships from a name and an index of different results', () => {
const other: MetadataCandidate = { ...audible, providerId: 'B00OTHER', seriesIndex: '12' }
const diff = setup(audible)
diff.setField('seriesName', 'use')
diff.setField('seriesIndex', 'use', other)
expect(diff.buildPatch().formPatch.seriesMemberships).toBeUndefined()
})
it('takes every difference from the active result', () => {
const diff = setup(google)
expect(diff.takeAllCount.value).toBe(diff.fields.value.filter((f) => f.hasDiff).length + 1)
diff.takeAll()
expect(diff.fields.value.every((f) => !f.hasDiff || f.isPicked)).toBe(true)
expect(field(diff, 'title')?.pickedProvider).toBe('google')
expect(diff.buildPatch().coverUrl).toBe('http://google.com/cover.jpg')
})
it('fills only the fields that are empty on the book', () => {
const diff = setup(google, { current: { ...mockCurrent, description: '' } })
diff.fillEmpty()
expect(field(diff, 'title')?.isPicked).toBe(false)
expect(field(diff, 'description')?.isPicked).toBe(true)
})
it('lists staged changes with what they replace, and unstages one', () => {
const diff = setup(google)
diff.toggleField('title')
diff.toggleField('description')
expect(diff.staged.value.map((change) => [change.key, change.before, change.after])).toEqual([
['title', 'Original Title', 'Google Title'],
['description', 'Original Description', 'Google Description'],
])
diff.unstage('title')
expect(diff.staged.value.map((change) => change.key)).toEqual(['description'])
toggleField('seriesName')
expect(buildPatch().formPatch.seriesMemberships).toBeUndefined()
})
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)
diff.clearAll()
expect(diff.hasCopied.value).toBe(false)
})
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 diff = setup({ provider: 'google', providerId: 'c1', title: 'Comic', comicMetadata: { issueNumber: '42', volumeName: 'Volume 1' } })
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' }),
]),
)
expect(field(diff, 'comicIssueNumber')).toMatchObject({ candidateDisplay: '42', kind: 'fill' })
})
it('marks locked fields and prevents them from being picked', () => {
const candidates = ref([mockCandidate1])
const activeProvider = ref<MetadataProviderKey>('google')
const lockedFields = ref(['title'] as const)
const { fields, toggleField, buildPatch } = useMetadataDiff(
mockCurrent,
candidates,
activeProvider,
providers,
undefined,
undefined,
lockedFields,
)
const diff = setup(google, { lockedFields: ['title'] })
toggleField('title')
diff.toggleField('title')
diff.takeAll()
expect(fields.value.find((f) => f.key === 'title')?.isLocked).toBe(true)
expect(fields.value.find((f) => f.key === 'title')?.isPicked).toBe(false)
expect(buildPatch().formPatch.title).toBeUndefined()
expect(field(diff, 'title')).toMatchObject({ isLocked: true, isPicked: false })
expect(diff.buildPatch().formPatch.title).toBeUndefined()
})
it('proxies external cover URLs for display and preserves raw cover URL in patch', () => {
it('proxies external cover URLs for display and preserves the raw cover URL in the patch', () => {
const externalCover = 'https://m.media-amazon.com/images/I/41ZaIFRkWyL.jpg'
const candidate: MetadataCandidate = {
...mockCandidate1,
coverUrl: externalCover,
}
const candidates = ref([candidate])
const activeProvider = ref<MetadataProviderKey>('google')
const { fields, toggleField, buildPatch } = useMetadataDiff(mockCurrent, candidates, activeProvider, providers)
const diff = setup({ ...google, coverUrl: externalCover })
const coverField = fields.value.find((f) => f.key === 'coverUrl')
expect(coverField?.candidateDisplay).toContain('/api/v1/books/cover/proxy?url=')
expect(diff.cover.value.active?.url).toContain('/api/v1/books/cover/proxy?url=')
diff.pickCover(diff.cover.value.active!.candidate)
toggleField('coverUrl')
const { coverUrl } = buildPatch()
expect(coverUrl).toBe(externalCover)
expect(diff.buildPatch().coverUrl).toBe(externalCover)
})
it('does not proxy same-origin cover URLs for display', () => {
const sameOriginCover = '/api/v1/books/1/cover'
const candidate: MetadataCandidate = {
...mockCandidate1,
coverUrl: sameOriginCover,
}
const candidates = ref([candidate])
const activeProvider = ref<MetadataProviderKey>('google')
const { fields } = useMetadataDiff(mockCurrent, candidates, activeProvider, providers)
const diff = setup({ ...google, coverUrl: '/api/v1/books/1/cover' })
const coverField = fields.value.find((f) => f.key === 'coverUrl')
expect(coverField?.candidateDisplay).toBe(sameOriginCover)
expect(diff.cover.value.active?.url).toBe('/api/v1/books/1/cover')
})
it('gives the cover row the lock, label and frame of the slot it fills, and reads the current cover live', () => {
const candidates = ref([mockCandidate1])
const activeProvider = ref<MetadataProviderKey>('google')
it('gives the cover the lock and slot it fills, and reads the current cover live', () => {
const currentCover = ref('')
const { fields, toggleField, buildPatch } = useMetadataDiff(
mockCurrent,
candidates,
activeProvider,
providers,
() => currentCover.value,
undefined,
['audioCover'],
{ coverMedium: 'audio', coverLabelKey: 'book.detail.editMetadata.diff.fields.audioCover' },
)
const diff = setup(google, { currentCoverUrl: () => currentCover.value, lockedFields: ['audioCover'], coverMedium: 'audio' })
expect(fields.value.find((f) => f.key === 'coverUrl')).toMatchObject({
labelKey: 'book.detail.editMetadata.diff.fields.audioCover',
coverMedium: 'audio',
isLocked: true,
bookValue: '',
})
toggleField('coverUrl')
expect(buildPatch().coverUrl).toBeUndefined()
expect(diff.cover.value).toMatchObject({ medium: 'audio', locked: true, currentUrl: '' })
diff.pickCover(google)
expect(diff.buildPatch().coverUrl).toBeUndefined()
currentCover.value = '/api/v1/books/1/cover?medium=audio'
expect(fields.value.find((f) => f.key === 'coverUrl')?.bookValue).toBe('/api/v1/books/1/cover?medium=audio')
expect(diff.cover.value.currentUrl).toBe('/api/v1/books/1/cover?medium=audio')
})
describe('cover shape', () => {
const hardcover: MetadataCandidate = { provider: 'hardcover', providerId: 'h1', title: 'The Silver Chair', coverUrl: '/covers/hardcover.jpg' }
const audible: MetadataCandidate = {
const square: MetadataCandidate = {
provider: 'audible',
providerId: 'a1',
title: 'The Silver Chair',
coverUrl: '/covers/audible.jpg',
coverShape: 'square',
}
const google: MetadataCandidate = {
const portrait: MetadataCandidate = {
provider: 'google',
providerId: 'g1',
title: 'The Silver Chair',
coverUrl: '/covers/google.jpg',
coverShape: 'portrait',
}
const measuredPortrait = (candidate: MetadataCandidate) => (candidate === hardcover ? 'portrait' : (candidate.coverShape ?? 'unknown'))
const strip: MetadataCandidate = {
provider: 'amazon',
providerId: 'am1',
title: 'The Silver Chair',
coverUrl: '/covers/strip.jpg',
coverShape: 'square',
}
// Keyed by id, as the real lookups go by URL: the active result arrives as a reactive proxy.
const measuredPortrait = (candidate: MetadataCandidate) => (candidate.providerId === 'h1' ? 'portrait' : (candidate.coverShape ?? 'unknown'))
const sizes = (candidate: MetadataCandidate) => (candidate.providerId === 'am1' ? { width: 575, height: 92 } : null)
function audioDiff(currentCover: string) {
return useMetadataDiff(mockCurrent, [hardcover, google, audible], 'hardcover', communityRatingProviders, () => currentCover, undefined, [], {
return setup(hardcover, {
alternatives: [hardcover, strip, portrait, square],
currentCoverUrl: () => currentCover,
coverMedium: 'audio',
coverPriority: ['google', 'hardcover'],
coverShapeOf: measuredPortrait,
coverSizeOf: sizes,
})
}
it('names the fit of the offered cover and lists square alternatives first for the audio slot', () => {
const { fields } = audioDiff('/api/v1/books/1/cover?medium=audio')
const cover = fields.value.find((f) => f.key === 'coverUrl')
it('names the fit of the offered cover and lists fitting art first, broken images last', () => {
const { cover } = audioDiff('/api/v1/books/1/cover?medium=audio')
expect(cover?.candidateCoverFit).toBe('mismatch')
expect(cover?.providerValues.map((value) => value.provider)).toEqual(['audible', 'hardcover', 'google'])
expect(cover.value.active?.fit).toBe('mismatch')
expect(cover.value.choices.map((choice) => [choice.candidate.provider, choice.fit, choice.broken])).toEqual([
['audible', 'match', false],
['google', 'mismatch', false],
['hardcover', 'mismatch', false],
['amazon', 'match', true],
])
})
it('keeps Copy All from swapping a cover for art of the other shape, but the arrow still can', () => {
const { copyAll, buildPatch, toggleField } = audioDiff('/api/v1/books/1/cover?medium=audio')
it('keeps Use all from swapping a cover for art of the other shape, but the cover control still can', () => {
const diff = audioDiff('/api/v1/books/1/cover?medium=audio')
copyAll()
expect(buildPatch().coverUrl).toBeUndefined()
expect(buildPatch().formPatch.title).toBe('The Silver Chair')
diff.takeAll()
expect(diff.buildPatch().coverUrl).toBeUndefined()
expect(diff.buildPatch().formPatch.title).toBe('The Silver Chair')
toggleField('coverUrl')
expect(buildPatch().coverUrl).toBe('/covers/hardcover.jpg')
diff.pickCover(hardcover)
expect(diff.buildPatch().coverUrl).toBe('/covers/hardcover.jpg')
})
it('lets Copy All fill an empty slot with art of the other shape', () => {
const { copyAll, buildPatch } = audioDiff('')
it('lets Use all fill an empty slot with art of the other shape', () => {
const diff = audioDiff('')
copyAll()
expect(buildPatch().coverUrl).toBe('/covers/hardcover.jpg')
diff.takeAll()
expect(diff.buildPatch().coverUrl).toBe('/covers/hardcover.jpg')
})
})
})
@@ -32,21 +32,20 @@ describe('useSecondCoverRow', () => {
expect(active.value).toBe(audible)
})
it('ranks art measured as the other shape last, and names its fit', () => {
it('ranks art measured as the other shape last', () => {
const hardcover = candidate('hardcover', 'hardcover-1')
const goodreads = candidate('goodreads', 'goodreads-1')
const measured = (entry: MetadataCandidate) => (entry === hardcover ? 'portrait' : (entry.coverShape ?? 'unknown'))
const { choices, active, row, select } = useSecondCoverRow(
const { choices, active, select } = useSecondCoverRow(
source({ priority: ['hardcover', 'goodreads'], candidates: [hardcover, goodreads] }),
measured,
)
expect(choices.value).toEqual([goodreads, hardcover])
expect(active.value).toBe(goodreads)
expect(row.value?.candidateCoverFit).toBe('unknown')
select(hardcover)
expect(row.value?.candidateCoverFit).toBe('mismatch')
expect(active.value).toBe(hardcover)
})
it('keeps its pick apart from the selection, and a locked slot cannot be picked', () => {
@@ -61,15 +60,7 @@ describe('useSecondCoverRow', () => {
row.select(itunes)
expect(row.pickedCoverUrl.value).toBe('/covers/audible-1.jpg')
expect(row.row.value).toMatchObject({
key: 'secondCoverUrl',
labelKey: 'book.detail.editMetadata.diff.fields.audioCover',
coverMedium: 'audio',
isPicked: true,
pickedFromActive: false,
pickedProvider: 'audible',
bookValue: '/api/v1/books/1/cover?medium=audio',
})
expect(row.active.value).toBe(itunes)
row.togglePick()
expect(row.picked.value).toBe(itunes)
@@ -77,13 +68,27 @@ describe('useSecondCoverRow', () => {
locked.value = true
row.togglePick()
expect(row.picked.value).toBe(itunes)
expect(row.row.value?.isLocked).toBe(true)
row.pick(audible)
expect(row.picked.value).toBe(itunes)
})
it('has no row without a second medium', () => {
const { row, choices } = useSecondCoverRow(null)
it('stages a cover straight from the strip, and a second pick of the same cover unstages it', () => {
const audible = candidate('audible', 'audible-1', { coverShape: 'square' })
const itunes = candidate('itunes', 'itunes-1', { coverShape: 'square' })
const row = useSecondCoverRow(source({ candidates: [audible, itunes] }))
expect(row.value).toBeNull()
row.pick(itunes)
expect(row.picked.value).toBe(itunes)
expect(row.active.value).toBe(itunes)
row.pick(itunes)
expect(row.picked.value).toBeNull()
})
it('offers nothing without a second medium', () => {
const { active, choices } = useSecondCoverRow(null)
expect(active.value).toBeNull()
expect(choices.value).toEqual([])
})
})
@@ -3,13 +3,19 @@ import { coverShapeFromSize, type MetadataCandidate, type MetadataCoverShape } f
import { toDisplayCoverUrl } from '../lib/metadata-fetch'
import { statedCoverShape } from '../lib/cover-slots'
export interface MeasuredCoverSize {
width: number
height: number
}
/**
* Each candidate cover's shape: the provider's, or when it states none, the size of the image once
* the browser has loaded it. Hardcover leaves the size off some editions, and an audiobook edition's
* image is as often the print jacket as square art, so a stated-unknown cover can still be portrait.
* Each candidate cover's shape and pixel size. The shape is the provider's, or when it states none,
* the size of the image once the browser has loaded it: Hardcover leaves the size off some editions,
* and an audiobook edition's image is as often the print jacket as square art. The size is the
* image's once measured, otherwise the provider's, so a 575 x 92 strip can be told from a cover.
*/
export function useCoverShapes(candidates: MaybeRefOrGetter<readonly MetadataCandidate[]>) {
const measured = shallowReactive(new Map<string, MetadataCoverShape>())
const measured = shallowReactive(new Map<string, MeasuredCoverSize | null>())
const pending = new Set<string>()
function measure(url: string) {
@@ -17,20 +23,17 @@ export function useCoverShapes(candidates: MaybeRefOrGetter<readonly MetadataCan
const image = new Image()
image.onload = () => {
pending.delete(url)
measured.set(url, coverShapeFromSize(image.naturalWidth, image.naturalHeight))
measured.set(url, { width: image.naturalWidth, height: image.naturalHeight })
}
image.onerror = () => {
pending.delete(url)
measured.set(url, 'unknown')
measured.set(url, null)
}
image.src = url
}
watch(
() =>
toValue(candidates)
.filter((candidate) => statedCoverShape(candidate) === 'unknown')
.map((candidate) => toDisplayCoverUrl(candidate.coverUrl)),
() => toValue(candidates).map((candidate) => toDisplayCoverUrl(candidate.coverUrl)),
(urls) => {
for (const url of urls) {
if (url && !measured.has(url) && !pending.has(url)) measure(url)
@@ -42,8 +45,16 @@ export function useCoverShapes(candidates: MaybeRefOrGetter<readonly MetadataCan
function shapeOf(candidate: MetadataCandidate): MetadataCoverShape {
const stated = statedCoverShape(candidate)
if (stated !== 'unknown') return stated
return measured.get(toDisplayCoverUrl(candidate.coverUrl)) ?? 'unknown'
const size = measured.get(toDisplayCoverUrl(candidate.coverUrl))
return size ? coverShapeFromSize(size.width, size.height) : 'unknown'
}
return { shapeOf }
/** The measured size wins: a stated size can describe a thumbnail smaller than the image served. */
function sizeOf(candidate: MetadataCandidate): MeasuredCoverSize | null {
const size = measured.get(toDisplayCoverUrl(candidate.coverUrl))
if (size) return size
return candidate.coverWidth && candidate.coverHeight ? { width: candidate.coverWidth, height: candidate.coverHeight } : null
}
return { shapeOf, sizeOf }
}
File diff suppressed because it is too large Load Diff
@@ -32,7 +32,12 @@ export function useMetadataSearch() {
const providers = ref<MetadataProviderInfo[]>([])
const selectedProviders = ref<MetadataProviderKey[]>([])
// Providers asked again on their own after they stopped early, while the answer is on its way.
const retryingProviders = ref<MetadataProviderKey[]>([])
let abortController: AbortController | null = null
const retryControllers = new Set<AbortController>()
let lastParams: SearchParams | null = null
async function loadProviders(bookId?: number) {
const query = bookId != null ? `?bookId=${bookId}` : ''
@@ -46,21 +51,15 @@ export function useMetadataSearch() {
function cancel() {
abortController?.abort()
abortController = null
for (const controller of retryControllers) controller.abort()
retryControllers.clear()
retryingProviders.value = []
isStreaming.value = false
}
onUnmounted(cancel)
async function search(params: SearchParams) {
cancel()
results.value = []
for (const k of Object.keys(providerCounts)) delete providerCounts[k as MetadataProviderKey]
for (const k of Object.keys(providerStatuses)) delete providerStatuses[k as MetadataProviderKey]
hasSearched.value = true
isStreaming.value = true
const controller = new AbortController()
abortController = controller
function buildQuery(params: SearchParams): URLSearchParams {
const query = new URLSearchParams()
if (params.title) query.set('title', params.title)
if (params.author) query.set('author', params.author)
@@ -71,46 +70,64 @@ export function useMetadataSearch() {
const onlyProvider = providers.value.length === 1 ? providers.value[0] : undefined
const requestedProviders = params.providers ?? (selectedProviders.value.length ? selectedProviders.value : onlyProvider ? [onlyProvider.key] : [])
if (requestedProviders.length) query.set('providers', requestedProviders.join(','))
return query
}
try {
const res = await api(`/api/v1/metadata-fetch/stream?${query}`, {
signal: controller.signal,
})
if (!res.ok || !res.body) return
/** Reads one search stream into the shared results; returns when it ends or is aborted. */
async function stream(query: URLSearchParams, controller: AbortController) {
const res = await api(`/api/v1/metadata-fetch/stream?${query}`, {
signal: controller.signal,
})
if (!res.ok || !res.body) return
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const events = buffer.split('\n\n')
buffer = events.pop() ?? ''
for (const event of events) {
const lines = event.split('\n')
const dataLine = lines.find((l) => l.startsWith('data:'))
if (!dataLine) continue
const eventName = lines
.find((l) => l.startsWith('event:'))
?.slice(6)
.trim()
try {
const payload = JSON.parse(dataLine.slice(5).trim())
if (eventName === METADATA_PROVIDER_STATUS_EVENT) {
const status = payload as MetadataProviderSearchStatus
providerStatuses[status.provider] = status.outcome
continue
}
const candidate = payload as MetadataCandidate
results.value.push(candidate)
providerCounts[candidate.provider] = (providerCounts[candidate.provider] ?? 0) + 1
} catch {
// ignore malformed events
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const events = buffer.split('\n\n')
buffer = events.pop() ?? ''
for (const event of events) {
const lines = event.split('\n')
const dataLine = lines.find((l) => l.startsWith('data:'))
if (!dataLine) continue
const eventName = lines
.find((l) => l.startsWith('event:'))
?.slice(6)
.trim()
try {
const payload = JSON.parse(dataLine.slice(5).trim())
if (eventName === METADATA_PROVIDER_STATUS_EVENT) {
const status = payload as MetadataProviderSearchStatus
providerStatuses[status.provider] = status.outcome
continue
}
const candidate = payload as MetadataCandidate
results.value.push(candidate)
providerCounts[candidate.provider] = (providerCounts[candidate.provider] ?? 0) + 1
} catch {
// ignore malformed events
}
}
}
}
async function search(params: SearchParams) {
cancel()
lastParams = params
results.value = []
for (const k of Object.keys(providerCounts)) delete providerCounts[k as MetadataProviderKey]
for (const k of Object.keys(providerStatuses)) delete providerStatuses[k as MetadataProviderKey]
hasSearched.value = true
isStreaming.value = true
const controller = new AbortController()
abortController = controller
try {
await stream(buildQuery(params), controller)
} catch (e) {
if (e instanceof Error && e.name === 'AbortError') return
} finally {
@@ -121,6 +138,26 @@ export function useMetadataSearch() {
}
}
/** Asks one provider again with the last search, replacing only its own results. */
async function retryProvider(provider: MetadataProviderKey) {
if (!lastParams || retryingProviders.value.includes(provider)) return
results.value = results.value.filter((candidate) => candidate.provider !== provider)
delete providerCounts[provider]
delete providerStatuses[provider]
retryingProviders.value = [...retryingProviders.value, provider]
const controller = new AbortController()
retryControllers.add(controller)
try {
await stream(buildQuery({ ...lastParams, providers: [provider] }), controller)
} catch (e) {
if (e instanceof Error && e.name === 'AbortError') return
} finally {
retryControllers.delete(controller)
retryingProviders.value = retryingProviders.value.filter((key) => key !== provider)
}
}
const PROVIDER_ORDER: MetadataProviderKey[] = [
'comicvine',
'amazon',
@@ -215,12 +252,14 @@ export function useMetadataSearch() {
providerCounts,
providerStatuses,
interruptedProviders,
retryingProviders,
isStreaming,
hasSearched,
providers,
selectedProviders,
loadProviders,
search,
retryProvider,
toggleProvider,
selectAllProviders,
selectFieldRuleProviders,
@@ -2,7 +2,6 @@ import { computed, shallowRef, toValue, type MaybeRefOrGetter } from 'vue'
import type { CoverMedium, MetadataCandidate, MetadataCoverShape, MetadataProviderKey } from '@bookorbit/types'
import { toDisplayCoverUrl } from '../lib/metadata-fetch'
import { COVER_FIT_RANK, coverFit, statedCoverShape } from '../lib/cover-slots'
import type { DiffField } from './useMetadataDiff'
/** What the diff panel is given for the other medium's cover row. */
export interface SecondCoverInput {
@@ -61,32 +60,6 @@ export function useSecondCoverRow(
return choices.value[0] ?? null
})
const row = computed<DiffField | null>(() => {
const current = toValue(source)
if (!current) return null
const candidateDisplay = toDisplayCoverUrl(active.value?.coverUrl)
const pickedDisplay = toDisplayCoverUrl(picked.value?.coverUrl)
const isPicked = picked.value !== null
return {
key: 'secondCoverUrl',
labelKey: `book.detail.editMetadata.diff.fields.${current.medium === 'audio' ? 'audioCover' : 'bookCover'}`,
bookValue: current.currentUrl,
currentDisplay: isPicked ? pickedDisplay : current.currentUrl,
candidateDisplay,
hasDiff: current.currentUrl !== candidateDisplay,
isPicked,
pickedFromActive: isPicked && picked.value === active.value,
pickedProvider: picked.value?.provider ?? null,
pickedDisplay,
isCover: true,
coverMedium: current.medium,
...(active.value && candidateDisplay ? { candidateCoverFit: coverFit(shapeOf(active.value), current.medium) } : {}),
isLocked: current.locked,
isCopyable: true,
providerValues: [],
}
})
function select(candidate: MetadataCandidate) {
chosen.value = candidate
}
@@ -96,7 +69,14 @@ export function useSecondCoverRow(
picked.value = picked.value === active.value ? null : active.value
}
/** Stages a cover straight from the strip, and shows it; picking the staged one again unstages it. */
function pick(candidate: MetadataCandidate | null) {
if (toValue(source)?.locked) return
if (candidate) chosen.value = candidate
picked.value = candidate && picked.value !== candidate ? candidate : null
}
const pickedCoverUrl = computed(() => picked.value?.coverUrl)
return { choices, active, row, picked, pickedCoverUrl, select, togglePick }
return { choices, active, picked, pickedCoverUrl, select, togglePick, pick }
}
@@ -0,0 +1,111 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import type { MetadataCandidate, MetadataSource } from '@bookorbit/types'
import {
classifyDate,
classifyDescription,
classifyIdentifier,
classifyLanguage,
classifyList,
classifyNumber,
classifyRating,
classifyText,
descriptionPlainText,
summarizeDifferences,
} from '../metadata-diff-fields'
describe('difference kinds', () => {
it('reads an empty book field as a fill and an empty offer as missing', () => {
expect(classifyText('', 'Dune')).toEqual({ kind: 'fill' })
expect(classifyText('Dune', ' ')).toEqual({ kind: 'missing' })
})
it('tells capitalization and punctuation apart from a real change', () => {
expect(classifyText('Dune', 'Dune')).toEqual({ kind: 'same' })
expect(classifyText('Dune Messiah', 'dune messiah')).toEqual({ kind: 'minor', reason: 'capitalization' })
expect(classifyText('Dune: Messiah', 'Dune Messiah')).toEqual({ kind: 'minor', reason: 'punctuation' })
expect(classifyText('Miramax', 'Disney Hyperion')).toEqual({ kind: 'change' })
})
it('compares lists as sets, so the same genres in another order are only reordered', () => {
expect(classifyList(['Fantasy', 'Fiction'], ['Fiction', 'Fantasy'])).toEqual({ kind: 'minor', reason: 'reordered' })
expect(classifyList(['Fantasy'], ['fantasy'])).toEqual({ kind: 'minor', reason: 'capitalization' })
expect(classifyList(['Fantasy'], ['Fantasy', 'Magic'])).toEqual({ kind: 'change' })
expect(classifyList([], ['Magic'])).toEqual({ kind: 'fill' })
})
it('sees markup-only description changes as formatting', () => {
expect(classifyDescription('<p>A <i>bold</i> start.</p>', 'A bold start.')).toEqual({ kind: 'minor', reason: 'formatting' })
expect(classifyDescription('A bold start.', 'A timid start.')).toEqual({ kind: 'change' })
})
it('treats a bare year against a full date of the same year as minor', () => {
expect(classifyDate('2003-04-01', '2003')).toEqual({ kind: 'minor', reason: 'yearOnly' })
expect(classifyDate('2003', '2003-04-01')).toEqual({ kind: 'minor', reason: 'addsDay' })
expect(classifyDate('2003-04-01', '2009-08-07')).toEqual({ kind: 'change' })
})
it('reads a language code and its name as the same language', () => {
expect(classifyLanguage('English', 'en')).toEqual({ kind: 'minor', reason: 'languageCode' })
expect(classifyLanguage('English', 'fr')).toEqual({ kind: 'change' })
})
it('compares numbers by value and identifiers without their separators', () => {
expect(classifyNumber('1', 1)).toEqual({ kind: 'same' })
expect(classifyNumber(396, 420)).toEqual({ kind: 'change' })
expect(classifyIdentifier('978-1-4231-3217-2', '9781423132172')).toEqual({ kind: 'minor', reason: 'formatting' })
})
it('treats a new rating count with the same rating as minor', () => {
expect(classifyRating({ rating: 4.4, ratingCount: 9846 }, { rating: 4.4, ratingCount: 9771 })).toEqual({ kind: 'minor', reason: 'ratingCount' })
expect(classifyRating({ rating: 3.9, ratingCount: 10 }, { rating: 4.4, ratingCount: 10 })).toEqual({ kind: 'change' })
})
})
describe('descriptionPlainText', () => {
it('keeps paragraph breaks and decodes entities', () => {
expect(descriptionPlainText('<p>She lost her daughter&nbsp;once.</p><p>Then&#8212;the letter &amp; more.</p>')).toBe(
'She lost her daughter once.\n\nThen\u2014the letter & more.',
)
})
})
describe('summarizeDifferences', () => {
const current: MetadataSource = {
title: 'Artemis Fowl',
subtitle: null,
authors: ['Eoin Colfer'],
genres: ['Fantasy', 'Fiction'],
description: null,
publisher: 'Miramax',
publishedDate: '2003-04-01',
publishedYear: 2003,
language: 'English',
pageCount: 396,
seriesName: 'Artemis Fowl',
seriesIndex: '1',
isbn10: null,
isbn13: null,
narrators: [],
durationSeconds: null,
abridged: null,
hardcoverEditionId: null,
communityRatings: [],
}
it('counts fills and real changes, leaving minor differences out', () => {
const candidate: MetadataCandidate = {
provider: 'amazon',
providerId: 'B002KP6DXQ',
title: 'Artemis Fowl',
authors: ['Eoin Colfer'],
genres: ['Fiction', 'Fantasy'],
publisher: 'Disney Hyperion',
language: 'en',
pageCount: 420,
isbn13: '9781423132172',
}
expect(summarizeDifferences(current, candidate, { amazon: 'B002KP6DXQ' })).toEqual({ fills: 1, changes: 2 })
})
})
@@ -0,0 +1,78 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import type { MetadataCandidate } from '@bookorbit/types'
import { assessMatch, candidateKey, groupMatches, rankMatches, type MatchSubject } from '../metadata-match'
const book: MatchSubject = {
title: 'Artemis Fowl',
authors: ['Eoin Colfer'],
isbn13: null,
isbn10: null,
seriesName: 'Artemis Fowl',
seriesIndex: '1',
providerIds: { goodreads: '249747', amazon: 'B002KP6DXQ' },
}
function result(provider: MetadataCandidate['provider'], providerId: string, data: Partial<MetadataCandidate> = {}): MetadataCandidate {
return { provider, providerId, title: 'Artemis Fowl', authors: ['Eoin Colfer'], ...data }
}
describe('assessMatch', () => {
it('rates a record the book is already linked to as a strong match', () => {
expect(assessMatch(result('goodreads', '249747'), book)).toMatchObject({ tier: 'strong', linked: true })
})
it('rates a shared ISBN as a strong match, ignoring hyphens', () => {
const subject = { ...book, isbn13: '978-0-7868-1787-0' }
expect(assessMatch(result('google', 'x', { isbn13: '9780786817870' }), subject)).toMatchObject({ tier: 'strong', isbnMatch: true, linked: false })
})
it('treats the same title and author as another edition, whatever edition note the provider adds', () => {
expect(assessMatch(result('google', 'mass', { title: 'Artemis Fowl (Mass market edition)' }), book)).toMatchObject({
tier: 'possible',
titleMatch: 'same',
authorMatch: true,
})
expect(assessMatch(result('hardcover', 'tp', { title: 'Artemis Fowl: The Time Paradox by Eoin Colfer' }), book)).toMatchObject({
tier: 'weak',
titleMatch: 'longer',
})
})
it('puts a collection or a different author with the probably different books', () => {
expect(assessMatch(result('google', 'set', { title: 'Artemis Fowl: Books 1-4' }), book).tier).toBe('weak')
expect(assessMatch(result('itunes', 'x', { title: 'Forget You Know Me', authors: ['Jessica Strawser'] }), book)).toMatchObject({
tier: 'weak',
authorMatch: false,
titleMatch: 'different',
})
})
it('matches an author by surname when the initials differ', () => {
expect(assessMatch(result('google', 'x', { authors: ['E. Colfer'] }), book).authorMatch).toBe(true)
})
it('flags a record with little in it as sparse', () => {
expect(assessMatch(result('hardcover', 'x'), book)).toMatchObject({ sparse: true, filledFields: 2 })
})
it('reads an Audible id the book holds as the link for an AudNexus record', () => {
expect(assessMatch(result('audnexus', 'B0ASIN'), { ...book, providerIds: { audible: 'B0ASIN' } }).linked).toBe(true)
})
})
describe('rankMatches', () => {
it('orders by tier, then score, then provider order', () => {
const linked = result('amazon', 'B002KP6DXQ', { seriesName: 'Artemis Fowl', seriesIndex: '1' })
const alsoLinked = result('goodreads', '249747')
const edition = result('hardcover', 'ed')
const other = result('google', 'set', { title: 'Artemis Fowl: Books 1-4' })
const ranked = rankMatches([other, edition, alsoLinked, linked], book, ['goodreads', 'amazon'])
expect(ranked.map((match) => candidateKey(match.candidate))).toEqual(['amazon:B002KP6DXQ', 'goodreads:249747', 'hardcover:ed', 'google:set'])
const groups = groupMatches(ranked)
expect([groups.strong.length, groups.possible.length, groups.weak.length]).toEqual([2, 1, 1])
})
})
@@ -0,0 +1,39 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { wordDiff } from '../word-diff'
describe('wordDiff', () => {
it('marks the words the new text drops and the ones it adds', () => {
const diff = wordDiff('Twelve-year-old Artemis Fowl is a genius', 'Twelve-year-old Artemis is a young genius')
expect(diff?.before).toEqual([
{ kind: 'same', text: 'Twelve-year-old Artemis ' },
{ kind: 'removed', text: 'Fowl' },
{ kind: 'same', text: ' is a genius' },
])
expect(diff?.after).toEqual([
{ kind: 'same', text: 'Twelve-year-old Artemis is a ' },
{ kind: 'added', text: 'young' },
{ kind: 'same', text: ' genius' },
])
})
it('never marks whitespace, so a highlight starts and ends on a word', () => {
const diff = wordDiff('one two', 'one three')
const marked = [...(diff?.before ?? []), ...(diff?.after ?? [])].filter((part) => part.kind !== 'same')
expect(marked.map((part) => part.text)).toEqual(['two', 'three'])
})
it('reports how much of the text the two share', () => {
expect(wordDiff('a b c d', 'a b c d')?.similarity).toBe(1)
expect(wordDiff('a b c d', 'w x y z')?.similarity).toBe(0)
expect(wordDiff('a b c d', 'a b x y')?.similarity).toBe(0.5)
})
it('declines texts too long to compare', () => {
const long = Array.from({ length: 1500 }, (_, i) => `word${i}`).join(' ')
expect(wordDiff(long, `${long} more`)).toBeNull()
})
})
@@ -0,0 +1,487 @@
import type {
ComicMetadataFields,
MetadataCandidate,
MetadataProviderKey,
MetadataSeriesMembership,
MetadataSource,
ProviderIds,
} from '@bookorbit/types'
import { toDisplayCoverUrl } from './metadata-fetch'
import { formatCommunityRatingValue } from './community-rating'
export type ComicDiffFieldKey =
| 'comicIssueNumber'
| 'comicVolumeName'
| 'comicPencillers'
| 'comicInkers'
| 'comicColorists'
| 'comicLetterers'
| 'comicCoverArtists'
| 'comicCharacters'
| 'comicTeams'
| 'comicLocations'
| 'comicStoryArcs'
export type ProviderIdPatchField =
| 'googleBooksId'
| 'goodreadsId'
| 'amazonId'
| 'hardcoverId'
| 'openLibraryId'
| 'itunesId'
| 'audibleId'
| 'librofmId'
| 'koboId'
| 'comicvineId'
| 'ranobedbId'
| 'lubimyczytacId'
| 'aladinId'
export type DiffFieldKey =
| 'title'
| 'subtitle'
| 'authors'
| 'description'
| 'publisher'
| 'publishedDate'
| 'publishedYear'
| 'language'
| 'pageCount'
| 'communityRating'
| 'seriesName'
| 'seriesIndex'
| 'isbn13'
| 'isbn10'
| 'genres'
| 'narrators'
| 'durationSeconds'
| 'abridged'
| 'coverUrl'
| 'hardcoverEditionId'
| ProviderIdPatchField
| 'sourceUrl'
| ComicDiffFieldKey
const FIELD_LABEL_PREFIX = 'book.detail.editMetadata.diff.fields'
export const FIELD_DEFS: { key: DiffFieldKey; labelKey: string }[] = (
[
'coverUrl',
'title',
'subtitle',
'authors',
'description',
'publisher',
'publishedDate',
'language',
'pageCount',
'communityRating',
'seriesName',
'seriesIndex',
'isbn13',
'isbn10',
'genres',
'narrators',
'durationSeconds',
'abridged',
'hardcoverEditionId',
] as const
).map((key) => ({ key, labelKey: `${FIELD_LABEL_PREFIX}.${key}` }))
export interface ComicFieldDef {
key: ComicDiffFieldKey
labelKey: string
comicKey: keyof ComicMetadataFields
}
export const COMIC_FIELD_DEFS: ComicFieldDef[] = (
[
['comicIssueNumber', 'issueNumber'],
['comicVolumeName', 'volumeName'],
['comicPencillers', 'pencillers'],
['comicInkers', 'inkers'],
['comicColorists', 'colorists'],
['comicLetterers', 'letterers'],
['comicCoverArtists', 'coverArtists'],
['comicCharacters', 'characters'],
['comicTeams', 'teams'],
['comicLocations', 'locations'],
['comicStoryArcs', 'storyArcs'],
] as const
).map(([key, comicKey]) => ({ key, labelKey: `${FIELD_LABEL_PREFIX}.${key}`, comicKey }))
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>
export function isComicDiffFieldKey(key: DiffFieldKey): key is ComicDiffFieldKey {
return key in COMIC_KEY_MAP
}
export const PROVIDER_ID_FIELD: Record<MetadataProviderKey, ProviderIdPatchField | undefined> = {
google: 'googleBooksId',
goodreads: 'goodreadsId',
amazon: 'amazonId',
hardcover: 'hardcoverId',
openLibrary: 'openLibraryId',
itunes: 'itunesId',
audible: 'audibleId',
audnexus: 'audibleId',
librofm: 'librofmId',
comicvine: 'comicvineId',
ranobedb: 'ranobedbId',
kobo: 'koboId',
lubimyczytac: 'lubimyczytacId',
aladin: 'aladinId',
}
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)
}
/** The i18n key naming each provider's id row. AudNexus stores an Audible id. */
export function providerIdLabelKey(provider: MetadataProviderKey): string {
return `book.detail.editMetadata.diff.providerIds.${provider === 'audnexus' ? 'audible' : provider}`
}
/** The id the book already holds for a provider. AudNexus answers with an Audible ASIN. */
export function existingProviderId(ids: ProviderIds | undefined, provider: MetadataProviderKey): string {
return ids?.[provider] ?? (provider === 'audnexus' ? ids?.audible : '') ?? ''
}
/** Fields whose value is a list: compared as sets and shown as chips. */
export const LIST_FIELD_KEYS = new Set<DiffFieldKey>([
'authors',
'genres',
'narrators',
'comicPencillers',
'comicInkers',
'comicColorists',
'comicLetterers',
'comicCoverArtists',
'comicCharacters',
'comicTeams',
'comicLocations',
'comicStoryArcs',
])
export function mergeGenreLists(existing: readonly string[], incoming: readonly string[]): string[] {
const merged: string[] = []
const seen = new Set<string>()
for (const raw of [...existing, ...incoming]) {
const genre = raw.trim()
const token = genre.toLowerCase()
if (!genre || seen.has(token)) continue
seen.add(token)
merged.push(genre)
}
return merged
}
export function normalizeSeriesMemberships(values: readonly MetadataSeriesMembership[] | undefined): MetadataSeriesMembership[] {
if (!values?.length) return []
const seen = new Set<string>()
const out: MetadataSeriesMembership[] = []
for (const value of values) {
const seriesName = value.seriesName.trim()
const key = seriesName.toLowerCase()
if (!seriesName || seen.has(key)) continue
seen.add(key)
out.push({
seriesName,
seriesIndex: value.seriesIndex ?? null,
})
}
return out
}
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 toDisplayCoverUrl(candidate.coverUrl)
if (key === 'authors') return (candidate.authors ?? []).join(', ')
if (key === 'genres') return (candidate.genres ?? []).join(', ')
if (key === 'narrators') return (candidate.narrators ?? []).join(', ')
if (key === 'communityRating') return formatCommunityRatingValue(candidate.communityRating, candidate.communityRatingCount)
if (key === 'publishedDate') return candidate.publishedDate ?? (candidate.publishedYear != null ? String(candidate.publishedYear) : '')
const val = candidate[key as keyof MetadataCandidate]
return val != null ? String(val) : ''
}
/** A list field's items on a candidate. */
export function getCandidateList(candidate: MetadataCandidate, key: DiffFieldKey): string[] {
if (isComicDiffFieldKey(key)) {
const val = candidate.comicMetadata?.[COMIC_KEY_MAP[key]]
return Array.isArray(val) ? val : []
}
if (key === 'authors') return candidate.authors ?? []
if (key === 'genres') return candidate.genres ?? []
if (key === 'narrators') return candidate.narrators ?? []
return []
}
/** A list field's items on the book. The book carries no comic metadata here, so those read as empty. */
export function getSourceList(current: MetadataSource, key: DiffFieldKey): string[] {
if (key === 'authors') return current.authors
if (key === 'genres') return current.genres
if (key === 'narrators') return current.narrators ?? []
return []
}
/** The book's value for a field, as the same string `getCandidateValueFrom` gives a candidate. */
export function getSourceValue(current: MetadataSource, key: DiffFieldKey, provider: MetadataProviderKey, currentCoverUrl = ''): string {
if (isComicDiffFieldKey(key)) return ''
if (LIST_FIELD_KEYS.has(key)) return getSourceList(current, key).join(', ')
if (key === 'coverUrl') return currentCoverUrl
if (key === 'communityRating') {
const existing = current.communityRatings?.find((r) => r.provider === provider)
return existing ? formatCommunityRatingValue(existing.rating, existing.ratingCount) : ''
}
if (key === 'publishedDate') return current.publishedDate ?? (current.publishedYear != null ? String(current.publishedYear) : '')
const val = current[key as keyof MetadataSource]
return val != null ? String(val) : ''
}
export type DiffKind = 'fill' | 'change' | 'minor' | 'same' | 'missing'
/** Why a difference is not worth a decision. Each has a translated label under `ledger.minor`. */
export type MinorReason = 'reordered' | 'formatting' | 'punctuation' | 'capitalization' | 'yearOnly' | 'addsDay' | 'languageCode' | 'ratingCount'
export interface DiffClass {
kind: DiffKind
reason?: MinorReason
}
const SAME: DiffClass = { kind: 'same' }
const CHANGE: DiffClass = { kind: 'change' }
const minor = (reason: MinorReason): DiffClass => ({ kind: 'minor', reason })
const ENTITIES: Record<string, string> = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
rsquo: '\u2019',
lsquo: '\u2018',
ldquo: '\u201c',
rdquo: '\u201d',
hellip: '\u2026',
mdash: '\u2014',
ndash: '\u2013',
}
/** Description HTML as readable text, without a DOM so it also runs where there is none. */
export function descriptionPlainText(html: string | null | undefined): string {
if (!html) return ''
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|h\d|blockquote)>/gi, '\n\n')
.replace(/<li[^>]*>/gi, '\u2022 ')
.replace(/<[^>]*>/g, '')
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
.replace(/&#x([0-9a-f]+);/gi, (_, code: string) => String.fromCodePoint(parseInt(code, 16)))
.replace(/&([a-z]+);/gi, (match, name: string) => ENTITIES[name.toLowerCase()] ?? match)
.replace(/[ \t\u00a0]+/g, ' ')
.replace(/ *\n */g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
/** Letters and digits only, lowercased and without accents, for "the same words" comparisons. */
export function normalizeForMatch(value: string): string {
return value
.normalize('NFKD')
.replace(/\p{M}+/gu, '')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim()
}
export function classifyText(current: string, incoming: string): DiffClass {
const a = current.trim()
const b = incoming.trim()
if (!b) return { kind: 'missing' }
if (!a) return { kind: 'fill' }
if (a === b) return SAME
if (a.toLowerCase() === b.toLowerCase()) return minor('capitalization')
if (normalizeForMatch(a) === normalizeForMatch(b)) return minor('punctuation')
return CHANGE
}
export function classifyList(current: readonly string[], incoming: readonly string[]): DiffClass {
const a = current.map((v) => v.trim()).filter(Boolean)
const b = incoming.map((v) => v.trim()).filter(Boolean)
if (!b.length) return { kind: 'missing' }
if (!a.length) return { kind: 'fill' }
const left = a.map(normalizeForMatch)
const right = b.map(normalizeForMatch)
const leftSet = new Set(left)
const rightSet = new Set(right)
const sameItems = leftSet.size === rightSet.size && [...leftSet].every((v) => rightSet.has(v))
if (!sameItems) return CHANGE
if (a.join('\u0000') === b.join('\u0000')) return SAME
return left.join('\u0000') === right.join('\u0000') ? minor('capitalization') : minor('reordered')
}
export function classifyDescription(current: string | null | undefined, incoming: string | null | undefined): DiffClass {
const a = descriptionPlainText(current)
const b = descriptionPlainText(incoming)
if (!b) return { kind: 'missing' }
if (!a) return { kind: 'fill' }
if (a === b) return (current ?? '').trim() === (incoming ?? '').trim() ? SAME : minor('formatting')
if (normalizeForMatch(a) === normalizeForMatch(b)) return minor('punctuation')
return CHANGE
}
export function classifyDate(current: string, incoming: string): DiffClass {
if (!incoming) return { kind: 'missing' }
if (!current) return { kind: 'fill' }
if (current === incoming) return SAME
if (current.slice(0, 4) === incoming.slice(0, 4)) {
if (/^\d{4}$/.test(incoming)) return minor('yearOnly')
if (/^\d{4}$/.test(current)) return minor('addsDay')
}
return CHANGE
}
const languageNames = (() => {
try {
return new Intl.DisplayNames(['en'], { type: 'language', fallback: 'none' })
} catch {
return null
}
})()
/** A language as one comparable token, so "en", "eng" and "English" read as the same language. */
export function languageToken(value: string): string {
const trimmed = value.trim()
if (/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i.test(trimmed)) {
try {
const name = languageNames?.of(trimmed)
if (name) return name.toLowerCase()
} catch {
// not a language code after all
}
}
return trimmed.toLowerCase()
}
export function classifyLanguage(current: string, incoming: string): DiffClass {
if (!incoming.trim()) return { kind: 'missing' }
if (!current.trim()) return { kind: 'fill' }
if (current.trim() === incoming.trim()) return SAME
return languageToken(current) === languageToken(incoming) ? minor('languageCode') : CHANGE
}
export function classifyNumber(current: number | string | null | undefined, incoming: number | string | null | undefined): DiffClass {
const empty = (v: unknown) => v == null || v === ''
if (empty(incoming)) return { kind: 'missing' }
if (empty(current)) return { kind: 'fill' }
const a = Number(current)
const b = Number(incoming)
if (Number.isFinite(a) && Number.isFinite(b)) return a === b ? SAME : CHANGE
return classifyText(String(current), String(incoming))
}
export function classifyIdentifier(current: string, incoming: string): DiffClass {
if (!incoming.trim()) return { kind: 'missing' }
if (!current.trim()) return { kind: 'fill' }
if (current.trim() === incoming.trim()) return SAME
const bare = (v: string) => v.replace(/[^0-9a-z]/gi, '').toLowerCase()
return bare(current) === bare(incoming) ? minor('formatting') : CHANGE
}
export function classifyRating(
current: { rating: number; ratingCount?: number | null } | null | undefined,
incoming: { rating: number; ratingCount?: number | null } | null | undefined,
): DiffClass {
if (!incoming || !Number.isFinite(incoming.rating)) return { kind: 'missing' }
if (!current || !Number.isFinite(current.rating)) return { kind: 'fill' }
const sameCount = (current.ratingCount ?? null) === (incoming.ratingCount ?? null)
if (Math.abs(current.rating - incoming.rating) < 0.05) return sameCount && current.rating === incoming.rating ? SAME : minor('ratingCount')
return CHANGE
}
const TEXT_KEYS = new Set<DiffFieldKey>(['title', 'subtitle', 'publisher', 'seriesName', 'comicIssueNumber', 'comicVolumeName'])
const IDENTIFIER_KEYS = new Set<DiffFieldKey>(['isbn13', 'isbn10', 'hardcoverEditionId'])
/** How a candidate's value for one field relates to the book's. Provider id rows go through `classifyIdentifier`. */
export function classifyField(key: DiffFieldKey, current: MetadataSource, candidate: MetadataCandidate): DiffClass {
if (LIST_FIELD_KEYS.has(key)) return classifyList(getSourceList(current, key), getCandidateList(candidate, key))
if (TEXT_KEYS.has(key)) return classifyText(getSourceValue(current, key, candidate.provider), getCandidateValueFrom(candidate, key))
if (IDENTIFIER_KEYS.has(key)) return classifyIdentifier(getSourceValue(current, key, candidate.provider), getCandidateValueFrom(candidate, key))
switch (key) {
case 'description':
return classifyDescription(current.description, candidate.description)
case 'publishedDate':
return classifyDate(getSourceValue(current, key, candidate.provider), getCandidateValueFrom(candidate, key))
case 'language':
return classifyLanguage(current.language ?? '', candidate.language ?? '')
case 'pageCount':
return classifyNumber(current.pageCount, candidate.pageCount)
case 'durationSeconds':
return classifyNumber(current.durationSeconds, candidate.durationSeconds)
case 'seriesIndex':
return classifyNumber(current.seriesIndex, candidate.seriesIndex)
case 'abridged':
return candidate.abridged == null
? { kind: 'missing' }
: current.abridged == null
? { kind: 'fill' }
: current.abridged === candidate.abridged
? SAME
: CHANGE
case 'communityRating': {
const existing = current.communityRatings?.find((r) => r.provider === candidate.provider)
const offered =
candidate.communityRating != null ? { rating: candidate.communityRating, ratingCount: candidate.communityRatingCount ?? null } : null
return classifyRating(existing, offered)
}
default:
return classifyText(getSourceValue(current, key, candidate.provider), getCandidateValueFrom(candidate, key))
}
}
/** Whether a difference is worth a decision: it fills an empty field or replaces a value. */
export function isActionable(kind: DiffKind): boolean {
return kind === 'fill' || kind === 'change' || kind === 'minor'
}
export interface DifferenceSummary {
/** Empty fields on the book the candidate would fill. */
fills: number
/** Fields where the candidate has a different value. Minor differences are not counted. */
changes: number
}
/** What a candidate offers the book at a glance, before it is opened. */
export function summarizeDifferences(current: MetadataSource, candidate: MetadataCandidate, providerIds?: ProviderIds): DifferenceSummary {
const summary: DifferenceSummary = { fills: 0, changes: 0 }
const count = (kind: DiffKind) => {
if (kind === 'fill') summary.fills += 1
else if (kind === 'change') summary.changes += 1
}
for (const def of FIELD_DEFS) {
if (def.key === 'coverUrl') continue
count(classifyField(def.key, current, candidate).kind)
}
if (candidate.comicMetadata) {
for (const def of COMIC_FIELD_DEFS) count(classifyField(def.key, current, candidate).kind)
}
if (PROVIDER_ID_FIELD[candidate.provider] && candidate.providerId) {
count(classifyIdentifier(existingProviderId(providerIds, candidate.provider), candidate.providerId).kind)
}
return summary
}
@@ -0,0 +1,154 @@
import type { MetadataCandidate, MetadataProviderKey, ProviderIds, SeriesIndex } from '@bookorbit/types'
import { existingProviderId, normalizeForMatch } from './metadata-diff-fields'
export type MatchTier = 'strong' | 'possible' | 'weak'
/** What a search result is matched against: the book, or a Book Dock file. */
export interface MatchSubject {
title: string | null
authors: readonly string[]
isbn13: string | null
isbn10: string | null
seriesName: string | null
seriesIndex: SeriesIndex | null
providerIds?: ProviderIds
}
export interface MatchAssessment {
tier: MatchTier
/** Orders results within a tier; not shown. */
score: number
/** The book already holds this record's id for the provider. */
linked: boolean
isbnMatch: boolean
titleMatch: 'same' | 'longer' | 'different'
authorMatch: boolean
seriesMatch: boolean
/** How many of the fields that matter the record fills. */
filledFields: number
/** Too few fields to be worth much even when it is the right book. */
sparse: boolean
}
const SPARSE_AT_MOST = 4
/** The title without an edition note in parentheses or a trailing "by Author", which providers append. */
function titleCore(title: string): string {
return normalizeForMatch(title.replace(/\s*\([^)]*\)\s*/g, ' ').replace(/\s+by\s+[^:]+$/i, ''))
}
function lastName(name: string): string {
const parts = normalizeForMatch(name).split(' ')
return parts[parts.length - 1] ?? ''
}
const bareIsbn = (value: string | null | undefined) => (value ?? '').replace(/[^0-9x]/gi, '').toUpperCase()
function filledFields(candidate: MetadataCandidate): number {
const values = [
candidate.title ?? candidate.displayTitle,
candidate.subtitle,
candidate.authors?.length,
candidate.description,
candidate.publisher,
candidate.publishedDate ?? candidate.publishedYear,
candidate.language,
candidate.pageCount,
candidate.seriesName,
candidate.isbn13 ?? candidate.isbn10,
candidate.genres?.length,
candidate.communityRating,
candidate.coverUrl,
]
return values.filter((value) => value !== undefined && value !== null && value !== '' && value !== 0).length
}
/**
* How likely a search result is to be this book, from what the page already knows: the provider
* ids the book holds, its ISBNs, title, authors and series. A record the book is already linked to,
* or one sharing its ISBN, is a strong match; the same title and author is another edition; the rest
* are usually other books that share words with the title.
*/
export function assessMatch(candidate: MetadataCandidate, subject: MatchSubject): MatchAssessment {
const linkedId = existingProviderId(subject.providerIds, candidate.provider)
const linked = !!linkedId && !!candidate.providerId && linkedId === candidate.providerId
const isbns = new Set([bareIsbn(subject.isbn13), bareIsbn(subject.isbn10)].filter(Boolean))
const isbnMatch = [bareIsbn(candidate.isbn13), bareIsbn(candidate.isbn10)].some((isbn) => isbn && isbns.has(isbn))
const bookTitle = titleCore(subject.title ?? '')
const resultTitle = titleCore(candidate.displayTitle ?? candidate.title ?? '')
const titleMatch: MatchAssessment['titleMatch'] =
bookTitle && resultTitle === bookTitle
? 'same'
: bookTitle && resultTitle && (resultTitle.startsWith(bookTitle) || bookTitle.startsWith(resultTitle))
? 'longer'
: 'different'
const bookAuthors = subject.authors.map(normalizeForMatch).filter(Boolean)
const bookLastNames = new Set(subject.authors.map(lastName).filter(Boolean))
const authorMatch = (candidate.authors ?? []).some(
(author) => bookAuthors.includes(normalizeForMatch(author)) || bookLastNames.has(lastName(author)),
)
const seriesMatch =
!!candidate.seriesName &&
!!subject.seriesName &&
normalizeForMatch(candidate.seriesName) === normalizeForMatch(subject.seriesName) &&
String(candidate.seriesIndex ?? '') === String(subject.seriesIndex ?? '')
const filled = filledFields(candidate)
const tier: MatchTier = linked || isbnMatch ? 'strong' : titleMatch === 'same' && authorMatch ? 'possible' : 'weak'
const score =
(linked ? 60 : 0) +
(isbnMatch ? 45 : 0) +
(titleMatch === 'same' ? 25 : titleMatch === 'longer' ? 6 : 0) +
(authorMatch ? 20 : 0) +
(seriesMatch ? 10 : 0) +
filled * 0.6
return { tier, score, linked, isbnMatch, titleMatch, authorMatch, seriesMatch, filledFields: filled, sparse: filled <= SPARSE_AT_MOST }
}
export interface RankedMatch {
candidate: MetadataCandidate
assessment: MatchAssessment
}
export interface MatchGroups {
strong: RankedMatch[]
possible: RankedMatch[]
weak: RankedMatch[]
}
const TIER_ORDER: Record<MatchTier, number> = { strong: 0, possible: 1, weak: 2 }
/** Best first: by tier, then score, then the provider order, then arrival. */
export function rankMatches(
candidates: readonly MetadataCandidate[],
subject: MatchSubject,
providerOrder: readonly MetadataProviderKey[] = [],
): RankedMatch[] {
const order = new Map(providerOrder.map((provider, index) => [provider, index]))
return candidates
.map((candidate, index) => ({ candidate, assessment: assessMatch(candidate, subject), index }))
.sort(
(a, b) =>
TIER_ORDER[a.assessment.tier] - TIER_ORDER[b.assessment.tier] ||
b.assessment.score - a.assessment.score ||
(order.get(a.candidate.provider) ?? Number.MAX_SAFE_INTEGER) - (order.get(b.candidate.provider) ?? Number.MAX_SAFE_INTEGER) ||
a.index - b.index,
)
.map(({ candidate, assessment }) => ({ candidate, assessment }))
}
export function groupMatches(ranked: readonly RankedMatch[]): MatchGroups {
const groups: MatchGroups = { strong: [], possible: [], weak: [] }
for (const match of ranked) groups[match.assessment.tier].push(match)
return groups
}
/** One key per result, stable across re-renders and re-searches, so a pick can name the result it came from. */
export function candidateKey(candidate: Pick<MetadataCandidate, 'provider' | 'providerId'>): string {
return `${candidate.provider}:${candidate.providerId ?? ''}`
}
+72
View File
@@ -0,0 +1,72 @@
export type WordDiffKind = 'same' | 'removed' | 'added'
export interface WordDiffPart {
kind: WordDiffKind
text: string
}
export interface WordDiff {
/** The old text, with the words the new one drops marked `removed`. */
before: WordDiffPart[]
/** The new text, with the words it adds marked `added`. */
after: WordDiffPart[]
/** Shared words over the longer side's word count, from 0 to 1. */
similarity: number
}
/** Past this many table cells a diff costs more than it tells; two long descriptions are shown plain. */
const MAX_CELLS = 1_600_000
const TOKEN = /\S+|\s+/g
const isWord = (token: string) => /\S/.test(token)
function push(parts: WordDiffPart[], kind: WordDiffKind, text: string) {
const last = parts[parts.length - 1]
if (last && last.kind === kind) last.text += text
else parts.push({ kind, text })
}
/**
* A word-level diff by longest common subsequence. Whitespace always reads as unchanged, so a
* highlight never starts or ends on a space. Returns null when the texts are too long to compare.
*/
export function wordDiff(before: string, after: string): WordDiff | null {
const a = before.match(TOKEN) ?? []
const b = after.match(TOKEN) ?? []
const n = a.length
const m = b.length
if (n * m > MAX_CELLS) return null
const table = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1))
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
table[i]![j] = a[i] === b[j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!)
}
}
const left: WordDiffPart[] = []
const right: WordDiffPart[] = []
let shared = 0
let i = 0
let j = 0
while (i < n && j < m) {
if (a[i] === b[j]) {
push(left, 'same', a[i]!)
push(right, 'same', b[j]!)
if (isWord(a[i]!)) shared += 1
i += 1
j += 1
} else if (table[i + 1]![j]! >= table[i]![j + 1]!) {
push(left, isWord(a[i]!) ? 'removed' : 'same', a[i]!)
i += 1
} else {
push(right, isWord(b[j]!) ? 'added' : 'same', b[j]!)
j += 1
}
}
for (; i < n; i++) push(left, isWord(a[i]!) ? 'removed' : 'same', a[i]!)
for (; j < m; j++) push(right, isWord(b[j]!) ? 'added' : 'same', b[j]!)
const words = Math.max(a.filter(isWord).length, b.filter(isWord).length)
return { before: left, after: right, similarity: words ? shared / words : 1 }
}
+182
View File
@@ -5646,6 +5646,188 @@
"searchInterruptedHint": "Try again, or narrow the search scope.",
"searchPrompt": "Search above, then click a result to start comparing metadata."
},
"match": {
"sheetDescription": "Search metadata providers and choose what to copy into this book.",
"mediumAudiobook": "Audiobook",
"mediumEbook": "Ebook",
"query": {
"label": "Metadata search",
"title": "Title",
"author": "Author",
"isbn": "ISBN",
"edited": "Query edited",
"useBookDetails": "Use book details"
},
"sources": {
"heading": "Sources",
"allEnabledHint": "{count, plural, one {# source. Best for a manual search.} other {# sources. Best for a manual search.}}",
"fieldRulesHint": "{count, plural, one {# source, the one automatic fetch uses.} other {# sources, the ones automatic fetch uses.}}",
"outsideRules": "{providers} {count, plural, one {is enabled but not in Field Rules, so automatic fetch skips it.} other {are enabled but not in Field Rules, so automatic fetch skips them.}}",
"manualOnly": "{providers}: manual search only, not used by automatic fetch.",
"count": "{provider}: {count, plural, =0 {no results} one {# result} other {# results}}",
"searching": "{provider}: searching",
"stopped": "{provider}: stopped before finishing"
},
"results": {
"label": "Matches",
"best": "Best matches",
"otherEditions": "Other editions",
"otherBooks": "Probably different books",
"stillSearching": "Still searching",
"linked": "Linked",
"linkedHint": "This record is already linked to the book",
"isbn": "ISBN match",
"fills": "+{count} empty",
"fillsHint": "{count, plural, one {Fills # empty field} other {Fills # empty fields}}",
"differ": "{count} differ",
"differHint": "{count, plural, one {# field differs from the book} other {# fields differ from the book}}",
"inSync": "In sync",
"inSyncHint": "Nothing here would change the book",
"differentAuthor": "Different author",
"titleDiffers": "Title differs",
"sparse": "Sparse",
"sparseHint": "{count, plural, one {Only # field filled} other {Only # fields filled}}",
"staged": "{count, plural, one {# change staged from this result} other {# changes staged from this result}}",
"noMatches": "No matches for this query",
"noMatchesHint": "{count, plural, one {The source answered with nothing.} other {# sources answered with nothing.}} Loosen the search and try again.",
"searchTitleOnly": "Search title only",
"prompt": "Enter a title or ISBN to search.",
"mayBeMissing": "Results from it may be missing.",
"retry": "Retry {provider}",
"pages": "{count, plural, one {# page} other {# pages}}",
"fetched": "Fetched"
},
"tier": {
"strong": "Strong match",
"possible": "Possible match",
"weak": "Unlikely match"
},
"compare": {
"label": "Compare",
"backToMatches": "Back to matches",
"previous": "Previous result",
"next": "Next result",
"position": "{index} of {total}",
"viewOn": "View on {provider}",
"linkedToBook": "Linked to this book",
"sameTitleAuthor": "Same title and author",
"streaming": "Showing the best match so far. Still waiting on {providers}.",
"rows": "Rows",
"differences": "Differences",
"allFields": "All fields",
"fillEmpty": "{count, plural, =0 {No empty fields} one {Fill # empty} other {Fill # empty}}",
"takeAll": "{count, plural, =0 {Nothing to use} one {Use #} other {Use all #}}",
"more": "More actions",
"clearAll": "Clear all picks",
"clearAllHint": "Back to the book's current values.",
"field": "Field",
"pickTitle": "Pick a match to compare",
"pickHint": "Choose a result. Its fields line up against the book so you can keep or replace each one.",
"noneTitle": "Nothing to compare yet",
"noneHint": "When a search finds matches, the best one opens here next to what {title} has today.",
"allMatch": "Every field matches the book. Switch to All fields to see them.",
"toReview": "{count, plural, =0 {Nothing to review} one {# to review} other {# to review}}",
"stagedInSection": "{count} staged"
},
"sections": {
"identity": "Identity",
"description": "Description",
"publication": "Publication",
"series": "Series",
"genres": "Genres",
"comics": "Comic credits",
"identifiers": "Identifiers",
"ratings": "Community rating"
},
"kind": {
"fill": "Fills empty",
"change": "Differs",
"same": "Same",
"missing": "Not provided"
},
"minor": {
"reordered": "Reordered",
"formatting": "Formatting only",
"punctuation": "Punctuation",
"capitalization": "Capitalization",
"yearOnly": "Year only",
"addsDay": "Adds the day",
"languageCode": "Same language",
"ratingCount": "Count only"
},
"decision": {
"keep": "Keep",
"use": "Use",
"merge": "Merge",
"replace": "Replace",
"alreadyMatches": "Already matches",
"nothingToApply": "Nothing to apply",
"lockedHint": "Unlock it in the editor to change it.",
"usingFrom": "Using the value from",
"switchTo": "Switch to {provider}"
},
"value": {
"empty": "Empty",
"notProvided": "Not provided",
"showAll": "Show all {count} words",
"otherValues": "{count, plural, one {# other value} other {# other values}}",
"otherValuesHeading": "{field} from other results",
"otherValuesHint": "Choosing one stages it for this field",
"sameAsCurrent": "Same as current",
"useThis": "Use this",
"using": "Using"
},
"providerRating": "{provider} rating",
"cover": {
"slotAudio": "Audiobook slot, square",
"slotBook": "Book slot, portrait",
"current": "Current",
"staged": "Staged",
"size": "{width} × {height}",
"fits": "Fits the slot",
"portraitInSquare": "Portrait: letterboxed",
"squareInPortrait": "Square: letterboxed",
"broken": "Looks broken",
"unknown": "Shape unknown",
"fitShort": "Fits",
"portraitShort": "Portrait",
"squareShort": "Square",
"brokenShort": "Broken",
"noCover": "No cover in this result",
"noCurrent": "No cover yet",
"strip": "Covers from matching results",
"stripMeta": "{count, plural, one {# cover} other {# covers}}, fitting first",
"choose": "Choose a cover",
"preview": "Preview the covers full screen",
"previewTitle": "Covers side by side",
"previewDescription": "The current cover next to the one that would replace it.",
"choice": "{provider} cover",
"choiceSized": "{provider} cover, {width} by {height}",
"using": "Using"
},
"footer": {
"changes": "{count, plural, one {# change} other {# changes}}",
"from": "from",
"nothingStaged": "Nothing staged. Choose Use on a field or pick a cover.",
"nothingStagedShort": "Nothing staged",
"formHint": "Fills the edit form. Save there to keep it.",
"fileHint": "Saves to this file's metadata.",
"apply": "{count, plural, =0 {Apply} one {Apply # change} other {Apply # changes}}",
"stagedHeading": "Staged for the form",
"clearAll": "Clear all",
"undo": "Undo {field}",
"merged": "merged",
"live": "{count, plural, =0 {No changes staged} one {# change staged} other {# changes staged}}"
},
"keyboard": {
"title": "Keyboard shortcuts",
"results": "Previous or next result",
"fields": "Move between fields",
"toggle": "Keep or use the field",
"search": "Edit the search",
"close": "Close menus"
}
},
"writeAndRename": "Write to File & Rename",
"writeAndRenameShort": "Write & Rename",
"identityCard": "Identity",