mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(authors): allow correcting metadata matches
Let users edit author facts, choose and pin a provider record, and keep later refreshes tied to that record. Preserve compatibility for older clients through additive API fields and a discoverable server feature. Keep server typechecking deterministic by breaking circular Drizzle inference and correcting the nullability mismatches it previously hid. Closes #1464
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
type WithMetadataCacheUsage,
|
||||
} from '@/features/metadata-cache/lib/metadata-cache'
|
||||
import type { AuthorBookSort, AuthorListSort, SortDirection } from '../types/author'
|
||||
import type { AuthorFactsPayload } from '../lib/author-facts'
|
||||
|
||||
export const MAX_AUTHOR_IMAGE_BYTES = 20 * 1024 * 1024
|
||||
|
||||
@@ -47,12 +48,28 @@ type ListAuthorBooksParams = {
|
||||
collapseSeries?: boolean
|
||||
}
|
||||
|
||||
export type UpdateAuthorPayload = {
|
||||
export type UpdateAuthorPayload = AuthorFactsPayload & {
|
||||
name?: string
|
||||
sortName?: string | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export type AuthorMetadataMatchPayload = {
|
||||
provider: AuthorMetadataProviderKey
|
||||
providerId: string
|
||||
}
|
||||
|
||||
/** Carries the status so the picker can tell "no such author" (404) from a provider outage (503). */
|
||||
export class AuthorMetadataMatchError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AuthorMetadataMatchError'
|
||||
}
|
||||
}
|
||||
|
||||
export type MergeAuthorsPayload = {
|
||||
targetAuthorId: number
|
||||
sourceAuthorIds: number[]
|
||||
@@ -342,6 +359,43 @@ export async function bulkRefreshAuthorsMetadata(
|
||||
return summary
|
||||
}
|
||||
|
||||
/** One provider's person by id; null when the id matches nobody. The cache marker comes back beside it. */
|
||||
export async function lookupAuthorMetadata(
|
||||
reference: AuthorMetadataMatchPayload,
|
||||
options: { cache?: MetadataCacheRequestPolicy; signal?: AbortSignal } = {},
|
||||
): Promise<{ candidate: AuthorMetadataCandidate; cache: MetadataCandidateCacheInfo | null } | null> {
|
||||
const query = toQuery({
|
||||
provider: reference.provider,
|
||||
id: reference.providerId,
|
||||
region: 'us',
|
||||
cache: options.cache === METADATA_CACHE_REFRESH ? METADATA_CACHE_REFRESH : undefined,
|
||||
})
|
||||
const res = await api(`/api/v1/authors/metadata/lookup${query}`, { signal: options.signal })
|
||||
if (!res.ok) throw new AuthorMetadataMatchError(res.status, 'Failed to look up author metadata')
|
||||
// A lookup that matched nobody answers 200 with an empty body.
|
||||
const text = await res.text()
|
||||
if (!text) return null
|
||||
const event = JSON.parse(text) as AuthorMetadataSearchCandidateEvent | null
|
||||
return event ? splitCandidateCache(event) : null
|
||||
}
|
||||
|
||||
/** Replaces the author's metadata with one provider's person and keeps refreshes on that id. */
|
||||
export async function pinAuthorMetadataMatch(authorId: number, payload: AuthorMetadataMatchPayload): Promise<AuthorMetadataRefreshResult> {
|
||||
const res = await api(`/api/v1/authors/${authorId}/metadata-match`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) throw new AuthorMetadataMatchError(res.status, 'Failed to use this author')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function unpinAuthorMetadataMatch(authorId: number): Promise<AuthorDetail> {
|
||||
const res = await api(`/api/v1/authors/${authorId}/metadata-match`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new AuthorMetadataMatchError(res.status, 'Failed to unpin the author match')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/** Always live; `metadataCache` lists providers that failed and were answered from saved results instead. */
|
||||
export async function refreshAuthorMetadata(authorId: number): Promise<AuthorMetadataRefreshResult> {
|
||||
const res = await api(`/api/v1/authors/${authorId}/enrichment/refresh`, {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ChipInput from '@/components/ui/ChipInput.vue'
|
||||
import type { AuthorFactsDraft, AuthorFactsField } from '../lib/author-facts'
|
||||
|
||||
const props = defineProps<{ invalid: AuthorFactsField[] }>()
|
||||
const draft = defineModel<AuthorFactsDraft>({ required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
const id = useId()
|
||||
|
||||
function isInvalid(field: AuthorFactsField): boolean {
|
||||
return props.invalid.includes(field)
|
||||
}
|
||||
|
||||
function field<K extends keyof AuthorFactsDraft>(key: K) {
|
||||
return computed<AuthorFactsDraft[K]>({
|
||||
get: () => draft.value[key],
|
||||
set: (value) => {
|
||||
draft.value = { ...draft.value, [key]: value }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const website = field('website')
|
||||
const born = field('born')
|
||||
const died = field('died')
|
||||
const genres = field('genres')
|
||||
const influences = field('influences')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs text-muted-foreground">
|
||||
{{ t('author.detail.edit.website') }}
|
||||
<input
|
||||
v-model="website"
|
||||
type="url"
|
||||
inputmode="url"
|
||||
autocomplete="off"
|
||||
class="mt-1 h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm"
|
||||
:class="isInvalid('website') ? 'border-destructive' : ''"
|
||||
:placeholder="t('author.detail.edit.websitePlaceholder')"
|
||||
:aria-invalid="isInvalid('website') || undefined"
|
||||
:aria-describedby="isInvalid('website') ? `${id}-website-error` : undefined"
|
||||
/>
|
||||
<span v-if="isInvalid('website')" :id="`${id}-website-error`" class="mt-1 block text-destructive">
|
||||
{{ t('author.detail.edit.websiteInvalid') }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="grid gap-2 md:grid-cols-2">
|
||||
<label class="text-xs text-muted-foreground">
|
||||
{{ t('author.detail.edit.born') }}
|
||||
<input
|
||||
v-model="born"
|
||||
autocomplete="off"
|
||||
class="mt-1 h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm"
|
||||
:class="isInvalid('born') ? 'border-destructive' : ''"
|
||||
:placeholder="t('author.detail.edit.lifeDatePlaceholder')"
|
||||
:aria-invalid="isInvalid('born') || undefined"
|
||||
:aria-describedby="isInvalid('born') ? `${id}-born-error` : `${id}-life-date-hint`"
|
||||
/>
|
||||
<span v-if="isInvalid('born')" :id="`${id}-born-error`" class="mt-1 block text-destructive">
|
||||
{{ t('author.detail.edit.lifeDateInvalid') }}
|
||||
</span>
|
||||
</label>
|
||||
<label class="text-xs text-muted-foreground">
|
||||
{{ t('author.detail.edit.died') }}
|
||||
<input
|
||||
v-model="died"
|
||||
autocomplete="off"
|
||||
class="mt-1 h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm"
|
||||
:class="isInvalid('died') ? 'border-destructive' : ''"
|
||||
:placeholder="t('author.detail.edit.lifeDatePlaceholder')"
|
||||
:aria-invalid="isInvalid('died') || undefined"
|
||||
:aria-describedby="isInvalid('died') ? `${id}-died-error` : `${id}-life-date-hint`"
|
||||
/>
|
||||
<span v-if="isInvalid('died')" :id="`${id}-died-error`" class="mt-1 block text-destructive">
|
||||
{{ t('author.detail.edit.lifeDateInvalid') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p :id="`${id}-life-date-hint`" class="text-[11px] text-muted-foreground">{{ t('author.detail.edit.lifeDateHint') }}</p>
|
||||
|
||||
<div class="grid gap-2 md:grid-cols-2">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
<label :for="`${id}-genres`">{{ t('author.detail.edit.genres') }}</label>
|
||||
<ChipInput v-model="genres" :input-id="`${id}-genres`" class="mt-1" />
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
<label :for="`${id}-influences`">{{ t('author.detail.edit.influences') }}</label>
|
||||
<ChipInput v-model="influences" :input-id="`${id}-influences`" class="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -20,6 +20,7 @@ const author: AuthorDetail = {
|
||||
influences: [],
|
||||
metadataProvider: null,
|
||||
metadataProviderId: null,
|
||||
metadataProviderPinned: false,
|
||||
}
|
||||
|
||||
describe('AuthorHeader', () => {
|
||||
@@ -80,3 +81,45 @@ describe('fresh metadata lookup', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('provider source and website', () => {
|
||||
it('links the matched provider page and marks a pinned match', () => {
|
||||
const wrapper = mount(AuthorHeader, {
|
||||
props: { author: { ...author, metadataProvider: 'goodreads', metadataProviderId: '2222', metadataProviderPinned: true } },
|
||||
})
|
||||
|
||||
const source = wrapper.get('[data-testid="author-source"]')
|
||||
expect(source.get('a').attributes('href')).toBe('https://www.goodreads.com/author/show/2222')
|
||||
expect(source.text()).toContain('Goodreads')
|
||||
expect(source.findAll('span').some((span) => span.classes().includes('text-primary'))).toBe(true)
|
||||
})
|
||||
|
||||
it('shows no pinned label for a match enrichment chose', () => {
|
||||
const wrapper = mount(AuthorHeader, {
|
||||
props: { author: { ...author, metadataProvider: 'audnexus', metadataProviderId: 'B001H6UH2C' } },
|
||||
})
|
||||
|
||||
const source = wrapper.get('[data-testid="author-source"]')
|
||||
expect(source.get('a').attributes('href')).toBe('https://www.audible.com/author/B001H6UH2C')
|
||||
expect(source.findAll('span').some((span) => span.classes().includes('text-primary'))).toBe(false)
|
||||
})
|
||||
|
||||
it('shows influences when they are the only author fact', () => {
|
||||
const wrapper = mount(AuthorHeader, { props: { author: { ...author, influences: ['Alistair MacLean'] } } })
|
||||
|
||||
expect(wrapper.text()).toContain('Alistair MacLean')
|
||||
})
|
||||
|
||||
it('renders a website that is not http(s) as text, never as a link', () => {
|
||||
const wrapper = mount(AuthorHeader, { props: { author: { ...author, website: 'javascript:alert(1)' } } })
|
||||
|
||||
expect(wrapper.text()).toContain('javascript:alert(1)')
|
||||
expect(wrapper.findAll('a').some((link) => link.attributes('href')?.startsWith('javascript:'))).toBe(false)
|
||||
})
|
||||
|
||||
it('links an http(s) website', () => {
|
||||
const wrapper = mount(AuthorHeader, { props: { author: { ...author, website: 'https://johnreid.example' } } })
|
||||
|
||||
expect(wrapper.findAll('a').some((link) => link.attributes('href') === 'https://johnreid.example')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,13 +3,15 @@ import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDate, formatNumber } from '@/i18n/formatters'
|
||||
import type { AuthorDetail, MetadataCandidateCacheInfo } from '@bookorbit/types'
|
||||
import { MoreHorizontal, Pencil, RefreshCcw, Trash2, UsersRound, X } from '@lucide/vue'
|
||||
import { MoreHorizontal, Pencil, RefreshCcw, Trash2, UserRoundSearch, UsersRound, X } from '@lucide/vue'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { toDisplayCoverUrl } from '@/features/book/lib/metadata-fetch'
|
||||
import MetadataCacheBadge from '@/features/metadata-cache/components/MetadataCacheBadge.vue'
|
||||
import MetadataCacheNotice from '@/features/metadata-cache/components/MetadataCacheNotice.vue'
|
||||
import SearchFreshButton from '@/features/metadata-cache/components/SearchFreshButton.vue'
|
||||
import type { MetadataCacheSummary } from '@/features/metadata-cache/lib/metadata-cache'
|
||||
import { isSafeWebsite, lifeDateLabel } from '../lib/author-facts'
|
||||
import { authorProviderLabel, authorProviderSourceUrl } from '../lib/author-metadata-match'
|
||||
|
||||
const props = defineProps<{
|
||||
author: AuthorDetail
|
||||
@@ -33,6 +35,7 @@ const emit = defineEmits<{
|
||||
merge: []
|
||||
refresh: []
|
||||
searchFresh: []
|
||||
findMatch: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
@@ -45,27 +48,19 @@ const initials = computed(() => {
|
||||
return `${parts[0]!.charAt(0)}${parts[1]!.charAt(0)}`.toUpperCase()
|
||||
})
|
||||
|
||||
// Goodreads supplies a full date for most authors and only a year for older
|
||||
// ones, so fall back to the year rather than showing nothing.
|
||||
function lifeDateLabel(date: string | null, year: number | null): string | null {
|
||||
if (date) {
|
||||
const parsed = new Date(date)
|
||||
// Birth and death are plain yyyy-MM-dd, which Date parses as UTC midnight.
|
||||
// Formatting that in a timezone behind UTC would render the previous day,
|
||||
// so read it back in UTC too.
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
return formatDate(parsed, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' })
|
||||
}
|
||||
}
|
||||
// A year is not a quantity: grouping would render 1929 as "1,929".
|
||||
return year !== null ? formatNumber(year, { useGrouping: false }) : null
|
||||
}
|
||||
|
||||
const bornLabel = computed(() => lifeDateLabel(props.author.birthDate, props.author.birthYear))
|
||||
const diedLabel = computed(() => lifeDateLabel(props.author.deathDate, props.author.deathYear))
|
||||
const genres = computed(() => props.author.genres ?? [])
|
||||
const influences = computed(() => props.author.influences ?? [])
|
||||
const hasFacts = computed(() => !!(bornLabel.value || diedLabel.value || props.author.website || genres.value.length))
|
||||
const websiteIsLink = computed(() => isSafeWebsite(props.author.website))
|
||||
const sourceLabel = computed(() => (props.author.metadataProvider ? authorProviderLabel(props.author.metadataProvider) : null))
|
||||
const sourceUrl = computed(() => {
|
||||
const { metadataProvider, metadataProviderId } = props.author
|
||||
return metadataProvider && metadataProviderId ? authorProviderSourceUrl(metadataProvider, metadataProviderId) : null
|
||||
})
|
||||
const hasFacts = computed(
|
||||
() => !!(bornLabel.value || diedLabel.value || props.author.website || genres.value.length || influences.value.length || sourceLabel.value),
|
||||
)
|
||||
|
||||
const resolvedBio = computed(() => {
|
||||
const local = props.author.description?.trim()
|
||||
@@ -92,11 +87,11 @@ const lastAddedLabel = computed(() => {
|
||||
return formatDate(date, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
})
|
||||
|
||||
const previewProviderLabel = computed(() => {
|
||||
if (!props.previewProvider) return ''
|
||||
if (props.previewProvider === 'audnexus') return 'Audnexus'
|
||||
return props.previewProvider
|
||||
})
|
||||
const previewProviderLabel = computed(() => (props.previewProvider ? authorProviderLabel(props.previewProvider) : ''))
|
||||
|
||||
function handleFindMatch() {
|
||||
emit('findMatch')
|
||||
}
|
||||
|
||||
const showMenu = computed(() => props.canUpdate || props.canMerge || props.canDelete)
|
||||
|
||||
@@ -166,6 +161,10 @@ watch(resolvedBio, () => {
|
||||
<RefreshCcw class="mr-2 h-4 w-4" :class="refreshing ? 'animate-spin' : ''" />
|
||||
{{ refreshing ? t('author.header.refreshing') : t('author.header.refreshMetadata') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="canUpdate" @click="handleFindMatch">
|
||||
<UserRoundSearch class="mr-2 h-4 w-4" />
|
||||
{{ t('author.header.findMatch') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator v-if="canDelete && (canUpdate || canMerge)" />
|
||||
<DropdownMenuItem v-if="canDelete" class="text-destructive focus:text-destructive" @click="emit('delete')">
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
@@ -217,9 +216,16 @@ watch(resolvedBio, () => {
|
||||
<div v-if="author.website" class="flex gap-1.5 min-w-0">
|
||||
<dt class="text-muted-foreground">{{ t('author.header.website') }}</dt>
|
||||
<dd class="min-w-0 truncate">
|
||||
<a :href="author.website" target="_blank" rel="noopener noreferrer" class="text-foreground underline underline-offset-2">
|
||||
<a
|
||||
v-if="websiteIsLink"
|
||||
:href="author.website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-foreground underline underline-offset-2"
|
||||
>
|
||||
{{ author.website }}
|
||||
</a>
|
||||
<span v-else class="text-foreground">{{ author.website }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="genres.length" class="flex gap-1.5 min-w-0">
|
||||
@@ -230,6 +236,18 @@ watch(resolvedBio, () => {
|
||||
<dt class="text-muted-foreground">{{ t('author.header.influences') }}</dt>
|
||||
<dd class="min-w-0 text-foreground">{{ influences.join(', ') }}</dd>
|
||||
</div>
|
||||
<div v-if="sourceLabel" class="flex gap-1.5 min-w-0" data-testid="author-source">
|
||||
<dt class="text-muted-foreground">{{ t('author.header.source') }}</dt>
|
||||
<dd class="flex min-w-0 items-center gap-1.5">
|
||||
<a v-if="sourceUrl" :href="sourceUrl" target="_blank" rel="noopener noreferrer" class="text-foreground underline underline-offset-2">
|
||||
{{ sourceLabel }}
|
||||
</a>
|
||||
<span v-else class="text-foreground">{{ sourceLabel }}</span>
|
||||
<span v-if="author.metadataProviderPinned" class="rounded bg-primary/10 px-1.5 text-[11px] font-medium text-primary">
|
||||
{{ t('author.header.pinned') }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mt-4 flex gap-3">
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChevronLeft, ExternalLink, Loader2, Pin } from '@lucide/vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { providerBadgeStyle, toDisplayCoverUrl, hideOnError } from '@/features/book/lib/metadata-fetch'
|
||||
import type { AuthorMatchEntry } from '../composables/useAuthorMatchPicker'
|
||||
import { isSafeWebsite, lifeDateLabel } from '../lib/author-facts'
|
||||
import { authorInitials } from '../lib/author-identity'
|
||||
import { authorProviderLabel, authorProviderSourceUrl } from '../lib/author-metadata-match'
|
||||
|
||||
const props = defineProps<{
|
||||
entry: AuthorMatchEntry | null
|
||||
loading: boolean
|
||||
current: boolean
|
||||
pinned: boolean
|
||||
busy: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ use: []; unpin: []; back: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const candidate = computed(() => props.entry?.candidate ?? null)
|
||||
const providerLabel = computed(() => (candidate.value ? authorProviderLabel(candidate.value.provider) : ''))
|
||||
const imageUrl = computed(() => toDisplayCoverUrl(candidate.value?.imageUrl))
|
||||
const initials = computed(() => authorInitials(candidate.value?.name ?? ''))
|
||||
const bornLabel = computed(() => lifeDateLabel(candidate.value?.birthDate, candidate.value?.birthYear))
|
||||
const diedLabel = computed(() => lifeDateLabel(candidate.value?.deathDate, candidate.value?.deathYear))
|
||||
const website = computed(() => (isSafeWebsite(candidate.value?.website) ? candidate.value!.website! : null))
|
||||
const genres = computed(() => candidate.value?.genres ?? [])
|
||||
const sourceUrl = computed(() => {
|
||||
const value = candidate.value
|
||||
if (!value) return null
|
||||
return isSafeWebsite(value.sourceUrl) ? value.sourceUrl : authorProviderSourceUrl(value.provider, value.providerId)
|
||||
})
|
||||
const showUnpin = computed(() => props.current && props.pinned)
|
||||
|
||||
function handleUse() {
|
||||
emit('use')
|
||||
}
|
||||
|
||||
function handleUnpin() {
|
||||
emit('unpin')
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
emit('back')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center border-b border-border px-3 py-2 md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-1 rounded-md px-2 text-sm text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@click="handleBack"
|
||||
>
|
||||
<ChevronLeft class="size-4" aria-hidden="true" />
|
||||
{{ t('author.match.detail.back') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="!candidate" class="m-auto max-w-xs p-6 text-center text-sm text-muted-foreground">{{ t('author.match.detail.empty') }}</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="relative block h-32 w-24 shrink-0 overflow-hidden rounded-lg bg-muted ring-1 ring-border">
|
||||
<img v-if="imageUrl" :src="imageUrl" alt="" class="size-full object-cover" @error="hideOnError" />
|
||||
<span v-else class="grid size-full place-items-center text-2xl font-semibold text-primary" aria-hidden="true">{{ initials }}</span>
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-lg font-semibold leading-snug [overflow-wrap:anywhere] text-foreground">{{ candidate.name }}</h3>
|
||||
<span
|
||||
class="mt-1 inline-flex h-[18px] items-center rounded-md px-1.5 text-[10.5px] font-bold"
|
||||
:style="providerBadgeStyle(candidate.provider)"
|
||||
>
|
||||
{{ providerLabel }}
|
||||
</span>
|
||||
<dl class="mt-3 space-y-1 text-sm">
|
||||
<div v-if="bornLabel" class="flex gap-1.5">
|
||||
<dt class="text-muted-foreground">{{ t('author.header.born') }}</dt>
|
||||
<dd class="text-foreground">{{ bornLabel }}</dd>
|
||||
</div>
|
||||
<div v-if="diedLabel" class="flex gap-1.5">
|
||||
<dt class="text-muted-foreground">{{ t('author.header.died') }}</dt>
|
||||
<dd class="text-foreground">{{ diedLabel }}</dd>
|
||||
</div>
|
||||
<div v-if="website" class="flex min-w-0 gap-1.5">
|
||||
<dt class="text-muted-foreground">{{ t('author.header.website') }}</dt>
|
||||
<dd class="min-w-0 truncate text-foreground">{{ website }}</dd>
|
||||
</div>
|
||||
<div v-if="genres.length" class="flex min-w-0 gap-1.5">
|
||||
<dt class="text-muted-foreground">{{ t('author.header.genres') }}</dt>
|
||||
<dd class="min-w-0 text-foreground">{{ genres.join(', ') }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<a
|
||||
v-if="sourceUrl"
|
||||
:href="sourceUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-3 inline-flex items-center gap-1 text-sm text-foreground underline underline-offset-2"
|
||||
>
|
||||
{{ t('author.match.detail.viewOn', { provider: providerLabel }) }}
|
||||
<ExternalLink class="size-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="mt-4 inline-flex items-center gap-1.5 text-sm text-muted-foreground" role="status">
|
||||
<Loader2 class="size-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
|
||||
{{ t('author.match.detail.loading') }}
|
||||
</p>
|
||||
<p v-if="candidate.description" class="mt-4 text-sm leading-6 whitespace-pre-line text-foreground">{{ candidate.description }}</p>
|
||||
<p v-else-if="!loading" class="mt-4 text-sm text-muted-foreground">{{ t('author.match.detail.noBio') }}</p>
|
||||
</div>
|
||||
|
||||
<footer class="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-border px-4 py-3">
|
||||
<p v-if="current" class="me-auto flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Pin v-if="pinned" class="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{{ pinned ? t('author.match.detail.pinnedHint') : t('author.match.detail.currentHint') }}
|
||||
</p>
|
||||
<Button v-if="showUnpin" variant="outline" :disabled="busy" @click="handleUnpin">{{ t('author.match.detail.unpin') }}</Button>
|
||||
<Button v-else :disabled="busy" @click="handleUse">
|
||||
<Loader2 v-if="busy" class="animate-spin motion-reduce:animate-none" aria-hidden="true" />
|
||||
{{ t('author.match.detail.use') }}
|
||||
</Button>
|
||||
</footer>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
import { providerBadgeStyle, toDisplayCoverUrl, hideOnError } from '@/features/book/lib/metadata-fetch'
|
||||
import MetadataCacheBadge from '@/features/metadata-cache/components/MetadataCacheBadge.vue'
|
||||
import type { AuthorMatchEntry } from '../composables/useAuthorMatchPicker'
|
||||
import { authorInitials } from '../lib/author-identity'
|
||||
import { authorProviderLabel, candidateLifeYears } from '../lib/author-metadata-match'
|
||||
|
||||
const props = defineProps<{
|
||||
entry: AuthorMatchEntry
|
||||
selected: boolean
|
||||
current: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ select: [AuthorMatchEntry] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const candidate = computed(() => props.entry.candidate)
|
||||
const imageUrl = computed(() => toDisplayCoverUrl(candidate.value.imageUrl))
|
||||
const initials = computed(() => authorInitials(candidate.value.name))
|
||||
const lifeLine = computed(() => {
|
||||
const { born, died } = candidateLifeYears(candidate.value)
|
||||
const year = (value: number) => formatNumber(value, { useGrouping: false })
|
||||
if (born !== null && died !== null) return t('author.match.results.lifeSpan', { born: year(born), died: year(died) })
|
||||
if (born !== null) return t('author.match.results.born', { year: year(born) })
|
||||
return null
|
||||
})
|
||||
const bio = computed(() => candidate.value.description?.trim() || null)
|
||||
|
||||
function handleSelect() {
|
||||
emit('select', props.entry)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="grid w-full grid-cols-[2.75rem_minmax(0,1fr)] 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 size-11 overflow-hidden rounded-md bg-muted ring-1 ring-border">
|
||||
<img v-if="imageUrl" :src="imageUrl" alt="" class="size-full object-cover" loading="lazy" @error="hideOnError" />
|
||||
<span v-else class="grid size-full place-items-center text-sm font-semibold text-primary" aria-hidden="true">{{ initials }}</span>
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-[13px] leading-snug font-semibold [overflow-wrap:anywhere] text-foreground">{{ candidate.name }}</span>
|
||||
<span v-if="lifeLine" class="mt-0.5 block text-[11.5px] leading-snug text-muted-foreground">{{ lifeLine }}</span>
|
||||
<span v-if="bio" class="mt-0.5 line-clamp-2 text-[11.5px] leading-snug text-muted-foreground">{{ bio }}</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)">
|
||||
{{ authorProviderLabel(candidate.provider) }}
|
||||
</span>
|
||||
<MetadataCacheBadge v-if="entry.cache" :info="entry.cache" />
|
||||
<span v-if="current" class="inline-flex h-[18px] items-center rounded-md bg-primary/15 px-1.5 text-[10.5px] font-semibold text-primary">
|
||||
{{ t('author.match.results.current') }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,285 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, toRef, useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Loader2, Search, X } from '@lucide/vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { AuthorDetail } from '@bookorbit/types'
|
||||
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
|
||||
import MetadataCacheNotice from '@/features/metadata-cache/components/MetadataCacheNotice.vue'
|
||||
import SearchFreshButton from '@/features/metadata-cache/components/SearchFreshButton.vue'
|
||||
import { showCacheFallbackToast } from '@/features/metadata-cache/lib/cache-feedback'
|
||||
import { splitMetadataCacheUsage, usageFallbackProviders } from '@/features/metadata-cache/lib/metadata-cache'
|
||||
import { AuthorMetadataMatchError } from '../api/author'
|
||||
import { useAuthorMatchPicker, type AuthorMatchEntry } from '../composables/useAuthorMatchPicker'
|
||||
import { authorProviderLabel } from '../lib/author-metadata-match'
|
||||
import AuthorMatchDetail from './AuthorMatchDetail.vue'
|
||||
import AuthorMatchRow from './AuthorMatchRow.vue'
|
||||
|
||||
const props = defineProps<{ author: AuthorDetail }>()
|
||||
const emit = defineEmits<{ close: []; updated: [AuthorDetail] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const id = useId()
|
||||
|
||||
const {
|
||||
query,
|
||||
reference,
|
||||
rows,
|
||||
cacheSummary,
|
||||
searching,
|
||||
searched,
|
||||
searchFailed,
|
||||
selected,
|
||||
selectedKey,
|
||||
currentKey,
|
||||
currentLookupComplete,
|
||||
current,
|
||||
loadingDetailKey,
|
||||
lookingUpReference,
|
||||
referenceError,
|
||||
search,
|
||||
searchFresh,
|
||||
select,
|
||||
clearSelection,
|
||||
lookUpReference,
|
||||
loadCurrent,
|
||||
pin,
|
||||
unpin,
|
||||
cancel,
|
||||
} = useAuthorMatchPicker(toRef(props, 'author'))
|
||||
|
||||
const confirmOpen = ref(false)
|
||||
const pinning = ref(false)
|
||||
const unpinning = ref(false)
|
||||
const busy = computed(() => pinning.value || unpinning.value)
|
||||
|
||||
const selectedIsCurrent = computed(() => !!selected.value && selected.value.key === currentKey.value)
|
||||
const loadingDetail = computed(() => !!selected.value && loadingDetailKey.value === selected.value.key)
|
||||
const showFallbackUnpin = computed(
|
||||
() =>
|
||||
props.author.metadataProviderPinned &&
|
||||
currentLookupComplete.value &&
|
||||
searched.value &&
|
||||
!current.value &&
|
||||
!rows.value.some((entry) => entry.key === currentKey.value),
|
||||
)
|
||||
const selectedProviderLabel = computed(() => (selected.value ? authorProviderLabel(selected.value.candidate.provider) : ''))
|
||||
const showCacheNotice = computed(() => searched.value && !searching.value && cacheSummary.value.cachedCount > 0)
|
||||
const resultStatus = computed(() => {
|
||||
if (searching.value) return t('author.match.results.searching')
|
||||
if (searchFailed.value) return t('author.match.results.failed')
|
||||
if (searched.value && rows.value.length === 0) return t('author.match.results.empty')
|
||||
if (searched.value) return t('author.match.results.count', { count: rows.value.length })
|
||||
return ''
|
||||
})
|
||||
const referenceErrorMessage = computed(() => {
|
||||
switch (referenceError.value) {
|
||||
case 'unrecognised':
|
||||
return t('author.match.reference.unrecognised')
|
||||
case 'notFound':
|
||||
return t('author.match.reference.notFound')
|
||||
case 'failed':
|
||||
return t('author.match.reference.failed')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (!open && !busy.value) emit('close')
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
void search()
|
||||
}
|
||||
|
||||
function handleSearchFresh() {
|
||||
void searchFresh()
|
||||
}
|
||||
|
||||
function handleLookUp() {
|
||||
void lookUpReference()
|
||||
}
|
||||
|
||||
function handleSelect(entry: AuthorMatchEntry) {
|
||||
select(entry)
|
||||
}
|
||||
|
||||
function handleUse() {
|
||||
confirmOpen.value = true
|
||||
}
|
||||
|
||||
function handleCancelConfirm() {
|
||||
if (!pinning.value) confirmOpen.value = false
|
||||
}
|
||||
|
||||
function matchErrorMessage(error: unknown, provider: string): string {
|
||||
if (error instanceof AuthorMetadataMatchError && error.status === 404) return t('author.match.toast.notFound', { provider })
|
||||
if (error instanceof AuthorMetadataMatchError && error.status === 503) return t('author.match.toast.providerDown', { provider })
|
||||
return t('author.match.toast.failed')
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
const entry = selected.value
|
||||
if (!entry || pinning.value) return
|
||||
const provider = authorProviderLabel(entry.candidate.provider)
|
||||
pinning.value = true
|
||||
try {
|
||||
const { value: updated, usage } = splitMetadataCacheUsage(await pin(entry))
|
||||
confirmOpen.value = false
|
||||
emit('updated', updated)
|
||||
toast.success(t('author.match.toast.pinned', { name: entry.candidate.name, provider }))
|
||||
showCacheFallbackToast(usageFallbackProviders(usage), authorProviderLabel)
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
confirmOpen.value = false
|
||||
toast.error(matchErrorMessage(error, provider))
|
||||
} finally {
|
||||
pinning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnpin() {
|
||||
if (unpinning.value) return
|
||||
unpinning.value = true
|
||||
try {
|
||||
emit('updated', await unpin())
|
||||
toast.success(t('author.match.toast.unpinned'))
|
||||
} catch {
|
||||
toast.error(t('author.match.toast.unpinFailed'))
|
||||
} finally {
|
||||
unpinning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadCurrent()
|
||||
void search()
|
||||
})
|
||||
|
||||
onBeforeUnmount(cancel)
|
||||
</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-[min(100vw-3rem,60rem)] sm:max-w-none"
|
||||
>
|
||||
<header class="flex min-h-[3.625rem] shrink-0 items-center gap-3 border-b border-border py-2 ps-4 pe-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<SheetTitle class="text-sm font-semibold">{{ t('author.match.title') }}</SheetTitle>
|
||||
<SheetDescription class="text-xs leading-snug [overflow-wrap:anywhere] text-muted-foreground">
|
||||
{{ t('author.match.description', { name: author.name }) }}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div class="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)] md:grid-cols-[minmax(0,22rem)_minmax(0,1fr)]">
|
||||
<section class="min-h-0 flex-col border-border md:flex md:border-e" :class="selectedKey ? 'hidden' : 'flex'">
|
||||
<div class="shrink-0 space-y-3 border-b border-border p-3">
|
||||
<form class="space-y-1" @submit.prevent="handleSearch">
|
||||
<label :for="`${id}-query`" class="text-xs text-muted-foreground">{{ t('author.match.search.label') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
:id="`${id}-query`"
|
||||
v-model="query"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
class="h-9 min-w-0 flex-1 rounded-md border border-input bg-background px-2.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="searching || !query.trim()"
|
||||
class="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground disabled:opacity-60"
|
||||
>
|
||||
<Search class="size-3.5" aria-hidden="true" />
|
||||
{{ t('author.match.search.submit') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<form class="space-y-1" @submit.prevent="handleLookUp">
|
||||
<label :for="`${id}-reference`" class="text-xs text-muted-foreground">{{ t('author.match.reference.label') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
:id="`${id}-reference`"
|
||||
v-model="reference"
|
||||
autocomplete="off"
|
||||
class="h-9 min-w-0 flex-1 rounded-md border border-input bg-background px-2.5 text-sm"
|
||||
:class="referenceError ? 'border-destructive' : ''"
|
||||
:placeholder="t('author.match.reference.placeholder')"
|
||||
:aria-invalid="!!referenceError || undefined"
|
||||
:aria-describedby="referenceError ? `${id}-reference-error` : undefined"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="lookingUpReference || !reference.trim()"
|
||||
class="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-md border border-input px-3 text-sm text-foreground hover:bg-muted disabled:opacity-60"
|
||||
>
|
||||
<Loader2 v-if="lookingUpReference" class="size-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
|
||||
{{ t('author.match.reference.submit') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="referenceError" :id="`${id}-reference-error`" class="text-xs text-destructive" role="alert">{{ referenceErrorMessage }}</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-between gap-2 px-3 pt-3">
|
||||
<p class="text-xs text-muted-foreground" role="status">{{ resultStatus }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="showFallbackUnpin"
|
||||
type="button"
|
||||
:disabled="busy"
|
||||
class="h-8 rounded-md border border-input px-3 text-sm text-foreground hover:bg-muted disabled:opacity-60"
|
||||
@click="handleUnpin"
|
||||
>
|
||||
{{ t('author.match.detail.unpin') }}
|
||||
</button>
|
||||
<SearchFreshButton v-if="searched && !searching && !showCacheNotice" @search-fresh="handleSearchFresh" />
|
||||
</div>
|
||||
</div>
|
||||
<MetadataCacheNotice v-if="showCacheNotice" class="mx-3 mt-2" :summary="cacheSummary" @search-fresh="handleSearchFresh" />
|
||||
|
||||
<ul class="min-h-0 flex-1 space-y-1 overflow-y-auto p-2" :aria-label="t('author.match.results.label')">
|
||||
<li v-for="entry in rows" :key="entry.key">
|
||||
<AuthorMatchRow :entry="entry" :selected="entry.key === selectedKey" :current="entry.key === currentKey" @select="handleSelect" />
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="min-h-0 flex-col md:flex" :class="selectedKey ? 'flex' : 'hidden'">
|
||||
<AuthorMatchDetail
|
||||
:entry="selected"
|
||||
:loading="loadingDetail"
|
||||
:current="selectedIsCurrent"
|
||||
:pinned="author.metadataProviderPinned"
|
||||
:busy="busy"
|
||||
@use="handleUse"
|
||||
@unpin="handleUnpin"
|
||||
@back="clearSelection"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="confirmOpen"
|
||||
:title="t('author.match.confirm.title', { provider: selectedProviderLabel })"
|
||||
:description="t('author.match.confirm.description')"
|
||||
:confirm-label="t('author.match.confirm.confirm')"
|
||||
:busy="pinning"
|
||||
:destructive="false"
|
||||
@confirm="handleConfirm"
|
||||
@cancel="handleCancelConfirm"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,219 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { defineComponent } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AuthorDetail, AuthorMetadataCandidate, MetadataCandidateCacheInfo } from '@bookorbit/types'
|
||||
import { i18n } from '@/i18n'
|
||||
import AuthorMatchSheet from '../AuthorMatchSheet.vue'
|
||||
|
||||
type AuthorApi = typeof import('../../api/author')
|
||||
type CandidateHandler = (candidate: AuthorMetadataCandidate, cache: MetadataCandidateCacheInfo | null) => void
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
class AuthorMetadataMatchError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
return {
|
||||
AuthorMetadataMatchError,
|
||||
streamAuthorMetadataCandidates: vi.fn<(params: Record<string, unknown>, onCandidate: CandidateHandler) => Promise<void>>(),
|
||||
lookupAuthorMetadata: vi.fn<AuthorApi['lookupAuthorMetadata']>(),
|
||||
pinAuthorMetadataMatch: vi.fn<AuthorApi['pinAuthorMetadataMatch']>(),
|
||||
unpinAuthorMetadataMatch: vi.fn<AuthorApi['unpinAuthorMetadataMatch']>(),
|
||||
toastSuccess: vi.fn<(message: string) => void>(),
|
||||
toastError: vi.fn<(message: string) => void>(),
|
||||
toastWarning: vi.fn<(message: string) => void>(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../api/author', () => ({
|
||||
AuthorMetadataMatchError: mocks.AuthorMetadataMatchError,
|
||||
streamAuthorMetadataCandidates: mocks.streamAuthorMetadataCandidates,
|
||||
lookupAuthorMetadata: mocks.lookupAuthorMetadata,
|
||||
pinAuthorMetadataMatch: mocks.pinAuthorMetadataMatch,
|
||||
unpinAuthorMetadataMatch: mocks.unpinAuthorMetadataMatch,
|
||||
}))
|
||||
|
||||
vi.mock('vue-sonner', () => ({
|
||||
toast: { success: mocks.toastSuccess, error: mocks.toastError, warning: mocks.toastWarning },
|
||||
}))
|
||||
|
||||
const Passthrough = defineComponent({ template: '<div><slot /></div>' })
|
||||
|
||||
const ConfirmDialogStub = defineComponent({
|
||||
name: 'ConfirmDialog',
|
||||
props: ['open', 'title', 'busy'],
|
||||
emits: ['confirm', 'cancel'],
|
||||
template: '<div v-if="open" data-test="confirm-dialog">{{ title }}</div>',
|
||||
})
|
||||
|
||||
function author(overrides: Partial<AuthorDetail> = {}): AuthorDetail {
|
||||
return {
|
||||
id: 9,
|
||||
name: 'John Reid',
|
||||
sortName: null,
|
||||
description: 'American journalist.',
|
||||
imageUrl: null,
|
||||
bookCount: 3,
|
||||
lastAddedAt: null,
|
||||
birthDate: null,
|
||||
birthYear: null,
|
||||
deathDate: null,
|
||||
deathYear: null,
|
||||
website: null,
|
||||
genres: [],
|
||||
influences: [],
|
||||
metadataProvider: null,
|
||||
metadataProviderId: null,
|
||||
metadataProviderPinned: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const scottish: AuthorMetadataCandidate = {
|
||||
provider: 'goodreads',
|
||||
providerId: '2222',
|
||||
name: 'John Reid',
|
||||
description: 'Scottish novelist.',
|
||||
birthYear: 1965,
|
||||
}
|
||||
|
||||
function mountSheet(value: AuthorDetail = author()) {
|
||||
return mount(AuthorMatchSheet, {
|
||||
props: { author: value },
|
||||
global: {
|
||||
stubs: {
|
||||
Sheet: Passthrough,
|
||||
SheetContent: Passthrough,
|
||||
SheetTitle: Passthrough,
|
||||
SheetDescription: Passthrough,
|
||||
SheetClose: Passthrough,
|
||||
ConfirmDialog: ConfirmDialogStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function rowButtons(wrapper: ReturnType<typeof mountSheet>) {
|
||||
return wrapper.findAll('ul button')
|
||||
}
|
||||
|
||||
function buttonLabelled(wrapper: ReturnType<typeof mountSheet>, key: string) {
|
||||
const label = i18n.global.t(key)
|
||||
const button = wrapper.findAll('button').find((candidate) => candidate.text().includes(label))
|
||||
expect(button, `no button labelled "${label}"`).toBeDefined()
|
||||
return button!
|
||||
}
|
||||
|
||||
describe('AuthorMatchSheet', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.streamAuthorMetadataCandidates.mockImplementation(async (_params, onCandidate) => {
|
||||
onCandidate({ provider: 'goodreads', providerId: '1111', name: 'John Reid', description: 'American journalist.' }, null)
|
||||
onCandidate({ provider: 'goodreads', providerId: '2222', name: 'John Reid' }, null)
|
||||
})
|
||||
mocks.lookupAuthorMetadata.mockResolvedValue({ candidate: scottish, cache: null })
|
||||
})
|
||||
|
||||
it('searches the author name on open and lists the matches', async () => {
|
||||
const wrapper = mountSheet()
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.streamAuthorMetadataCandidates.mock.calls[0]![0]).toEqual(expect.objectContaining({ q: 'John Reid' }))
|
||||
expect(rowButtons(wrapper)).toHaveLength(2)
|
||||
expect(wrapper.get('[role="status"]').text()).toBe(i18n.global.t('author.match.results.count', { count: 2 }))
|
||||
})
|
||||
|
||||
it('pins the chosen person after confirming and hands back the updated author', async () => {
|
||||
const updated = author({
|
||||
description: 'Scottish novelist.',
|
||||
metadataProvider: 'goodreads',
|
||||
metadataProviderId: '2222',
|
||||
metadataProviderPinned: true,
|
||||
})
|
||||
mocks.pinAuthorMetadataMatch.mockResolvedValue(updated)
|
||||
|
||||
const wrapper = mountSheet()
|
||||
await flushPromises()
|
||||
await rowButtons(wrapper)[1]!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Scottish novelist.')
|
||||
await buttonLabelled(wrapper, 'author.match.detail.use').trigger('click')
|
||||
expect(wrapper.find('[data-test="confirm-dialog"]').exists()).toBe(true)
|
||||
|
||||
await wrapper.getComponent(ConfirmDialogStub).vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.pinAuthorMetadataMatch).toHaveBeenCalledWith(9, { provider: 'goodreads', providerId: '2222' })
|
||||
expect(wrapper.emitted('updated')?.[0]).toEqual([updated])
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
expect(mocks.toastSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('says the provider is down on a 503 and keeps the sheet open', async () => {
|
||||
mocks.pinAuthorMetadataMatch.mockRejectedValue(new mocks.AuthorMetadataMatchError(503, 'down'))
|
||||
|
||||
const wrapper = mountSheet()
|
||||
await flushPromises()
|
||||
await rowButtons(wrapper)[1]!.trigger('click')
|
||||
await flushPromises()
|
||||
await buttonLabelled(wrapper, 'author.match.detail.use').trigger('click')
|
||||
await wrapper.getComponent(ConfirmDialogStub).vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(i18n.global.t('author.match.toast.providerDown', { provider: 'Goodreads' }))
|
||||
expect(wrapper.emitted('updated')).toBeUndefined()
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('marks the current pinned match and offers to unpin it', async () => {
|
||||
mocks.lookupAuthorMetadata.mockResolvedValue({ candidate: scottish, cache: null })
|
||||
mocks.unpinAuthorMetadataMatch.mockResolvedValue(author({ metadataProvider: 'goodreads', metadataProviderId: '2222' }))
|
||||
|
||||
const wrapper = mountSheet(author({ metadataProvider: 'goodreads', metadataProviderId: '2222', metadataProviderPinned: true }))
|
||||
await flushPromises()
|
||||
|
||||
const rows = rowButtons(wrapper)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]!.text()).toContain(i18n.global.t('author.match.results.current'))
|
||||
|
||||
await rows[0]!.trigger('click')
|
||||
await flushPromises()
|
||||
await buttonLabelled(wrapper, 'author.match.detail.unpin').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.unpinAuthorMetadataMatch).toHaveBeenCalledWith(9)
|
||||
expect(wrapper.emitted('updated')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('offers to unpin when the stored provider match no longer resolves', async () => {
|
||||
mocks.lookupAuthorMetadata.mockResolvedValue(null)
|
||||
mocks.unpinAuthorMetadataMatch.mockResolvedValue(author({ metadataProvider: 'goodreads', metadataProviderId: '9999' }))
|
||||
|
||||
const wrapper = mountSheet(author({ metadataProvider: 'goodreads', metadataProviderId: '9999', metadataProviderPinned: true }))
|
||||
await flushPromises()
|
||||
|
||||
await buttonLabelled(wrapper, 'author.match.detail.unpin').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.unpinAuthorMetadataMatch).toHaveBeenCalledWith(9)
|
||||
expect(wrapper.emitted('updated')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('explains a pasted reference it does not recognise', async () => {
|
||||
const wrapper = mountSheet()
|
||||
await flushPromises()
|
||||
|
||||
const reference = wrapper.get(`input[placeholder="${i18n.global.t('author.match.reference.placeholder')}"]`)
|
||||
await reference.setValue('John Reid the Scottish one')
|
||||
await wrapper.findAll('form')[1]!.trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('[role="alert"]').text()).toBe(i18n.global.t('author.match.reference.unrecognised'))
|
||||
expect(reference.attributes('aria-invalid')).toBe('true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { AuthorDetail, AuthorMetadataCandidate, MetadataCandidateCacheInfo } from '@bookorbit/types'
|
||||
import { useAuthorMatchPicker } from './useAuthorMatchPicker'
|
||||
|
||||
type AuthorApi = typeof import('../api/author')
|
||||
type CandidateHandler = (candidate: AuthorMetadataCandidate, cache: MetadataCandidateCacheInfo | null) => void
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
streamAuthorMetadataCandidates: vi.fn<(params: Record<string, unknown>, onCandidate: CandidateHandler, signal?: AbortSignal) => Promise<void>>(),
|
||||
lookupAuthorMetadata: vi.fn<AuthorApi['lookupAuthorMetadata']>(),
|
||||
pinAuthorMetadataMatch: vi.fn<AuthorApi['pinAuthorMetadataMatch']>(),
|
||||
unpinAuthorMetadataMatch: vi.fn<AuthorApi['unpinAuthorMetadataMatch']>(),
|
||||
}))
|
||||
|
||||
vi.mock('../api/author', () => api)
|
||||
|
||||
function candidate(overrides: Partial<AuthorMetadataCandidate> = {}): AuthorMetadataCandidate {
|
||||
return { provider: 'goodreads', providerId: '1111', name: 'John Reid', ...overrides } as AuthorMetadataCandidate
|
||||
}
|
||||
|
||||
function author(overrides: Partial<AuthorDetail> = {}): AuthorDetail {
|
||||
return {
|
||||
id: 9,
|
||||
name: 'John Reid',
|
||||
sortName: null,
|
||||
description: null,
|
||||
imageUrl: null,
|
||||
bookCount: 3,
|
||||
lastAddedAt: null,
|
||||
birthDate: null,
|
||||
birthYear: null,
|
||||
deathDate: null,
|
||||
deathYear: null,
|
||||
website: null,
|
||||
genres: [],
|
||||
influences: [],
|
||||
metadataProvider: null,
|
||||
metadataProviderId: null,
|
||||
metadataProviderPinned: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useAuthorMatchPicker', () => {
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(api)) fn.mockReset()
|
||||
})
|
||||
|
||||
it('searches by the author name and keeps every distinct candidate', async () => {
|
||||
api.streamAuthorMetadataCandidates.mockImplementation(async (_params, onCandidate) => {
|
||||
onCandidate(candidate({ providerId: '1111' }), null)
|
||||
onCandidate(candidate({ providerId: '2222' }), { fetchedAt: '2026-09-01T00:00:00.000Z', stale: false })
|
||||
onCandidate(candidate({ providerId: '1111' }), null)
|
||||
})
|
||||
|
||||
const picker = useAuthorMatchPicker(ref(author()))
|
||||
await picker.search()
|
||||
|
||||
expect(api.streamAuthorMetadataCandidates.mock.calls[0]![0]).toEqual({ q: 'John Reid', region: 'us', limit: 10 })
|
||||
expect(picker.rows.value.map((row) => row.key)).toEqual(['goodreads:1111', 'goodreads:2222'])
|
||||
expect(picker.cacheSummary.value.cachedCount).toBe(1)
|
||||
expect(picker.searched.value).toBe(true)
|
||||
})
|
||||
|
||||
it('asks for a live search when searching fresh', async () => {
|
||||
api.streamAuthorMetadataCandidates.mockResolvedValue(undefined)
|
||||
|
||||
const picker = useAuthorMatchPicker(ref(author()))
|
||||
await picker.searchFresh()
|
||||
|
||||
expect(api.streamAuthorMetadataCandidates.mock.calls[0]![0]).toEqual(expect.objectContaining({ cache: 'refresh' }))
|
||||
})
|
||||
|
||||
it('reports a failed search without throwing', async () => {
|
||||
api.streamAuthorMetadataCandidates.mockRejectedValue(new Error('offline'))
|
||||
|
||||
const picker = useAuthorMatchPicker(ref(author()))
|
||||
await picker.search()
|
||||
|
||||
expect(picker.searchFailed.value).toBe(true)
|
||||
expect(picker.searching.value).toBe(false)
|
||||
})
|
||||
|
||||
it('fills a selected search row in from a lookup by id', async () => {
|
||||
api.streamAuthorMetadataCandidates.mockImplementation(async (_params, onCandidate) => {
|
||||
onCandidate(candidate({ providerId: '2222' }), null)
|
||||
})
|
||||
api.lookupAuthorMetadata.mockResolvedValue({
|
||||
candidate: candidate({ providerId: '2222', description: 'Scottish novelist.', birthYear: 1965 }),
|
||||
cache: null,
|
||||
})
|
||||
|
||||
const picker = useAuthorMatchPicker(ref(author()))
|
||||
await picker.search()
|
||||
picker.select(picker.rows.value[0]!)
|
||||
await vi.waitFor(() => expect(picker.selected.value?.candidate.description).toBe('Scottish novelist.'))
|
||||
|
||||
expect(api.lookupAuthorMetadata).toHaveBeenCalledWith(expect.objectContaining({ provider: 'goodreads', providerId: '2222' }))
|
||||
expect(picker.selected.value?.candidate.birthYear).toBe(1965)
|
||||
})
|
||||
|
||||
it('looks a pasted link up and selects it', async () => {
|
||||
api.lookupAuthorMetadata.mockResolvedValue({ candidate: candidate({ providerId: '3333', name: 'John Reid' }), cache: null })
|
||||
|
||||
const picker = useAuthorMatchPicker(ref(author()))
|
||||
picker.reference.value = 'https://www.goodreads.com/author/show/3333.John_Reid'
|
||||
await picker.lookUpReference()
|
||||
|
||||
expect(api.lookupAuthorMetadata).toHaveBeenCalledWith({ provider: 'goodreads', providerId: '3333' })
|
||||
expect(picker.selectedKey.value).toBe('goodreads:3333')
|
||||
expect(picker.rows.value[0]?.key).toBe('goodreads:3333')
|
||||
expect(picker.reference.value).toBe('')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['not a link', null, 'unrecognised'],
|
||||
['3333', null, 'notFound'],
|
||||
['3333', new Error('offline'), 'failed'],
|
||||
])('reports %j as %s', async (input, lookup, error) => {
|
||||
if (lookup instanceof Error) api.lookupAuthorMetadata.mockRejectedValue(lookup)
|
||||
else api.lookupAuthorMetadata.mockResolvedValue(lookup)
|
||||
|
||||
const picker = useAuthorMatchPicker(ref(author()))
|
||||
picker.reference.value = input
|
||||
await picker.lookUpReference()
|
||||
|
||||
expect(picker.referenceError.value).toBe(error)
|
||||
expect(picker.selectedKey.value).toBeNull()
|
||||
})
|
||||
|
||||
it('leads with the current match even when the search does not return it', async () => {
|
||||
api.lookupAuthorMetadata.mockResolvedValue({ candidate: candidate({ providerId: '9999' }), cache: null })
|
||||
api.streamAuthorMetadataCandidates.mockImplementation(async (_params, onCandidate) => {
|
||||
onCandidate(candidate({ providerId: '2222' }), null)
|
||||
onCandidate(candidate({ providerId: '9999' }), null)
|
||||
})
|
||||
|
||||
const picker = useAuthorMatchPicker(ref(author({ metadataProvider: 'goodreads', metadataProviderId: '9999' })))
|
||||
await picker.loadCurrent()
|
||||
await picker.search()
|
||||
|
||||
expect(picker.currentKey.value).toBe('goodreads:9999')
|
||||
expect(picker.rows.value.map((row) => row.key)).toEqual(['goodreads:9999', 'goodreads:2222'])
|
||||
})
|
||||
|
||||
it('finishes loading the current match when its stored id no longer resolves', async () => {
|
||||
api.lookupAuthorMetadata.mockResolvedValue(null)
|
||||
|
||||
const picker = useAuthorMatchPicker(ref(author({ metadataProvider: 'goodreads', metadataProviderId: '9999', metadataProviderPinned: true })))
|
||||
await picker.loadCurrent()
|
||||
|
||||
expect(picker.current.value).toBeNull()
|
||||
expect(picker.currentLookupComplete.value).toBe(true)
|
||||
})
|
||||
|
||||
it('pins the chosen candidate for this author', async () => {
|
||||
api.pinAuthorMetadataMatch.mockResolvedValue(author({ metadataProviderPinned: true }))
|
||||
|
||||
const picker = useAuthorMatchPicker(ref(author()))
|
||||
await picker.pin({ key: 'audnexus:B001H6UH2C', candidate: candidate({ provider: 'audnexus', providerId: 'B001H6UH2C' }), cache: null })
|
||||
|
||||
expect(api.pinAuthorMetadataMatch).toHaveBeenCalledWith(9, { provider: 'audnexus', providerId: 'B001H6UH2C' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,227 @@
|
||||
import { computed, ref, shallowRef, type Ref } from 'vue'
|
||||
|
||||
import type { AuthorDetail, AuthorMetadataCandidate, MetadataCandidateCacheInfo } from '@bookorbit/types'
|
||||
import { METADATA_CACHE_REFRESH, summarizeCandidateCache } from '@/features/metadata-cache/lib/metadata-cache'
|
||||
import { lookupAuthorMetadata, pinAuthorMetadataMatch, streamAuthorMetadataCandidates, unpinAuthorMetadataMatch } from '../api/author'
|
||||
import { parseAuthorProviderReference, type AuthorProviderReference } from '../lib/author-metadata-match'
|
||||
|
||||
export type AuthorMatchEntry = {
|
||||
key: string
|
||||
candidate: AuthorMetadataCandidate
|
||||
cache: MetadataCandidateCacheInfo | null
|
||||
}
|
||||
|
||||
export type AuthorMatchReferenceError = 'unrecognised' | 'notFound' | 'failed'
|
||||
|
||||
const SEARCH_LIMIT = 10
|
||||
|
||||
export function authorMatchKey(reference: AuthorProviderReference): string {
|
||||
return `${reference.provider}:${reference.providerId}`
|
||||
}
|
||||
|
||||
function toEntry(candidate: AuthorMetadataCandidate, cache: MetadataCandidateCacheInfo | null): AuthorMatchEntry {
|
||||
return { key: authorMatchKey(candidate), candidate, cache }
|
||||
}
|
||||
|
||||
export function useAuthorMatchPicker(author: Ref<AuthorDetail>) {
|
||||
const query = ref(author.value.name)
|
||||
const reference = ref('')
|
||||
const entries = shallowRef<AuthorMatchEntry[]>([])
|
||||
const searching = ref(false)
|
||||
const searched = ref(false)
|
||||
const searchFailed = ref(false)
|
||||
const selectedKey = ref<string | null>(null)
|
||||
/** Selected candidates looked up by id, since a name search returns only a shallow record. */
|
||||
const details = shallowRef<Record<string, AuthorMatchEntry>>({})
|
||||
const loadingDetailKey = ref<string | null>(null)
|
||||
const lookingUpReference = ref(false)
|
||||
const referenceError = ref<AuthorMatchReferenceError | null>(null)
|
||||
const current = shallowRef<AuthorMatchEntry | null>(null)
|
||||
const currentLookupComplete = ref(false)
|
||||
|
||||
let searchAbort: AbortController | null = null
|
||||
let searchSeq = 0
|
||||
let detailSeq = 0
|
||||
|
||||
const currentKey = computed(() => {
|
||||
const { metadataProvider, metadataProviderId } = author.value
|
||||
return metadataProvider && metadataProviderId ? authorMatchKey({ provider: metadataProvider, providerId: metadataProviderId }) : null
|
||||
})
|
||||
|
||||
/** The current match leads the list even when the name search does not return it. */
|
||||
const rows = computed(() => {
|
||||
const currentEntry = current.value
|
||||
if (!currentEntry) return entries.value
|
||||
return [currentEntry, ...entries.value.filter((entry) => entry.key !== currentEntry.key)]
|
||||
})
|
||||
|
||||
const cacheSummary = computed(() => summarizeCandidateCache(entries.value.map((entry) => entry.cache)))
|
||||
|
||||
const selected = computed<AuthorMatchEntry | null>(() => {
|
||||
const key = selectedKey.value
|
||||
if (!key) return null
|
||||
return details.value[key] ?? rows.value.find((entry) => entry.key === key) ?? null
|
||||
})
|
||||
|
||||
function upsertEntry(entry: AuthorMatchEntry) {
|
||||
entries.value = [entry, ...entries.value.filter((existing) => existing.key !== entry.key)]
|
||||
}
|
||||
|
||||
async function search(options: { fresh?: boolean } = {}) {
|
||||
const q = query.value.trim()
|
||||
if (!q) return
|
||||
|
||||
const seq = ++searchSeq
|
||||
searchAbort?.abort()
|
||||
searchAbort = new AbortController()
|
||||
searching.value = true
|
||||
searched.value = false
|
||||
searchFailed.value = false
|
||||
entries.value = []
|
||||
|
||||
try {
|
||||
await streamAuthorMetadataCandidates(
|
||||
{ q, region: 'us', limit: SEARCH_LIMIT, ...(options.fresh ? { cache: METADATA_CACHE_REFRESH } : {}) },
|
||||
(candidate, cache) => {
|
||||
if (seq !== searchSeq) return
|
||||
const entry = toEntry(candidate, cache)
|
||||
if (entries.value.some((existing) => existing.key === entry.key)) return
|
||||
entries.value = [...entries.value, entry]
|
||||
},
|
||||
searchAbort.signal,
|
||||
)
|
||||
if (seq === searchSeq) searched.value = true
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') return
|
||||
if (seq !== searchSeq) return
|
||||
searchFailed.value = true
|
||||
searched.value = true
|
||||
} finally {
|
||||
if (seq === searchSeq) {
|
||||
searching.value = false
|
||||
searchAbort = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function searchFresh() {
|
||||
return search({ fresh: true })
|
||||
}
|
||||
|
||||
async function loadDetail(entry: AuthorMatchEntry) {
|
||||
if (details.value[entry.key]) return
|
||||
const seq = ++detailSeq
|
||||
loadingDetailKey.value = entry.key
|
||||
try {
|
||||
const result = await lookupAuthorMetadata(entry.candidate)
|
||||
if (!result) return
|
||||
// The search row keeps its own cache marker; the lookup's describes only the extra fields.
|
||||
details.value = { ...details.value, [entry.key]: toEntry({ ...entry.candidate, ...result.candidate }, entry.cache) }
|
||||
} catch {
|
||||
// The shallow search record stays on screen; picking it still looks the person up in full.
|
||||
} finally {
|
||||
if (seq === detailSeq) loadingDetailKey.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function select(entry: AuthorMatchEntry) {
|
||||
selectedKey.value = entry.key
|
||||
void loadDetail(entry)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedKey.value = null
|
||||
}
|
||||
|
||||
async function lookUpReference() {
|
||||
referenceError.value = null
|
||||
const parsed = parseAuthorProviderReference(reference.value)
|
||||
if (!parsed) {
|
||||
referenceError.value = 'unrecognised'
|
||||
return
|
||||
}
|
||||
|
||||
lookingUpReference.value = true
|
||||
try {
|
||||
const result = await lookupAuthorMetadata(parsed)
|
||||
if (!result) {
|
||||
referenceError.value = 'notFound'
|
||||
return
|
||||
}
|
||||
const entry = toEntry(result.candidate, result.cache)
|
||||
upsertEntry(entry)
|
||||
details.value = { ...details.value, [entry.key]: entry }
|
||||
selectedKey.value = entry.key
|
||||
reference.value = ''
|
||||
} catch {
|
||||
referenceError.value = 'failed'
|
||||
} finally {
|
||||
lookingUpReference.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Looks up whoever the author is matched to now, so the list can show them as the current match. */
|
||||
async function loadCurrent() {
|
||||
const { metadataProvider, metadataProviderId } = author.value
|
||||
currentLookupComplete.value = false
|
||||
current.value = null
|
||||
if (!metadataProvider || !metadataProviderId) {
|
||||
currentLookupComplete.value = true
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await lookupAuthorMetadata({ provider: metadataProvider, providerId: metadataProviderId })
|
||||
if (!result) return
|
||||
const entry = toEntry(result.candidate, result.cache)
|
||||
current.value = entry
|
||||
details.value = { ...details.value, [entry.key]: entry }
|
||||
} catch {
|
||||
// Without it the list is just the search results.
|
||||
} finally {
|
||||
currentLookupComplete.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function pin(entry: AuthorMatchEntry) {
|
||||
return pinAuthorMetadataMatch(author.value.id, { provider: entry.candidate.provider, providerId: entry.candidate.providerId })
|
||||
}
|
||||
|
||||
function unpin() {
|
||||
return unpinAuthorMetadataMatch(author.value.id)
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
searchAbort?.abort()
|
||||
searchAbort = null
|
||||
searchSeq += 1
|
||||
detailSeq += 1
|
||||
searching.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
reference,
|
||||
rows,
|
||||
cacheSummary,
|
||||
searching,
|
||||
searched,
|
||||
searchFailed,
|
||||
selected,
|
||||
selectedKey,
|
||||
currentKey,
|
||||
currentLookupComplete,
|
||||
current,
|
||||
loadingDetailKey,
|
||||
lookingUpReference,
|
||||
referenceError,
|
||||
search,
|
||||
searchFresh,
|
||||
select,
|
||||
clearSelection,
|
||||
lookUpReference,
|
||||
loadCurrent,
|
||||
pin,
|
||||
unpin,
|
||||
cancel,
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,11 @@ import { useAuthorMetadataPreview } from './useAuthorMetadataPreview'
|
||||
type CandidateHandler = (candidate: AuthorMetadataCandidate, cache: MetadataCandidateCacheInfo | null) => void
|
||||
|
||||
const streamMock = vi.hoisted(() => vi.fn<(params: Record<string, unknown>, onCandidate: CandidateHandler, signal?: AbortSignal) => Promise<void>>())
|
||||
const lookupMock = vi.hoisted(() => vi.fn<(typeof import('../api/author'))['lookupAuthorMetadata']>())
|
||||
|
||||
vi.mock('../api/author', () => ({
|
||||
streamAuthorMetadataCandidates: streamMock,
|
||||
lookupAuthorMetadata: lookupMock,
|
||||
}))
|
||||
|
||||
function candidate(overrides: Partial<AuthorMetadataCandidate> = {}): AuthorMetadataCandidate {
|
||||
@@ -18,6 +20,33 @@ function candidate(overrides: Partial<AuthorMetadataCandidate> = {}): AuthorMeta
|
||||
describe('useAuthorMetadataPreview', () => {
|
||||
beforeEach(() => {
|
||||
streamMock.mockReset()
|
||||
lookupMock.mockReset()
|
||||
})
|
||||
|
||||
it('previews the pinned person by id instead of searching by name', async () => {
|
||||
const cache = { fetchedAt: '2026-09-01T00:00:00.000Z', stale: false }
|
||||
lookupMock.mockResolvedValue({ candidate: candidate({ providerId: '2222', description: 'Scottish novelist.' }), cache })
|
||||
|
||||
const state = useAuthorMetadataPreview(ref('John Reid'), ref({ provider: 'goodreads', providerId: '2222' }))
|
||||
await state.load()
|
||||
await state.loadFresh()
|
||||
|
||||
expect(streamMock).not.toHaveBeenCalled()
|
||||
expect(lookupMock.mock.calls[0]![0]).toEqual({ provider: 'goodreads', providerId: '2222' })
|
||||
expect(lookupMock.mock.calls[1]![1]).toEqual(expect.objectContaining({ cache: 'refresh' }))
|
||||
expect(state.preview.value?.description).toBe('Scottish novelist.')
|
||||
expect(state.previewCache.value).toEqual(cache)
|
||||
expect(state.searched.value).toBe(true)
|
||||
})
|
||||
|
||||
it('shows no preview when the pinned id matches nobody', async () => {
|
||||
lookupMock.mockResolvedValue(null)
|
||||
|
||||
const state = useAuthorMetadataPreview(ref('John Reid'), ref({ provider: 'goodreads', providerId: '2222' }))
|
||||
await state.load()
|
||||
|
||||
expect(state.preview.value).toBeNull()
|
||||
expect(state.searched.value).toBe(true)
|
||||
})
|
||||
|
||||
it('tracks which candidates the cache answered for and which one the preview shows', async () => {
|
||||
|
||||
@@ -2,14 +2,16 @@ import { computed, ref, shallowRef, type Ref } from 'vue'
|
||||
|
||||
import type { AuthorMetadataCandidate, MetadataCandidateCacheInfo } from '@bookorbit/types'
|
||||
import { METADATA_CACHE_REFRESH, summarizeCandidateCache } from '@/features/metadata-cache/lib/metadata-cache'
|
||||
import { streamAuthorMetadataCandidates } from '../api/author'
|
||||
import { lookupAuthorMetadata, streamAuthorMetadataCandidates } from '../api/author'
|
||||
import type { AuthorProviderReference } from '../lib/author-metadata-match'
|
||||
|
||||
export interface AuthorMetadataPreviewLoadOptions {
|
||||
/** Skips saved answers and asks every provider live. */
|
||||
fresh?: boolean
|
||||
}
|
||||
|
||||
export function useAuthorMetadataPreview(authorName: Ref<string>) {
|
||||
/** A pinned author previews its pinned person; a name search could show the wrong one. */
|
||||
export function useAuthorMetadataPreview(authorName: Ref<string>, pinnedMatch?: Ref<AuthorProviderReference | null>) {
|
||||
const preview = ref<AuthorMetadataCandidate | null>(null)
|
||||
/** Set when the metadata cache answered for the candidate shown as the preview. */
|
||||
const previewCache = ref<MetadataCandidateCacheInfo | null>(null)
|
||||
@@ -57,6 +59,20 @@ export function useAuthorMetadataPreview(authorName: Ref<string>) {
|
||||
|
||||
try {
|
||||
clearPreview()
|
||||
const pinned = pinnedMatch?.value
|
||||
if (pinned) {
|
||||
const result = await lookupAuthorMetadata(pinned, {
|
||||
...(options.fresh ? { cache: METADATA_CACHE_REFRESH } : {}),
|
||||
signal: abortController.signal,
|
||||
})
|
||||
if (seq !== requestSeq) return
|
||||
if (result) {
|
||||
candidateCaches.value = [result.cache]
|
||||
showCandidate(result.candidate, result.cache)
|
||||
}
|
||||
searched.value = true
|
||||
return
|
||||
}
|
||||
await streamAuthorMetadataCandidates(
|
||||
{
|
||||
q: name,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AuthorDetail } from '@bookorbit/types'
|
||||
import { authorFactsDraft, authorFactsPayload, isSafeWebsite, normalizeWebsiteInput, parseLifeDateInput, sameFactsDraft } from '../author-facts'
|
||||
|
||||
function author(overrides: Partial<AuthorDetail> = {}): AuthorDetail {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'John Reid',
|
||||
sortName: null,
|
||||
description: null,
|
||||
imageUrl: null,
|
||||
bookCount: 1,
|
||||
lastAddedAt: null,
|
||||
birthDate: null,
|
||||
birthYear: null,
|
||||
deathDate: null,
|
||||
deathYear: null,
|
||||
website: null,
|
||||
genres: [],
|
||||
influences: [],
|
||||
metadataProvider: null,
|
||||
metadataProviderId: null,
|
||||
metadataProviderPinned: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseLifeDateInput', () => {
|
||||
it.each([
|
||||
['', { date: null, year: null }],
|
||||
['1965', { date: null, year: 1965 }],
|
||||
['-800', { date: null, year: -800 }],
|
||||
[' 1965-03-14 ', { date: '1965-03-14', year: 1965 }],
|
||||
['2024-02-29', { date: '2024-02-29', year: 2024 }],
|
||||
['0065-04-12', { date: '0065-04-12', year: 65 }],
|
||||
])('reads %j', (input, expected) => {
|
||||
expect(parseLifeDateInput(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
it.each(['1965-02-30', '1965-13-01', '0000-01-01', '14/03/1965', 'March 1965', '12345'])('rejects %j', (input) => {
|
||||
expect(parseLifeDateInput(input)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('website input', () => {
|
||||
it('adds https to a bare host and keeps an explicit scheme', () => {
|
||||
expect(normalizeWebsiteInput('johnreid.example')).toBe('https://johnreid.example')
|
||||
expect(normalizeWebsiteInput(' http://johnreid.example/about ')).toBe('http://johnreid.example/about')
|
||||
expect(normalizeWebsiteInput(' ')).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses links that are not http(s) or have no dotted host', () => {
|
||||
expect(normalizeWebsiteInput('javascript:alert(1)')).toBeUndefined()
|
||||
expect(normalizeWebsiteInput('ftp://johnreid.example')).toBeUndefined()
|
||||
expect(normalizeWebsiteInput('localhost')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats only http(s) as safe to link', () => {
|
||||
expect(isSafeWebsite('https://a.example')).toBe(true)
|
||||
expect(isSafeWebsite('javascript:alert(1)')).toBe(false)
|
||||
expect(isSafeWebsite(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authorFactsPayload', () => {
|
||||
it('sends nothing when nothing changed, even a value the server would now reject', () => {
|
||||
const seeded = authorFactsDraft(author({ website: 'not a url', birthDate: '1950-01-01', birthYear: 1950, genres: ['Journalism'] }))
|
||||
|
||||
expect(authorFactsPayload({ ...seeded }, seeded)).toEqual({ payload: {}, invalid: [] })
|
||||
})
|
||||
|
||||
it('sends changed facts, clearing emptied ones', () => {
|
||||
const seeded = authorFactsDraft(author({ website: 'https://old.example', birthYear: 1950, genres: ['Journalism'] }))
|
||||
const draft = { ...seeded, website: '', born: '1965-03-14', died: '2020', genres: [], influences: ['Alistair MacLean'] }
|
||||
|
||||
expect(authorFactsPayload(draft, seeded)).toEqual({
|
||||
payload: {
|
||||
website: null,
|
||||
birthDate: '1965-03-14',
|
||||
birthYear: 1965,
|
||||
deathDate: null,
|
||||
deathYear: 2020,
|
||||
genres: null,
|
||||
influences: ['Alistair MacLean'],
|
||||
},
|
||||
invalid: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('names every field it could not read', () => {
|
||||
const seeded = authorFactsDraft(author())
|
||||
const draft = { ...seeded, website: 'javascript:alert(1)', born: 'yesterday', died: '1965-02-30' }
|
||||
|
||||
expect(authorFactsPayload(draft, seeded).invalid).toEqual(['website', 'born', 'died'])
|
||||
})
|
||||
|
||||
it('compares drafts field by field', () => {
|
||||
const seeded = authorFactsDraft(author({ genres: ['Thriller'] }))
|
||||
expect(sameFactsDraft(seeded, authorFactsDraft(author({ genres: ['Thriller'] })))).toBe(true)
|
||||
expect(sameFactsDraft(seeded, { ...seeded, genres: ['Thriller', 'Crime'] })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { authorProviderLabel, authorProviderSourceUrl, candidateLifeYears, parseAuthorProviderReference } from '../author-metadata-match'
|
||||
|
||||
describe('parseAuthorProviderReference', () => {
|
||||
it.each([
|
||||
['https://www.goodreads.com/author/show/1234567.John_Reid', '1234567'],
|
||||
['goodreads.com/author/show/1234567-john-reid?from_search=true', '1234567'],
|
||||
['https://www.goodreads.com/en/author/show/42', '42'],
|
||||
['https://www.goodreads.com/author/list/42.John_Reid', '42'],
|
||||
[' 1234567 ', '1234567'],
|
||||
])('reads %s as a Goodreads id', (input, providerId) => {
|
||||
expect(parseAuthorProviderReference(input)).toEqual({ provider: 'goodreads', providerId })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['https://www.audible.com/author/John-Reid/B001H6UH2C', 'B001H6UH2C'],
|
||||
['https://www.audible.co.uk/author/B001H6UH2C?ref=a_search', 'B001H6UH2C'],
|
||||
['https://api.audnex.us/authors/B001H6UH2C', 'B001H6UH2C'],
|
||||
['b001h6uh2c', 'B001H6UH2C'],
|
||||
])('reads %s as an Audible ASIN', (input, providerId) => {
|
||||
expect(parseAuthorProviderReference(input)).toEqual({ provider: 'audnexus', providerId })
|
||||
})
|
||||
|
||||
it.each(['', ' ', 'John Reid', 'https://www.goodreads.com/book/show/123', 'https://example.com/author/show/123', 'B12'])(
|
||||
'does not recognise %j',
|
||||
(input) => {
|
||||
expect(parseAuthorProviderReference(input)).toBeNull()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('author provider helpers', () => {
|
||||
it('labels known providers and passes unknown keys through', () => {
|
||||
expect(authorProviderLabel('goodreads')).toBe('Goodreads')
|
||||
expect(authorProviderLabel('audnexus')).toBe('Audnexus')
|
||||
expect(authorProviderLabel('openlibrary')).toBe('openlibrary')
|
||||
})
|
||||
|
||||
it('builds the provider page for an id', () => {
|
||||
expect(authorProviderSourceUrl('goodreads', '42')).toBe('https://www.goodreads.com/author/show/42')
|
||||
expect(authorProviderSourceUrl('audnexus', 'B001H6UH2C')).toBe('https://www.audible.com/author/B001H6UH2C')
|
||||
expect(authorProviderSourceUrl('openlibrary', 'OL1A')).toBeNull()
|
||||
})
|
||||
|
||||
it('takes life years from the year or, failing that, the date', () => {
|
||||
expect(candidateLifeYears({ provider: 'goodreads', providerId: '1', name: 'A', birthDate: '1929-10-21', deathYear: 2018 })).toEqual({
|
||||
born: 1929,
|
||||
died: 2018,
|
||||
})
|
||||
expect(candidateLifeYears({ provider: 'audnexus', providerId: 'B1', name: 'A' })).toEqual({ born: null, died: null })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { AuthorDetail } from '@bookorbit/types'
|
||||
import { formatDate, formatNumber } from '@/i18n/formatters'
|
||||
|
||||
/** What the edit panel holds for the facts, as typed: a life date is a year or a full date. */
|
||||
export type AuthorFactsDraft = {
|
||||
website: string
|
||||
born: string
|
||||
died: string
|
||||
genres: string[]
|
||||
influences: string[]
|
||||
}
|
||||
|
||||
export type AuthorFactsPayload = {
|
||||
website?: string | null
|
||||
birthDate?: string | null
|
||||
birthYear?: number | null
|
||||
deathDate?: string | null
|
||||
deathYear?: number | null
|
||||
genres?: string[] | null
|
||||
influences?: string[] | null
|
||||
}
|
||||
|
||||
export type AuthorFactsField = 'website' | 'born' | 'died'
|
||||
|
||||
type LifeDate = { date: string | null; year: number | null }
|
||||
|
||||
const YEAR_INPUT = /^-?\d{1,4}$/
|
||||
const DATE_INPUT = /^\d{4}-\d{2}-\d{2}$/
|
||||
const URL_SCHEME = /^[a-z][a-z\d+.-]*:/i
|
||||
|
||||
export function authorFactsDraft(author: AuthorDetail | null): AuthorFactsDraft {
|
||||
return {
|
||||
website: author?.website ?? '',
|
||||
born: lifeDateInput(author?.birthDate ?? null, author?.birthYear ?? null),
|
||||
died: lifeDateInput(author?.deathDate ?? null, author?.deathYear ?? null),
|
||||
genres: [...(author?.genres ?? [])],
|
||||
influences: [...(author?.influences ?? [])],
|
||||
}
|
||||
}
|
||||
|
||||
function lifeDateInput(date: string | null, year: number | null): string {
|
||||
if (date) return date
|
||||
return year !== null ? String(year) : ''
|
||||
}
|
||||
|
||||
/** Null when the text is neither a year nor a real yyyy-mm-dd date. */
|
||||
export function parseLifeDateInput(value: string): LifeDate | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return { date: null, year: null }
|
||||
if (YEAR_INPUT.test(trimmed)) return { date: null, year: Number(trimmed) }
|
||||
if (!DATE_INPUT.test(trimmed)) return null
|
||||
|
||||
const [year, month, day] = trimmed.split('-').map(Number) as [number, number, number]
|
||||
if (year < 1) return null
|
||||
// setUTCFullYear, because Date.UTC reads years below 100 as 19xx.
|
||||
const parsed = new Date(0)
|
||||
parsed.setUTCFullYear(year, month - 1, day)
|
||||
const real = parsed.getUTCFullYear() === year && parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day
|
||||
return real ? { date: trimmed, year } : null
|
||||
}
|
||||
|
||||
/** Adds https:// to a bare host. Null clears the website; undefined means it is not an http(s) link with a dotted host. */
|
||||
export function normalizeWebsiteInput(value: string): string | null | undefined {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const candidate = URL_SCHEME.test(trimmed) ? trimmed : `https://${trimmed}`
|
||||
try {
|
||||
const url = new URL(candidate)
|
||||
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname.includes('.')) return undefined
|
||||
return candidate
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Only http(s) links are rendered as links; anything else a provider sent stays plain text. */
|
||||
export function isSafeWebsite(value: string | null | undefined): value is string {
|
||||
return !!value && /^https?:\/\//i.test(value.trim())
|
||||
}
|
||||
|
||||
function sameList(a: string[], b: string[]): boolean {
|
||||
return a.length === b.length && a.every((value, index) => value === b[index])
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the facts the user changed are sent. A value the server never validated (a provider's
|
||||
* website without a dotted host, say) would otherwise fail every save of an unrelated field.
|
||||
*/
|
||||
export function authorFactsPayload(draft: AuthorFactsDraft, seeded: AuthorFactsDraft): { payload: AuthorFactsPayload; invalid: AuthorFactsField[] } {
|
||||
const payload: AuthorFactsPayload = {}
|
||||
const invalid: AuthorFactsField[] = []
|
||||
|
||||
if (draft.website.trim() !== seeded.website.trim()) {
|
||||
const website = normalizeWebsiteInput(draft.website)
|
||||
if (website === undefined) invalid.push('website')
|
||||
else payload.website = website
|
||||
}
|
||||
|
||||
if (draft.born.trim() !== seeded.born.trim()) {
|
||||
const born = parseLifeDateInput(draft.born)
|
||||
if (!born) invalid.push('born')
|
||||
else Object.assign(payload, { birthDate: born.date, birthYear: born.year })
|
||||
}
|
||||
|
||||
if (draft.died.trim() !== seeded.died.trim()) {
|
||||
const died = parseLifeDateInput(draft.died)
|
||||
if (!died) invalid.push('died')
|
||||
else Object.assign(payload, { deathDate: died.date, deathYear: died.year })
|
||||
}
|
||||
|
||||
if (!sameList(draft.genres, seeded.genres)) payload.genres = draft.genres.length > 0 ? draft.genres : null
|
||||
if (!sameList(draft.influences, seeded.influences)) payload.influences = draft.influences.length > 0 ? draft.influences : null
|
||||
|
||||
return { payload, invalid }
|
||||
}
|
||||
|
||||
export function sameFactsDraft(a: AuthorFactsDraft, b: AuthorFactsDraft): boolean {
|
||||
return a.website === b.website && a.born === b.born && a.died === b.died && sameList(a.genres, b.genres) && sameList(a.influences, b.influences)
|
||||
}
|
||||
|
||||
// Goodreads supplies a full date for most authors and only a year for older
|
||||
// ones, so fall back to the year rather than showing nothing.
|
||||
export function lifeDateLabel(date: string | null | undefined, year: number | null | undefined): string | null {
|
||||
if (date) {
|
||||
const parsed = new Date(date)
|
||||
// Birth and death are plain yyyy-MM-dd, which Date parses as UTC midnight.
|
||||
// Formatting that in a timezone behind UTC would render the previous day,
|
||||
// so read it back in UTC too.
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
return formatDate(parsed, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' })
|
||||
}
|
||||
}
|
||||
// A year is not a quantity: grouping would render 1929 as "1,929".
|
||||
return year != null ? formatNumber(year, { useGrouping: false }) : null
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { AuthorMetadataProviderKey, type AuthorMetadataCandidate } from '@bookorbit/types'
|
||||
|
||||
export type AuthorProviderReference = {
|
||||
provider: AuthorMetadataProviderKey
|
||||
providerId: string
|
||||
}
|
||||
|
||||
const PROVIDER_LABELS: Record<AuthorMetadataProviderKey, string> = {
|
||||
[AuthorMetadataProviderKey.GOODREADS]: 'Goodreads',
|
||||
[AuthorMetadataProviderKey.AUDNEXUS]: 'Audnexus',
|
||||
}
|
||||
|
||||
const GOODREADS_AUTHOR_URL = /goodreads\.com\/(?:[a-z]{2}\/)?author\/(?:show|list)\/(\d+)/i
|
||||
const AUDIBLE_AUTHOR_URL = /audible\.[a-z.]+\/author\/(?:[^/?#]+\/)?(B[0-9A-Z]{9})(?=[/?#]|$)/i
|
||||
const AUDNEXUS_AUTHOR_URL = /audnex\.us\/authors\/(B[0-9A-Z]{9})(?=[/?#]|$)/i
|
||||
const BARE_GOODREADS_ID = /^\d+$/
|
||||
const BARE_ASIN = /^B[0-9A-Z]{9}$/i
|
||||
|
||||
/** Brand names, so never translated. Unknown keys from a newer server show as they are. */
|
||||
export function authorProviderLabel(provider: string): string {
|
||||
return PROVIDER_LABELS[provider as AuthorMetadataProviderKey] ?? provider
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a pasted Goodreads author link or id, or an Audible author link or ASIN, as the provider
|
||||
* id the server looks up. Anything else is null, so the picker can say it does not recognise it.
|
||||
*/
|
||||
export function parseAuthorProviderReference(input: string): AuthorProviderReference | null {
|
||||
const value = input.trim()
|
||||
if (!value) return null
|
||||
|
||||
const goodreads = value.match(GOODREADS_AUTHOR_URL)?.[1] ?? (BARE_GOODREADS_ID.test(value) ? value : null)
|
||||
if (goodreads) return { provider: AuthorMetadataProviderKey.GOODREADS, providerId: goodreads }
|
||||
|
||||
const asin = value.match(AUDIBLE_AUTHOR_URL)?.[1] ?? value.match(AUDNEXUS_AUTHOR_URL)?.[1] ?? (BARE_ASIN.test(value) ? value : null)
|
||||
if (asin) return { provider: AuthorMetadataProviderKey.AUDNEXUS, providerId: asin.toUpperCase() }
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** The provider's own page for an author, the same link the server gives a candidate as `sourceUrl`. */
|
||||
export function authorProviderSourceUrl(provider: string, providerId: string): string | null {
|
||||
const id = encodeURIComponent(providerId)
|
||||
if (provider === AuthorMetadataProviderKey.GOODREADS) return `https://www.goodreads.com/author/show/${id}`
|
||||
if (provider === AuthorMetadataProviderKey.AUDNEXUS) return `https://www.audible.com/author/${id}`
|
||||
return null
|
||||
}
|
||||
|
||||
/** Birth and death years, from the full date when only that was given. */
|
||||
export function candidateLifeYears(candidate: AuthorMetadataCandidate): { born: number | null; died: number | null } {
|
||||
return {
|
||||
born: candidate.birthYear ?? yearOf(candidate.birthDate),
|
||||
died: candidate.deathYear ?? yearOf(candidate.deathDate),
|
||||
}
|
||||
}
|
||||
|
||||
function yearOf(date: string | undefined): number | null {
|
||||
const year = date ? Number(date.slice(0, 4)) : Number.NaN
|
||||
return Number.isFinite(year) ? year : null
|
||||
}
|
||||
@@ -186,6 +186,7 @@ function makeAuthor(overrides: Partial<AuthorDetail> = {}): AuthorDetail {
|
||||
influences: [],
|
||||
metadataProvider: null,
|
||||
metadataProviderId: null,
|
||||
metadataProviderPinned: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -204,9 +205,15 @@ async function mountView() {
|
||||
AuthorHeader: {
|
||||
name: 'AuthorHeader',
|
||||
props: ['author'],
|
||||
emits: ['edit', 'merge', 'refresh', 'delete'],
|
||||
emits: ['edit', 'merge', 'refresh', 'delete', 'find-match'],
|
||||
template: '<div data-test="author-header">{{ author.bookCount }}</div>',
|
||||
},
|
||||
AuthorMatchSheet: {
|
||||
name: 'AuthorMatchSheet',
|
||||
props: ['author'],
|
||||
emits: ['close', 'updated'],
|
||||
template: '<div data-test="author-match-sheet" />',
|
||||
},
|
||||
AuthorConfirmDialog: {
|
||||
name: 'AuthorConfirmDialog',
|
||||
props: ['open'],
|
||||
@@ -270,13 +277,21 @@ describe('AuthorDetailView', () => {
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function inputLabelled(wrapper: Awaited<ReturnType<typeof mountView>>, key: string) {
|
||||
const label = i18n.global.t(key)
|
||||
const match = wrapper.findAll('label').find((candidate) => candidate.text().startsWith(label))
|
||||
expect(match, `no field labelled "${label}"`).toBeDefined()
|
||||
return match!.get('input')
|
||||
}
|
||||
|
||||
function editFields(wrapper: Awaited<ReturnType<typeof mountView>>) {
|
||||
const textInputs = wrapper.findAll('input:not([type="file"])')
|
||||
expect(textInputs).toHaveLength(2)
|
||||
return {
|
||||
name: textInputs[0],
|
||||
sortName: textInputs[1],
|
||||
name: inputLabelled(wrapper, 'author.detail.edit.name'),
|
||||
sortName: inputLabelled(wrapper, 'author.detail.edit.sortName'),
|
||||
description: wrapper.get('textarea'),
|
||||
website: inputLabelled(wrapper, 'author.detail.edit.website'),
|
||||
born: inputLabelled(wrapper, 'author.detail.edit.born'),
|
||||
died: inputLabelled(wrapper, 'author.detail.edit.died'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,7 +424,7 @@ describe('AuthorDetailView', () => {
|
||||
})
|
||||
|
||||
it('stops treating the form as unsaved once the edits are saved', async () => {
|
||||
vi.mocked(updateAuthor).mockImplementation(async (_id, payload) => makeAuthor({ ...payload }))
|
||||
vi.mocked(updateAuthor).mockImplementation(async (_id, payload) => makeAuthor({ ...(payload as Partial<AuthorDetail>) }))
|
||||
vi.mocked(refreshAuthorMetadata).mockResolvedValue(makeAuthor({ description: 'Provider biography' }))
|
||||
|
||||
const wrapper = await openEditPanel()
|
||||
@@ -428,6 +443,80 @@ describe('AuthorDetailView', () => {
|
||||
expect((editFields(wrapper).description.element as HTMLTextAreaElement).value).toBe('Provider biography')
|
||||
})
|
||||
|
||||
it('sends only the facts that were changed, as a date and its year', async () => {
|
||||
mocks.author = ref(makeAuthor({ website: 'not a url', birthYear: 1950, genres: ['Journalism'] }))
|
||||
vi.mocked(updateAuthor).mockResolvedValue(makeAuthor())
|
||||
|
||||
const wrapper = await openEditPanel()
|
||||
const fields = editFields(wrapper)
|
||||
expect((fields.born.element as HTMLInputElement).value).toBe('1950')
|
||||
await fields.born.setValue('1965-03-14')
|
||||
await fields.died.setValue('2020')
|
||||
|
||||
await findButtonByLabel(wrapper, 'common.save').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.mocked(updateAuthor)).toHaveBeenCalledWith(7, {
|
||||
name: 'Author',
|
||||
sortName: null,
|
||||
description: null,
|
||||
birthDate: '1965-03-14',
|
||||
birthYear: 1965,
|
||||
deathDate: null,
|
||||
deathYear: 2020,
|
||||
})
|
||||
})
|
||||
|
||||
it('adds https to a bare website and clears an emptied one', async () => {
|
||||
mocks.author = ref(makeAuthor({ website: 'https://old.example' }))
|
||||
vi.mocked(updateAuthor).mockResolvedValue(makeAuthor())
|
||||
|
||||
const wrapper = await openEditPanel()
|
||||
await editFields(wrapper).website.setValue('johnreid.example')
|
||||
await findButtonByLabel(wrapper, 'common.save').trigger('click')
|
||||
await flushPromises()
|
||||
expect(vi.mocked(updateAuthor)).toHaveBeenLastCalledWith(7, expect.objectContaining({ website: 'https://johnreid.example' }))
|
||||
})
|
||||
|
||||
it('keeps the panel open and flags a life date it cannot read', async () => {
|
||||
const wrapper = await openEditPanel()
|
||||
const born = editFields(wrapper).born
|
||||
await born.setValue('14 March 1965')
|
||||
|
||||
await findButtonByLabel(wrapper, 'common.save').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.mocked(updateAuthor)).not.toHaveBeenCalled()
|
||||
expect(born.attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.text()).toContain(i18n.global.t('author.detail.edit.lifeDateInvalid'))
|
||||
|
||||
await born.setValue('1965')
|
||||
await flushPromises()
|
||||
|
||||
expect(born.attributes('aria-invalid')).toBeUndefined()
|
||||
expect(wrapper.text()).not.toContain(i18n.global.t('author.detail.edit.lifeDateInvalid'))
|
||||
})
|
||||
|
||||
it('opens the match picker and adopts the author it returns', async () => {
|
||||
const wrapper = await mountView()
|
||||
expect(wrapper.find('[data-test="author-match-sheet"]').exists()).toBe(false)
|
||||
|
||||
await wrapper.getComponent({ name: 'AuthorHeader' }).vm.$emit('find-match')
|
||||
await flushPromises()
|
||||
const sheet = wrapper.getComponent({ name: 'AuthorMatchSheet' })
|
||||
|
||||
await sheet.vm.$emit(
|
||||
'updated',
|
||||
makeAuthor({ description: 'Scottish novelist.', metadataProvider: 'goodreads', metadataProviderId: '2222', metadataProviderPinned: true }),
|
||||
)
|
||||
await sheet.vm.$emit('close')
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.author.value?.description).toBe('Scottish novelist.')
|
||||
expect(mocks.loadMetadataPreview).toHaveBeenCalled()
|
||||
expect(wrapper.find('[data-test="author-match-sheet"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('deletes a book from the author grid menu action', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ import { usePageTitle } from '@/composables/usePageTitle'
|
||||
import { useDeleteBook } from '@/features/book/composables/useDeleteBook'
|
||||
import AuthorHeader from '../components/AuthorHeader.vue'
|
||||
import AuthorConfirmDialog from '../components/AuthorConfirmDialog.vue'
|
||||
import AuthorFactsFields from '../components/AuthorFactsFields.vue'
|
||||
import AuthorMatchSheet from '../components/AuthorMatchSheet.vue'
|
||||
import { authorFactsDraft, authorFactsPayload, sameFactsDraft, type AuthorFactsDraft, type AuthorFactsField } from '../lib/author-facts'
|
||||
import {
|
||||
deleteAuthorImage,
|
||||
deleteAuthors,
|
||||
@@ -80,6 +83,11 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
const authorName = computed(() => author.value?.name ?? '')
|
||||
const pinnedMatch = computed(() => {
|
||||
const value = author.value
|
||||
if (!value?.metadataProviderPinned || !value.metadataProvider || !value.metadataProviderId) return null
|
||||
return { provider: value.metadataProvider, providerId: value.metadataProviderId }
|
||||
})
|
||||
const pageTitle = computed(() => {
|
||||
if (author.value?.name) return t('author.detail.pageTitleNamed', { name: author.value.name })
|
||||
return Number.isFinite(authorId.value) ? t('author.detail.pageTitleId', { id: authorId.value }) : t('author.detail.pageTitle')
|
||||
@@ -95,7 +103,7 @@ const {
|
||||
cancel: cancelMetadataPreview,
|
||||
load: loadMetadataPreview,
|
||||
loadFresh: loadFreshMetadataPreview,
|
||||
} = useAuthorMetadataPreview(authorName)
|
||||
} = useAuthorMetadataPreview(authorName, pinnedMatch)
|
||||
|
||||
const canUpdate = computed(() => hasPermissionInAnyLibrary('library_edit_metadata', 'editor'))
|
||||
const canMerge = computed(() => isSuperuser.value)
|
||||
@@ -116,13 +124,21 @@ const authorImageInput = ref<HTMLInputElement | null>(null)
|
||||
const draftName = ref('')
|
||||
const draftSortName = ref('')
|
||||
const draftDescription = ref('')
|
||||
const seededDrafts = ref<{ name: string; sortName: string; description: string } | null>(null)
|
||||
const draftFacts = ref<AuthorFactsDraft>(authorFactsDraft(null))
|
||||
const invalidFacts = ref<AuthorFactsField[]>([])
|
||||
const seededDrafts = ref<{ name: string; sortName: string; description: string; facts: AuthorFactsDraft } | null>(null)
|
||||
const authorImageBusy = computed(() => uploadingImage.value || removingImage.value)
|
||||
const matchSheetOpen = ref(false)
|
||||
|
||||
const draftsDirty = computed(() => {
|
||||
const seeded = seededDrafts.value
|
||||
if (!seeded) return false
|
||||
return draftName.value !== seeded.name || draftSortName.value !== seeded.sortName || draftDescription.value !== seeded.description
|
||||
return (
|
||||
draftName.value !== seeded.name ||
|
||||
draftSortName.value !== seeded.sortName ||
|
||||
draftDescription.value !== seeded.description ||
|
||||
!sameFactsDraft(draftFacts.value, seeded.facts)
|
||||
)
|
||||
})
|
||||
|
||||
const mergeQuery = ref('')
|
||||
@@ -187,10 +203,13 @@ function seedDrafts(value: AuthorDetail | null) {
|
||||
name: value?.name ?? '',
|
||||
sortName: value?.sortName ?? '',
|
||||
description: value?.description ?? '',
|
||||
facts: authorFactsDraft(value),
|
||||
}
|
||||
draftName.value = seeded.name
|
||||
draftSortName.value = seeded.sortName
|
||||
draftDescription.value = seeded.description
|
||||
draftFacts.value = authorFactsDraft(value)
|
||||
invalidFacts.value = []
|
||||
seededDrafts.value = seeded
|
||||
}
|
||||
|
||||
@@ -206,6 +225,16 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// After a failed save, re-check as the user types so a corrected field loses its error at once.
|
||||
watch(
|
||||
draftFacts,
|
||||
(draft) => {
|
||||
if (invalidFacts.value.length === 0 || !seededDrafts.value) return
|
||||
invalidFacts.value = authorFactsPayload(draft, seededDrafts.value.facts).invalid
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
function goBack() {
|
||||
const from = typeof route.query.from === 'string' ? route.query.from : ''
|
||||
if (from.startsWith('/authors')) {
|
||||
@@ -268,12 +297,17 @@ async function saveAuthorEdits() {
|
||||
return
|
||||
}
|
||||
|
||||
const facts = authorFactsPayload(draftFacts.value, seededDrafts.value?.facts ?? authorFactsDraft(author.value))
|
||||
invalidFacts.value = facts.invalid
|
||||
if (facts.invalid.length > 0) return
|
||||
|
||||
savingEdit.value = true
|
||||
try {
|
||||
const updated = await updateAuthor(author.value.id, {
|
||||
name,
|
||||
sortName: draftSortName.value.trim() || null,
|
||||
description: draftDescription.value.trim() || null,
|
||||
...facts.payload,
|
||||
})
|
||||
author.value = updated
|
||||
seedDrafts(updated)
|
||||
@@ -468,6 +502,20 @@ function searchMetadataPreviewFresh() {
|
||||
void loadFreshMetadataPreview()
|
||||
}
|
||||
|
||||
function openMatchSheet() {
|
||||
matchSheetOpen.value = true
|
||||
}
|
||||
|
||||
function closeMatchSheet() {
|
||||
matchSheetOpen.value = false
|
||||
}
|
||||
|
||||
async function handleMatchUpdated(updated: AuthorDetail) {
|
||||
author.value = updated
|
||||
seedDrafts(updated)
|
||||
await loadMetadataPreview()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchLibraries()
|
||||
await Promise.all([loadAuthor(), loadBooks(true)])
|
||||
@@ -566,8 +614,10 @@ defineOptions({ name: 'AuthorDetailView' })
|
||||
@merge="toggleMerge"
|
||||
@refresh="refreshMetadata"
|
||||
@search-fresh="searchMetadataPreviewFresh"
|
||||
@find-match="openMatchSheet"
|
||||
@delete="confirmDeleteOpen = true"
|
||||
/>
|
||||
<AuthorMatchSheet v-if="author && matchSheetOpen && canUpdate" :author="author" @close="closeMatchSheet" @updated="handleMatchUpdated" />
|
||||
|
||||
<div v-if="metadataPreviewError" class="mt-3 rounded-md border border-border/70 bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||
{{ t('author.detail.previewError') }}
|
||||
@@ -589,6 +639,7 @@ defineOptions({ name: 'AuthorDetailView' })
|
||||
{{ t('author.detail.edit.description') }}
|
||||
<textarea v-model="draftDescription" rows="3" class="mt-1 w-full rounded-md border border-input bg-background px-2.5 py-2 text-sm" />
|
||||
</label>
|
||||
<AuthorFactsFields v-model="draftFacts" :invalid="invalidFacts" />
|
||||
<div class="space-y-1.5">
|
||||
<p class="text-xs text-muted-foreground">{{ t('author.detail.edit.image') }}</p>
|
||||
<input ref="authorImageInput" type="file" accept="image/*" class="hidden" :disabled="authorImageBusy" @change="onAuthorImageSelected" />
|
||||
|
||||
@@ -5773,7 +5773,10 @@
|
||||
"died": "Died",
|
||||
"website": "Website",
|
||||
"genres": "Genres",
|
||||
"influences": "Influences"
|
||||
"influences": "Influences",
|
||||
"findMatch": "Find the Right Author",
|
||||
"source": "Source",
|
||||
"pinned": "Pinned"
|
||||
},
|
||||
"list": {
|
||||
"title": "Authors",
|
||||
@@ -5845,7 +5848,17 @@
|
||||
"removing": "Removing...",
|
||||
"removeImage": "Remove image",
|
||||
"imageHint": "PNG/JPEG/WEBP/GIF/BMP up to {size} MB",
|
||||
"saving": "Saving..."
|
||||
"saving": "Saving...",
|
||||
"website": "Website",
|
||||
"websitePlaceholder": "https://example.com",
|
||||
"websiteInvalid": "Enter a web address such as example.com.",
|
||||
"born": "Born",
|
||||
"died": "Died",
|
||||
"lifeDatePlaceholder": "1965 or 1965-03-14",
|
||||
"lifeDateHint": "Born and died take a year, or a full date as year-month-day.",
|
||||
"lifeDateInvalid": "Enter a year or a real date as year-month-day.",
|
||||
"genres": "Genres",
|
||||
"influences": "Influences"
|
||||
},
|
||||
"merge": {
|
||||
"searchPlaceholder": "Search authors to merge into this author",
|
||||
@@ -5910,6 +5923,56 @@
|
||||
"actionsFor": "Actions for {name}",
|
||||
"refreshing": "Refreshing metadata for {name}",
|
||||
"deleting": "Deleting {name}"
|
||||
},
|
||||
"match": {
|
||||
"title": "Find the right author",
|
||||
"description": "Pick the person behind {name}, from a name search or a pasted Goodreads or Audible link.",
|
||||
"search": {
|
||||
"label": "Name",
|
||||
"submit": "Search"
|
||||
},
|
||||
"reference": {
|
||||
"label": "Goodreads or Audible link or ID",
|
||||
"placeholder": "goodreads.com/author/show/12345",
|
||||
"submit": "Look up",
|
||||
"unrecognised": "Paste a Goodreads or Audible author link, a Goodreads ID or an Audible ASIN.",
|
||||
"notFound": "No author has that ID.",
|
||||
"failed": "The lookup failed. Try again in a moment."
|
||||
},
|
||||
"results": {
|
||||
"label": "Matches",
|
||||
"count": "{count, plural, =0 {No matches} one {# match} other {# matches}}",
|
||||
"searching": "Searching...",
|
||||
"empty": "No authors found. Try another spelling or paste a link.",
|
||||
"failed": "The search failed. Try again in a moment.",
|
||||
"current": "Current",
|
||||
"born": "Born {year}",
|
||||
"lifeSpan": "{born}–{died}"
|
||||
},
|
||||
"detail": {
|
||||
"empty": "Pick a match to see who it is.",
|
||||
"loading": "Loading details...",
|
||||
"noBio": "No biography.",
|
||||
"viewOn": "View on {provider}",
|
||||
"back": "Back to matches",
|
||||
"use": "Use this author",
|
||||
"unpin": "Unpin",
|
||||
"pinnedHint": "Pinned: refreshes look this author up by ID instead of searching by name.",
|
||||
"currentHint": "The author is matched to this person now. Use it to pin the match."
|
||||
},
|
||||
"confirm": {
|
||||
"title": "Use this {provider} author?",
|
||||
"description": "The bio, photo, dates, website, genres and influences are replaced with this author's, and later refreshes keep to this person.",
|
||||
"confirm": "Use this author"
|
||||
},
|
||||
"toast": {
|
||||
"pinned": "Now matched to {name} on {provider}",
|
||||
"unpinned": "Unpinned. Refreshes search by name again.",
|
||||
"notFound": "{provider} has no author with that ID any more.",
|
||||
"providerDown": "{provider} could not be reached. Try again in a moment.",
|
||||
"failed": "Could not use this author.",
|
||||
"unpinFailed": "Could not unpin the match."
|
||||
}
|
||||
}
|
||||
},
|
||||
"book": {
|
||||
|
||||
Reference in New Issue
Block a user