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:
neon
2026-10-20 18:47:16 +00:00
parent 0d695a7ea0
commit 2b8917afb6
44 changed files with 27075 additions and 60 deletions
+55 -1
View File
@@ -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" />
+65 -2
View File
@@ -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": {