mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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 once.</p><p>Then—the letter & 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 ?? ''}`
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user