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": {
+6
View File
@@ -25,6 +25,12 @@ export const SERVER_FEATURES = [
* review reports `heldOnRequest`, `releaseInfoUrl`, `edition` and `hasCover` with a cover route.
*/
"book-request-review-hold",
/**
* `PATCH /authors/:id` accepts `website`, `birthDate`, `birthYear`, `deathDate`, `deathYear`,
* `genres` and `influences`, and `PUT`/`DELETE /authors/:id/metadata-match` pin or unpin the
* provider match.
*/
"author-metadata-match",
/**
* `GET /metadata-fetch/stream` accepts `resultsPerProvider` (up to
* `METADATA_SEARCH_MAX_RESULTS_PER_PROVIDER`), and Open Library and Hardcover answer a search that
+2
View File
@@ -35,6 +35,8 @@ export type AuthorDetail = AuthorSummary & {
influences: string[];
metadataProvider: AuthorMetadataProviderKey | null;
metadataProviderId: string | null;
/** True when a user chose the provider match; refreshes then look that id up instead of searching by name. */
metadataProviderPinned: boolean;
};
export type AuthorsPage = {
@@ -0,0 +1 @@
ALTER TABLE "authors" ADD COLUMN "metadata_provider_pinned" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
@@ -869,6 +869,13 @@
"when": 1792512356419,
"tag": "0123_add_koreader_hide_audio_only_books",
"breakpoints": true
},
{
"idx": 124,
"version": "7",
"when": 1792516862510,
"tag": "0124_add_author_metadata_provider_pinned",
"breakpoints": true
}
]
}
+4 -1
View File
@@ -1,5 +1,6 @@
import { sql } from 'drizzle-orm';
import {
type AnyPgColumn,
bigint,
boolean,
check,
@@ -30,7 +31,9 @@ export const books = pgTable(
libraryFolderId: integer('library_folder_id')
.notNull()
.references(() => libraryFolders.id, { onDelete: 'cascade' }),
primaryFileId: integer('primary_file_id').references(() => bookFiles.id, { onDelete: 'set null' }),
// Annotated because book_files references books back: an inferred return makes the two
// table types circular, and depending on check order one of them silently becomes any.
primaryFileId: integer('primary_file_id').references((): AnyPgColumn => bookFiles.id, { onDelete: 'set null' }),
primaryAuthorSortName: varchar('primary_author_sort_name', { length: 500 }),
folderPath: varchar('folder_path', { length: 4096 }).notNull(),
status: varchar('status', { length: 20 }).notNull().default('present'),
+2
View File
@@ -181,6 +181,8 @@ export const authors = pgTable(
influences: text('influences').array(),
metadataProvider: varchar('metadata_provider', { length: 50 }),
metadataProviderId: varchar('metadata_provider_id', { length: 128 }),
/** Set when a user chose this provider match; enrichment then looks the id up instead of searching by name. */
metadataProviderPinned: boolean('metadata_provider_pinned').notNull().default(false),
lastEnrichedAt: timestamp('last_enriched_at', { withTimezone: true }),
},
(t) => [
@@ -100,7 +100,14 @@ describe('AppInfoService', () => {
const info = await service.getAppInfo();
expect(info.features).toEqual([...SERVER_FEATURES]);
expect(info.features).toEqual(
expect.arrayContaining(['annotation-stars', 'annotation-color-names', 'annotation-trash', 'book-journal', 'bookmark-edit']),
expect.arrayContaining([
'annotation-stars',
'annotation-color-names',
'annotation-trash',
'book-journal',
'bookmark-edit',
'author-metadata-match',
]),
);
});
@@ -40,8 +40,8 @@ describe('AuthorEnrichmentExecutorService', () => {
findByIdForEnrichment: vi.fn(),
updateAuthorById: vi.fn(),
};
const metadataFetch = { collectByProvider: vi.fn() };
const imageStorage = { saveFromUrl: vi.fn(), getThumbnailPath: vi.fn() };
const metadataFetch = { collectByProvider: vi.fn(), lookupByIdDetailed: vi.fn() };
const imageStorage = { saveFromUrl: vi.fn(), getThumbnailPath: vi.fn(), deleteAuthorDir: vi.fn() };
let service: AuthorEnrichmentExecutorService;
@@ -245,6 +245,189 @@ describe('AuthorEnrichmentExecutorService', () => {
expect(authorsRepo.updateAuthorById).toHaveBeenCalledWith(5, expect.objectContaining({ hasPhoto: true }));
});
describe('pinned match', () => {
const PINNED_ROW = {
id: 5,
name: 'Jane Doe',
description: null,
hasPhoto: false,
bookCount: 3,
metadataProvider: AuthorMetadataProviderKey.GOODREADS,
metadataProviderId: '4242',
metadataProviderPinned: true,
};
beforeEach(() => {
authorsRepo.findByIdForEnrichment.mockResolvedValue(PINNED_ROW);
metadataFetch.lookupByIdDetailed.mockResolvedValue({ candidate: GOODREADS, failure: null, cacheServed: [], outboundRequests: 1 });
});
it('looks the pinned id up instead of searching by name', async () => {
const result = await service.execute({ authorId: 5, preferences: prefs() }, { policy: 'default', allowFallback: true });
expect(metadataFetch.collectByProvider).not.toHaveBeenCalled();
expect(metadataFetch.lookupByIdDetailed).toHaveBeenCalledWith(AuthorMetadataProviderKey.GOODREADS, '4242', 'us', {
directive: { policy: 'default', allowFallback: true },
});
expect(result).toMatchObject({ kind: 'done', provider: AuthorMetadataProviderKey.GOODREADS, descriptionUpdated: true });
expect(authorsRepo.updateAuthorById).toHaveBeenCalledWith(5, expect.objectContaining({ description: 'Goodreads bio' }));
});
it('never rewrites the pinned provider id', async () => {
metadataFetch.lookupByIdDetailed.mockResolvedValue({
candidate: { ...GOODREADS, providerId: '4242.Jane_Doe' },
failure: null,
cacheServed: [],
outboundRequests: 1,
});
await service.execute({ authorId: 5, preferences: prefs() });
const update = authorsRepo.updateAuthorById.mock.calls[0][1];
expect(update).not.toHaveProperty('metadataProvider');
expect(update).not.toHaveProperty('metadataProviderId');
});
it('keeps the pin and reports no_match when the id now matches nobody', async () => {
metadataFetch.lookupByIdDetailed.mockResolvedValue({ candidate: null, failure: null, cacheServed: [], outboundRequests: 1 });
const result = await service.execute({ authorId: 5, preferences: prefs() });
expect(result).toMatchObject({ kind: 'skipped', reason: 'no_match', outboundRequests: 1 });
expect(authorsRepo.updateAuthorById).not.toHaveBeenCalled();
});
it('reports a failed lookup as a failure so the queue retries', async () => {
metadataFetch.lookupByIdDetailed.mockResolvedValue({
candidate: null,
failure: { provider: AuthorMetadataProviderKey.GOODREADS, message: 'HTTP 429', httpStatus: 429, retryAfterMs: 5000, transient: true },
cacheServed: [],
outboundRequests: 1,
});
const result = await service.execute({ authorId: 5, preferences: prefs() });
expect(result).toMatchObject({ kind: 'failed', httpStatus: 429, retryAfterMs: 5000, transient: true });
});
it('skips when the pinned provider is no longer used by any field', async () => {
const preferences = prefs();
for (const field of Object.keys(preferences.fields) as AuthorMetadataField[]) {
preferences.fields[field].providers = [AuthorMetadataProviderKey.AUDNEXUS];
}
const result = await service.execute({ authorId: 5, preferences });
expect(result).toMatchObject({ kind: 'skipped', reason: 'provider_disabled' });
expect(metadataFetch.lookupByIdDetailed).not.toHaveBeenCalled();
expect(metadataFetch.collectByProvider).not.toHaveBeenCalled();
});
it('searches by name again once unpinned', async () => {
authorsRepo.findByIdForEnrichment.mockResolvedValue({ ...PINNED_ROW, metadataProviderPinned: false });
await service.execute({ authorId: 5, preferences: prefs() });
expect(metadataFetch.lookupByIdDetailed).not.toHaveBeenCalled();
expect(metadataFetch.collectByProvider).toHaveBeenCalled();
});
});
describe('applyMatch', () => {
const WRONG_PERSON_ROW = {
id: 5,
name: 'John Reid',
description: 'American journalist.',
hasPhoto: true,
birthDate: '1950-01-01',
birthYear: 1950,
deathDate: null,
deathYear: null,
website: 'https://wrong.example',
genres: ['Journalism'],
influences: null,
bookCount: 2,
metadataProvider: AuthorMetadataProviderKey.GOODREADS,
metadataProviderId: '1111',
metadataProviderPinned: false,
};
it('replaces every enabled field, clears what the match lacks and pins it', async () => {
const match = {
provider: AuthorMetadataProviderKey.GOODREADS,
providerId: '2222',
name: 'John Reid',
description: 'Scottish novelist.',
genres: ['Thriller'],
};
const result = await service.applyMatch(WRONG_PERSON_ROW as never, prefs(), match);
expect(imageStorage.deleteAuthorDir).toHaveBeenCalledWith(5);
expect(authorsRepo.updateAuthorById).toHaveBeenCalledWith(5, {
description: 'Scottish novelist.',
birthDate: null,
birthYear: null,
deathDate: null,
deathYear: null,
website: null,
genres: ['Thriller'],
influences: null,
metadataProvider: AuthorMetadataProviderKey.GOODREADS,
metadataProviderId: '2222',
metadataProviderPinned: true,
hasPhoto: true,
lastEnrichedAt: expect.any(Date),
});
expect(result).toMatchObject({ kind: 'applied', imageUpdated: true, downloads: 0 });
});
it('stores the match portrait and leaves disabled fields alone', async () => {
const result = await service.applyMatch(WRONG_PERSON_ROW as never, prefs({ website: { enabled: false } }), GOODREADS);
expect(imageStorage.saveFromUrl).toHaveBeenCalledWith(5, 'https://img.example/goodreads.jpg');
expect(imageStorage.deleteAuthorDir).not.toHaveBeenCalled();
expect(authorsRepo.updateAuthorById.mock.calls[0][1]).not.toHaveProperty('website');
expect(result).toMatchObject({ kind: 'applied', imageUpdated: true, downloads: 1 });
expect((result as { fieldsUpdated: string[] }).fieldsUpdated).toContain('photo');
});
it('clears fields an Audnexus match cannot supply', async () => {
await service.applyMatch(WRONG_PERSON_ROW as never, prefs(), AUDNEXUS);
expect(authorsRepo.updateAuthorById).toHaveBeenCalledWith(
5,
expect.objectContaining({
description: 'Audnexus bio',
birthYear: null,
website: null,
genres: null,
metadataProvider: AuthorMetadataProviderKey.AUDNEXUS,
metadataProviderId: 'A1',
}),
);
});
it('writes nothing when the portrait download fails', async () => {
imageStorage.saveFromUrl.mockRejectedValue(new AuthorImageStorageError('rate limited', { httpStatus: 429, transient: true }));
const result = await service.applyMatch(WRONG_PERSON_ROW as never, prefs(), GOODREADS);
expect(result).toMatchObject({ kind: 'failed', message: 'rate limited', httpStatus: 429, transient: true });
expect(authorsRepo.updateAuthorById).not.toHaveBeenCalled();
});
it('leaves the existing match untouched when the portrait cannot be stored', async () => {
imageStorage.saveFromUrl.mockResolvedValue(false);
const result = await service.applyMatch(WRONG_PERSON_ROW as never, prefs(), GOODREADS);
expect(result).toMatchObject({ kind: 'failed', message: 'Failed to save author image', transient: false });
expect(imageStorage.deleteAuthorDir).not.toHaveBeenCalled();
expect(authorsRepo.updateAuthorById).not.toHaveBeenCalled();
});
});
describe('metadata cache', () => {
const CACHED = { provider: AuthorMetadataProviderKey.GOODREADS, source: 'cache', stale: false, cachedAt: '2026-09-01T00:00:00.000Z' };
@@ -52,6 +52,14 @@ type AuthorEnrichmentOutcome =
export type AuthorEnrichmentExecutionResult = AuthorEnrichmentOutcome & AuthorEnrichmentProvenance;
export type AuthorMatchApplyResult =
| { kind: 'applied'; imageUpdated: boolean; fieldsUpdated: AuthorMetadataField[]; downloads: number }
| Omit<Extract<AuthorEnrichmentOutcome, { kind: 'failed' }>, 'descriptionUpdated' | 'imageUpdated' | 'fieldsUpdated'>;
type PinnedMatch = { provider: AuthorMetadataProviderKey; providerId: string };
const AUTHOR_PROVIDER_KEYS = new Set<string>(Object.values(AuthorMetadataProviderKey));
type AuthorUpdate = Parameters<AuthorsRepository['updateAuthorById']>[1];
type SkipReason = 'author_not_found' | 'orphaned' | 'provider_disabled' | 'no_match';
@@ -80,11 +88,12 @@ export class AuthorEnrichmentExecutorService {
const providerOrder = this.providersInUse(params.preferences);
if (providerOrder.length === 0) return this.skipped('provider_disabled');
const { candidates, failures, outboundRequests, cacheServed } = await this.authorMetadataFetchService.collectByProvider(
{ name: author.name, region: 'us', limit: 1 },
providerOrder,
directive,
);
const pinned = this.pinnedMatch(author);
if (pinned && !providerOrder.includes(pinned.provider)) return this.skipped('provider_disabled');
const { candidates, failures, outboundRequests, cacheServed } = pinned
? await this.collectPinned(pinned, directive)
: await this.authorMetadataFetchService.collectByProvider({ name: author.name, region: 'us', limit: 1 }, providerOrder, directive);
const provenance: AuthorEnrichmentProvenance = { outboundRequests, cacheServed };
if (candidates.size === 0) {
@@ -107,6 +116,90 @@ export class AuthorEnrichmentExecutorService {
return this.applyCandidates(author, params.preferences, candidates, provenance);
}
/**
* Makes the author exactly the chosen person. Every enabled field takes the candidate's value and
* is cleared where the candidate has none, so nothing from an earlier, wrong match survives. The
* portrait is fetched before anything is written, so a failed download leaves the author as it was.
*/
async applyMatch(
author: AuthorEnrichmentRow,
preferences: AuthorMetadataPreferences,
candidate: AuthorMetadataCandidate,
): Promise<AuthorMatchApplyResult> {
const update: AuthorUpdate = {};
const fieldsUpdated: AuthorMetadataField[] = [];
let imageUpdated = false;
let downloads = 0;
if (preferences.fields.photo?.enabled) {
if (candidate.imageUrl) {
downloads += 1;
try {
imageUpdated = await this.authorImageStorage.saveFromUrl(author.id, candidate.imageUrl);
if (!imageUpdated) {
return {
kind: 'failed',
message: 'Failed to save author image',
provider: candidate.provider,
httpStatus: null,
retryAfterMs: null,
transient: false,
};
}
} catch (error) {
const storageError = error instanceof AuthorImageStorageError ? error : null;
return {
kind: 'failed',
message: storageError?.message ?? (error instanceof Error ? error.message : 'Failed to save author image'),
provider: candidate.provider,
httpStatus: storageError?.httpStatus ?? null,
retryAfterMs: storageError?.retryAfterMs ?? null,
transient: storageError?.transient ?? true,
};
}
} else if (author.hasPhoto) {
await this.authorImageStorage.deleteAuthorDir(author.id);
imageUpdated = true;
}
if (imageUpdated) fieldsUpdated.push('photo');
}
for (const field of ALL_AUTHOR_METADATA_FIELDS) {
if (field === 'photo' || !preferences.fields[field]?.enabled) continue;
const values = this.extractField(field, candidate, 'overwrite');
if (!values) continue;
Object.assign(update, values);
fieldsUpdated.push(field);
}
update.metadataProvider = candidate.provider;
update.metadataProviderId = candidate.providerId;
update.metadataProviderPinned = true;
update.hasPhoto = !!(await this.authorImageStorage.getThumbnailPath(author.id));
update.lastEnrichedAt = new Date();
await this.authorsRepo.updateAuthorById(author.id, update);
return { kind: 'applied', imageUpdated, fieldsUpdated, downloads };
}
private pinnedMatch(author: AuthorEnrichmentRow): PinnedMatch | null {
if (!author.metadataProviderPinned || !author.metadataProviderId) return null;
if (!author.metadataProvider || !AUTHOR_PROVIDER_KEYS.has(author.metadataProvider)) return null;
return { provider: author.metadataProvider as AuthorMetadataProviderKey, providerId: author.metadataProviderId };
}
/** A pinned author is looked up by id alone: a name search is what found the wrong person. */
private async collectPinned(pinned: PinnedMatch, directive: CacheDirective) {
const { candidate, failure, cacheServed, outboundRequests } = await this.authorMetadataFetchService.lookupByIdDetailed(
pinned.provider,
pinned.providerId,
'us',
{ directive },
);
const candidates = new Map<AuthorMetadataProviderKey, AuthorMetadataCandidate>(candidate ? [[pinned.provider, candidate]] : []);
return { candidates, failures: failure ? [failure] : [], cacheServed, outboundRequests };
}
private async applyCandidates(
author: AuthorEnrichmentRow,
preferences: AuthorMetadataPreferences,
@@ -143,7 +236,7 @@ export class AuthorEnrichmentExecutorService {
}
const attribution = winners.get('description') ?? winners.get('photo') ?? [...winners.values()][0] ?? null;
if (attribution) {
if (attribution && !author.metadataProviderPinned) {
const candidate = candidates.get(attribution);
update.metadataProvider = attribution;
update.metadataProviderId = candidate?.providerId ?? null;
@@ -4,10 +4,11 @@ import { BadRequestException } from '@nestjs/common';
import { createReadStream } from 'fs';
import { stat } from 'fs/promises';
import { of } from 'rxjs';
import { Permission } from '@bookorbit/types';
import { AuditAction, Permission } from '@bookorbit/types';
import { AUDITABLE_KEY } from '../../common/decorators/auditable.decorator';
import { FORBIDDEN_PERMISSION_KEY } from '../../common/decorators/forbid-permission.decorator';
import { PERMISSION_KEY } from '../../common/decorators/require-permission.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { AuthorsController } from './authors.controller';
import { BulkRefreshAuthorsMetadataDto } from './dto/bulk-refresh-authors-metadata.dto';
@@ -95,6 +96,8 @@ function makeController() {
uploadImage: vi.fn(),
deleteImage: vi.fn(),
refreshEnrichment: vi.fn(),
pinMetadataMatch: vi.fn(),
unpinMetadataMatch: vi.fn(),
update: vi.fn(),
merge: vi.fn(),
delete: vi.fn(),
@@ -378,6 +381,26 @@ describe('AuthorsController', () => {
await expect(controller.delete(user, { authorIds: [2] })).resolves.toEqual({ deletedAuthorIds: [2], affectedBookCount: 3 });
});
it('delegates metadata match pin and unpin, gated and audited like an author edit', async () => {
const { controller, authorsService } = makeController();
const user = makeUser();
const dto = { provider: 'goodreads', providerId: '4567' } as never;
authorsService.pinMetadataMatch.mockResolvedValue({ id: 9, metadataProviderPinned: true });
authorsService.unpinMetadataMatch.mockResolvedValue({ id: 9, metadataProviderPinned: false });
await expect(controller.pinMetadataMatch(user, 9, dto)).resolves.toEqual({ id: 9, metadataProviderPinned: true });
expect(authorsService.pinMetadataMatch).toHaveBeenCalledWith(user, 9, dto);
await expect(controller.unpinMetadataMatch(user, 9)).resolves.toEqual({ id: 9, metadataProviderPinned: false });
expect(authorsService.unpinMetadataMatch).toHaveBeenCalledWith(user, 9);
for (const handler of [AuthorsController.prototype.pinMetadataMatch, AuthorsController.prototype.unpinMetadataMatch]) {
expect(Reflect.getMetadata(PERMISSION_KEY, handler)).toBe(Permission.LibraryEditMetadata);
const audit = Reflect.getMetadata(AUDITABLE_KEY, handler) as { action: string; getResourceId: (req: unknown) => number };
expect(audit.action).toBe(AuditAction.AuthorUpdate);
expect(audit.getResourceId({ params: { id: '9' } })).toBe(9);
}
});
it('delegates delete image endpoint to AuthorsService', async () => {
const { controller, authorsService } = makeController();
const user = makeUser();
@@ -44,6 +44,7 @@ import { ListAuthorMetadataDto } from './dto/list-author-metadata.dto';
import { ListAuthorLettersDto, ListAuthorsDto } from './dto/list-authors.dto';
import { LookupAuthorMetadataDto } from './dto/lookup-author-metadata.dto';
import { MergeAuthorsDto } from './dto/merge-authors.dto';
import { PinAuthorMetadataMatchDto } from './dto/pin-author-metadata-match.dto';
import { PreviewAuthorEnrichmentCountDto } from './dto/preview-author-enrichment-count.dto';
import { UpdateAuthorDto } from './dto/update-author.dto';
import { AuthorsService } from './authors.service';
@@ -306,6 +307,30 @@ export class AuthorsController {
return this.authorsService.refreshEnrichment(user, id);
}
@Put(':id/metadata-match')
@RequirePermission(Permission.LibraryEditMetadata)
@Auditable({
action: AuditAction.AuthorUpdate,
resource: AuditResource.Author,
getResourceId: (req) => parseInt(req.params['id'], 10),
description: (req) => `Pinned the metadata match for author #${req.params['id']}`,
})
pinMetadataMatch(@CurrentUser() user: RequestUser, @Param('id', ParseIntPipe) id: number, @Body() dto: PinAuthorMetadataMatchDto) {
return this.authorsService.pinMetadataMatch(user, id, dto);
}
@Delete(':id/metadata-match')
@RequirePermission(Permission.LibraryEditMetadata)
@Auditable({
action: AuditAction.AuthorUpdate,
resource: AuditResource.Author,
getResourceId: (req) => parseInt(req.params['id'], 10),
description: (req) => `Unpinned the metadata match for author #${req.params['id']}`,
})
unpinMetadataMatch(@CurrentUser() user: RequestUser, @Param('id', ParseIntPipe) id: number) {
return this.authorsService.unpinMetadataMatch(user, id);
}
@Patch(':id')
@RequirePermission(Permission.LibraryEditMetadata)
@Auditable({
@@ -47,6 +47,7 @@ export type AuthorDetailRow = AuthorSummaryRow & {
influences: string[] | null;
metadataProvider: string | null;
metadataProviderId: string | null;
metadataProviderPinned: boolean;
};
export type AuthorEnrichmentRow = AuthorSummaryRow & {
@@ -58,6 +59,9 @@ export type AuthorEnrichmentRow = AuthorSummaryRow & {
website: string | null;
genres: string[] | null;
influences: string[] | null;
metadataProvider: string | null;
metadataProviderId: string | null;
metadataProviderPinned: boolean;
};
@Injectable()
@@ -188,6 +192,7 @@ export class AuthorsRepository {
influences: authors.influences,
metadataProvider: authors.metadataProvider,
metadataProviderId: authors.metadataProviderId,
metadataProviderPinned: authors.metadataProviderPinned,
bookCount: sql<number>`count(distinct ${books.id})::int`,
lastAddedAt: max(books.addedAt),
})
@@ -209,6 +214,7 @@ export class AuthorsRepository {
authors.influences,
authors.metadataProvider,
authors.metadataProviderId,
authors.metadataProviderPinned,
)
.limit(1);
@@ -230,6 +236,9 @@ export class AuthorsRepository {
website: authors.website,
genres: authors.genres,
influences: authors.influences,
metadataProvider: authors.metadataProvider,
metadataProviderId: authors.metadataProviderId,
metadataProviderPinned: authors.metadataProviderPinned,
bookCount: sql<number>`count(distinct ${books.id})::int`,
lastAddedAt: max(books.addedAt),
})
@@ -250,6 +259,9 @@ export class AuthorsRepository {
authors.website,
authors.genres,
authors.influences,
authors.metadataProvider,
authors.metadataProviderId,
authors.metadataProviderPinned,
)
.limit(1);
return row ?? null;
@@ -333,6 +345,7 @@ export class AuthorsRepository {
influences: string[] | null;
metadataProvider: string | null;
metadataProviderId: string | null;
metadataProviderPinned: boolean;
lastEnrichedAt: Date | null;
}>,
) {
@@ -1,4 +1,4 @@
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
import { AUTHOR_ENRICHMENT_REASONS } from './author-enrichment-reasons';
@@ -72,6 +72,7 @@ describe('AuthorsService', () => {
listProviders: vi.fn(),
search: vi.fn(),
lookupById: vi.fn(),
lookupByIdDetailed: vi.fn(),
stream: vi.fn(),
quickSearch: vi.fn(),
};
@@ -88,6 +89,7 @@ describe('AuthorsService', () => {
const enrichmentExecutor = {
execute: vi.fn(),
applyMatch: vi.fn(),
};
const enrichmentOrchestrator = {
@@ -796,6 +798,181 @@ describe('AuthorsService', () => {
await expect(service.update(reqUser(), 31, { name: ' ' })).rejects.toBeInstanceOf(BadRequestException);
});
describe('author facts', () => {
beforeEach(() => {
authorsRepo.findVisibleAuthorIds.mockResolvedValue([32]);
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]);
authorsRepo.updateAuthorById.mockResolvedValue({ id: 32 });
authorsRepo.findById.mockResolvedValue({ id: 32, name: 'John Reid', sortName: null, description: null, bookCount: 3, lastAddedAt: null });
});
it('update writes facts, takes the year from a full date and tidies lists', async () => {
await service.update(reqUser(), 32, {
website: ' https://johnreid.example ',
birthDate: '1965-03-14',
birthYear: 1900,
deathYear: 2020,
genres: [' Thriller ', 'thriller', '', 'Military fiction'],
influences: [' '],
});
expect(authorsRepo.updateAuthorById).toHaveBeenCalledWith(32, {
website: 'https://johnreid.example',
birthDate: '1965-03-14',
birthYear: 1965,
deathDate: null,
deathYear: 2020,
genres: ['Thriller', 'Military fiction'],
influences: null,
});
expect(enrichmentOrchestrator.schedule).not.toHaveBeenCalled();
});
it('update clears facts sent as null and keeps a year sent beside a cleared date', async () => {
await service.update(reqUser(), 32, { website: null, birthDate: null, birthYear: 1965, deathDate: null, genres: null });
expect(authorsRepo.updateAuthorById).toHaveBeenCalledWith(32, {
website: null,
birthDate: null,
birthYear: 1965,
deathDate: null,
genres: null,
});
});
it.each(['1965-02-30', '1965-13-01', '0000-01-01'])('update rejects the impossible date %s', async (birthDate) => {
await expect(service.update(reqUser(), 32, { birthDate })).rejects.toBeInstanceOf(BadRequestException);
expect(authorsRepo.updateAuthorById).not.toHaveBeenCalled();
});
it('update accepts a leap day and a first-century date', async () => {
await service.update(reqUser(), 32, { birthDate: '2024-02-29', deathDate: '0065-04-12' });
expect(authorsRepo.updateAuthorById).toHaveBeenCalledWith(32, {
birthDate: '2024-02-29',
birthYear: 2024,
deathDate: '0065-04-12',
deathYear: 65,
});
});
});
describe('metadata match', () => {
const enrichmentRow = { id: 33, name: 'John Reid', hasPhoto: true, bookCount: 2, metadataProviderPinned: false };
const candidate = { provider: 'goodreads', providerId: '4567', name: 'John Reid', description: 'Scottish novelist.' };
beforeEach(() => {
authorsRepo.findVisibleAuthorIds.mockResolvedValue([33]);
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]);
authorsRepo.findByIdForEnrichment.mockResolvedValue(enrichmentRow);
authorsRepo.findById.mockResolvedValue({
id: 33,
name: 'John Reid',
sortName: null,
description: 'Scottish novelist.',
bookCount: 2,
lastAddedAt: null,
metadataProvider: 'goodreads',
metadataProviderId: '4567',
metadataProviderPinned: true,
});
});
it('pins the looked-up person and returns the replaced author', async () => {
authorMetadataFetchService.lookupByIdDetailed.mockResolvedValue({ candidate, failure: null, cacheServed: [], outboundRequests: 1 });
enrichmentExecutor.applyMatch.mockResolvedValue({ kind: 'applied', imageUpdated: true, fieldsUpdated: ['description', 'photo'], downloads: 1 });
const result = await service.pinMetadataMatch(reqUser(), 33, { provider: 'goodreads', providerId: '4567' } as any);
expect(authorMetadataFetchService.lookupByIdDetailed).toHaveBeenCalledWith('goodreads', '4567', 'us', {
directive: { policy: 'refresh', allowFallback: true },
});
expect(enrichmentExecutor.applyMatch).toHaveBeenCalledWith(enrichmentRow, expect.objectContaining({ fields: expect.any(Object) }), candidate);
expect(result).toEqual(expect.objectContaining({ id: 33, metadataProviderPinned: true }));
expect(result).not.toHaveProperty('metadataCache');
});
it('reports a cache fallback the same way a refresh does', async () => {
const served = { provider: 'goodreads', source: 'fallback', fetchedAt: '2026-10-01T00:00:00.000Z' };
authorMetadataFetchService.lookupByIdDetailed.mockResolvedValue({ candidate, failure: null, cacheServed: [served], outboundRequests: 1 });
enrichmentExecutor.applyMatch.mockResolvedValue({ kind: 'applied', imageUpdated: false, fieldsUpdated: [], downloads: 0 });
const result = await service.pinMetadataMatch(reqUser(), 33, { provider: 'goodreads', providerId: '4567' } as any);
expect(result.metadataCache).toEqual({ fallbackProviders: [served] });
});
it('answers 404 when the id matches nobody and leaves the author alone', async () => {
authorMetadataFetchService.lookupByIdDetailed.mockResolvedValue({ candidate: null, failure: null, cacheServed: [], outboundRequests: 1 });
await expect(service.pinMetadataMatch(reqUser(), 33, { provider: 'goodreads', providerId: '999' } as any)).rejects.toBeInstanceOf(
NotFoundException,
);
expect(enrichmentExecutor.applyMatch).not.toHaveBeenCalled();
});
it('answers 503 when the provider fails rather than calling it a missing author', async () => {
authorMetadataFetchService.lookupByIdDetailed.mockResolvedValue({
candidate: null,
failure: { provider: 'goodreads', message: 'HTTP 503', httpStatus: 503, retryAfterMs: null, transient: true },
cacheServed: [],
outboundRequests: 1,
});
await expect(service.pinMetadataMatch(reqUser(), 33, { provider: 'goodreads', providerId: '4567' } as any)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
expect(enrichmentExecutor.applyMatch).not.toHaveBeenCalled();
});
it('answers 404 when the provider reports that the id does not exist', async () => {
authorMetadataFetchService.lookupByIdDetailed.mockResolvedValue({
candidate: null,
failure: { provider: 'goodreads', message: 'HTTP 404', httpStatus: 404, retryAfterMs: null, transient: false },
cacheServed: [],
outboundRequests: 1,
});
await expect(service.pinMetadataMatch(reqUser(), 33, { provider: 'goodreads', providerId: '999' } as any)).rejects.toBeInstanceOf(
NotFoundException,
);
expect(enrichmentExecutor.applyMatch).not.toHaveBeenCalled();
});
it('answers 503 when the portrait download fails', async () => {
authorMetadataFetchService.lookupByIdDetailed.mockResolvedValue({ candidate, failure: null, cacheServed: [], outboundRequests: 1 });
enrichmentExecutor.applyMatch.mockResolvedValue({
kind: 'failed',
message: 'HTTP 429',
provider: 'goodreads',
httpStatus: 429,
retryAfterMs: 1000,
transient: true,
});
await expect(service.pinMetadataMatch(reqUser(), 33, { provider: 'goodreads', providerId: '4567' } as any)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
it('checks library access before looking anything up', async () => {
libraryService.findEditableLibraryIds.mockResolvedValue([]);
await expect(service.pinMetadataMatch(reqUser(), 33, { provider: 'goodreads', providerId: '4567' } as any)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(authorMetadataFetchService.lookupByIdDetailed).not.toHaveBeenCalled();
});
it('unpins without touching the metadata', async () => {
authorsRepo.updateAuthorById.mockResolvedValue({ id: 33 });
await service.unpinMetadataMatch(reqUser(), 33);
expect(authorsRepo.updateAuthorById).toHaveBeenCalledWith(33, { metadataProviderPinned: false });
});
});
it('get image paths verify readability before touching storage', async () => {
authorsRepo.findVisibleAuthorIds.mockResolvedValue([40]);
authorImageStorage.getThumbnailPath.mockResolvedValue('/tmp/thumb.jpg');
@@ -41,6 +41,7 @@ import { ListAuthorMetadataDto } from './dto/list-author-metadata.dto';
import { ListAuthorLettersDto, ListAuthorsDto } from './dto/list-authors.dto';
import { LookupAuthorMetadataDto } from './dto/lookup-author-metadata.dto';
import { MergeAuthorsDto } from './dto/merge-authors.dto';
import { PinAuthorMetadataMatchDto } from './dto/pin-author-metadata-match.dto';
import { UpdateAuthorDto } from './dto/update-author.dto';
import { AuthorMetadataFetchService } from './metadata/author-metadata-fetch.service';
@@ -55,6 +56,47 @@ type EnrichmentRefreshOutcome = {
cacheServed: MetadataCacheServedProvider[];
};
type LifeDateUpdate = { date?: string | null; year?: number | null };
/** A full date carries its own year, and a year sent on its own clears a date that may name another. */
function resolveLifeDate(
field: string,
hasDate: boolean,
date: string | null | undefined,
hasYear: boolean,
year: number | null | undefined,
): LifeDateUpdate {
if (hasDate && date) {
if (!isCalendarDate(date)) throw new BadRequestException(`${field} is not a calendar date`);
return { date, year: Number(date.slice(0, 4)) };
}
if (hasDate) return hasYear ? { date: null, year: year ?? null } : { date: null };
if (hasYear) return { date: null, year: year ?? null };
return {};
}
function isCalendarDate(value: string): boolean {
const [year, month, day] = value.split('-').map(Number);
if (year < 1) return false;
// setUTCFullYear, because Date.UTC reads years below 100 as 19xx.
const parsed = new Date(0);
parsed.setUTCFullYear(year, month - 1, day);
return parsed.getUTCFullYear() === year && parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day;
}
function normalizeAuthorList(values: string[] | null | undefined): string[] | null {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values ?? []) {
const trimmed = value.trim();
const key = trimmed.toLocaleLowerCase();
if (!trimmed || seen.has(key)) continue;
seen.add(key);
result.push(trimmed);
}
return result.length > 0 ? result : null;
}
@Injectable()
export class AuthorsService {
static readonly MAX_AUTHOR_IMAGE_BYTES = 20 * 1024 * 1024;
@@ -337,6 +379,26 @@ export class AuthorsService {
values.description = dto.description?.trim() || null;
}
if ('website' in dto) {
values.website = dto.website?.trim() || null;
}
const birth = resolveLifeDate('birthDate', 'birthDate' in dto, dto.birthDate, 'birthYear' in dto, dto.birthYear);
if (birth.date !== undefined) values.birthDate = birth.date;
if (birth.year !== undefined) values.birthYear = birth.year;
const death = resolveLifeDate('deathDate', 'deathDate' in dto, dto.deathDate, 'deathYear' in dto, dto.deathYear);
if (death.date !== undefined) values.deathDate = death.date;
if (death.year !== undefined) values.deathYear = death.year;
if ('genres' in dto) {
values.genres = normalizeAuthorList(dto.genres);
}
if ('influences' in dto) {
values.influences = normalizeAuthorList(dto.influences);
}
const fieldNames = Object.keys(values);
if (fieldNames.length === 0) {
const detail = await this.findOne(user, authorId);
@@ -466,6 +528,57 @@ export class AuthorsService {
}
}
/** Points the author at one provider's person, replacing its metadata, and keeps refreshes on that id. */
async pinMetadataMatch(user: RequestUser, authorId: number, dto: PinAuthorMetadataMatchDto): Promise<AuthorRefreshResult> {
const event = 'author.pin_metadata_match';
const startedAt = Date.now();
this.logger.log(
`[${event}] [start] userId=${user.id} authorId=${authorId} provider=${dto.provider} providerId=${JSON.stringify(dto.providerId)} - author metadata match pin started`,
);
try {
await this.assertMutationAccess(user, [authorId]);
const author = await this.authorsRepo.findByIdForEnrichment(authorId);
if (!author) throw new NotFoundException('Author not found');
const lookup = await this.authorMetadataFetchService.lookupByIdDetailed(dto.provider, dto.providerId, 'us', {
directive: SINGLE_REFRESH_CACHE_DIRECTIVE,
});
if (lookup.failure) {
if (lookup.failure.httpStatus === 404) throw new NotFoundException(`No ${dto.provider} author has that id`);
throw new ServiceUnavailableException(`Author lookup failed for provider ${dto.provider}: ${lookup.failure.message}`);
}
if (!lookup.candidate) throw new NotFoundException(`No ${dto.provider} author has that id`);
const preferences = await this.metadataPreferences.getPreferences();
const applied = await this.enrichmentExecutor.applyMatch(author, preferences, lookup.candidate);
if (applied.kind === 'failed') {
throw new ServiceUnavailableException(`Author image download failed for provider ${dto.provider}: ${applied.message}`);
}
const detail = await this.findOne(user, authorId);
const fallbackProviders = lookup.cacheServed.filter((served) => served.source === 'fallback');
this.logger.log(
`[${event}] [end] userId=${user.id} authorId=${authorId} provider=${dto.provider} durationMs=${Date.now() - startedAt} fields=${applied.fieldsUpdated.join(',') || 'none'} imageUpdated=${applied.imageUpdated} cacheFallbacks=${fallbackProviders.length} - author metadata match pin completed`,
);
return fallbackProviders.length > 0 ? { ...detail, metadataCache: { fallbackProviders } } : detail;
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] userId=${user.id} authorId=${authorId} provider=${dto.provider} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - author metadata match pin failed`,
);
throw err;
}
}
/** Lets refreshes search by name again; the metadata and the provider id stay as they are. */
async unpinMetadataMatch(user: RequestUser, authorId: number): Promise<AuthorDetail> {
await this.assertMutationAccess(user, [authorId]);
const updated = await this.authorsRepo.updateAuthorById(authorId, { metadataProviderPinned: false });
if (!updated) throw new NotFoundException('Author not found');
return this.findOne(user, authorId);
}
async getThumbnailPath(user: RequestUser, authorId: number): Promise<string | null> {
await this.assertAuthorReadable(user, [authorId]);
return this.authorImageStorage.getThumbnailPath(authorId);
@@ -680,6 +793,7 @@ export class AuthorsService {
influences: row.influences ?? [],
metadataProvider: (row.metadataProvider as AuthorDetail['metadataProvider']) ?? null,
metadataProviderId: row.metadataProviderId,
metadataProviderPinned: row.metadataProviderPinned,
};
}
@@ -14,6 +14,7 @@ import { ListAuthorMetadataDto } from './list-author-metadata.dto';
import { ListAuthorsDto } from './list-authors.dto';
import { LookupAuthorMetadataDto } from './lookup-author-metadata.dto';
import { MergeAuthorsDto } from './merge-authors.dto';
import { PinAuthorMetadataMatchDto } from './pin-author-metadata-match.dto';
import { PreviewAuthorEnrichmentCountDto } from './preview-author-enrichment-count.dto';
import { UpdateAuthorDto } from './update-author.dto';
@@ -120,6 +121,45 @@ describe('Authors DTO validation', () => {
expect((await errorsFor(UpdateAuthorDto, { name: 'x'.repeat(501) })).length).toBeGreaterThan(0);
});
it('accepts author facts and clearing them with null', async () => {
expect(
(
await errorsFor(UpdateAuthorDto, {
website: 'https://johnreid.example/about',
birthDate: '1965-03-14',
birthYear: 1965,
deathDate: null,
deathYear: null,
genres: ['Thriller', 'Military fiction'],
influences: ['Alistair MacLean'],
})
).length,
).toBe(0);
expect((await errorsFor(UpdateAuthorDto, { website: null, birthDate: null, genres: null, influences: null })).length).toBe(0);
expect((await errorsFor(UpdateAuthorDto, { birthYear: -800 })).length).toBe(0);
});
it('rejects unsafe or malformed author facts', async () => {
expect((await errorsFor(UpdateAuthorDto, { website: 'javascript:alert(1)' })).length).toBeGreaterThan(0);
expect((await errorsFor(UpdateAuthorDto, { website: 'johnreid.example' })).length).toBeGreaterThan(0);
expect((await errorsFor(UpdateAuthorDto, { website: 'ftp://johnreid.example' })).length).toBeGreaterThan(0);
expect((await errorsFor(UpdateAuthorDto, { birthDate: '14/03/1965' })).length).toBeGreaterThan(0);
expect((await errorsFor(UpdateAuthorDto, { birthYear: 1965.5 })).length).toBeGreaterThan(0);
expect((await errorsFor(UpdateAuthorDto, { deathYear: 10_000 })).length).toBeGreaterThan(0);
expect((await errorsFor(UpdateAuthorDto, { genres: 'Thriller' })).length).toBeGreaterThan(0);
expect((await errorsFor(UpdateAuthorDto, { genres: Array.from({ length: 51 }, (_, i) => `g${i}`) })).length).toBeGreaterThan(0);
expect((await errorsFor(UpdateAuthorDto, { influences: ['x'.repeat(501)] })).length).toBeGreaterThan(0);
});
it('validates a metadata match pin', async () => {
expect((await errorsFor(PinAuthorMetadataMatchDto, { provider: AuthorMetadataProviderKey.GOODREADS, providerId: '12345' })).length).toBe(0);
expect((await errorsFor(PinAuthorMetadataMatchDto, { provider: 'openlibrary', providerId: '12345' })).length).toBeGreaterThan(0);
expect((await errorsFor(PinAuthorMetadataMatchDto, { provider: AuthorMetadataProviderKey.GOODREADS, providerId: '' })).length).toBeGreaterThan(0);
expect(
(await errorsFor(PinAuthorMetadataMatchDto, { provider: AuthorMetadataProviderKey.AUDNEXUS, providerId: 'B'.repeat(129) })).length,
).toBeGreaterThan(0);
});
it('requires enrichment preview conditions and boolean flags', async () => {
expect(
(
@@ -0,0 +1,13 @@
import { IsEnum, IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { AuthorMetadataProviderKey } from '@bookorbit/types';
/** Arrived with the `author-metadata-match` server feature. */
export class PinAuthorMetadataMatchDto {
@IsEnum(AuthorMetadataProviderKey)
provider!: AuthorMetadataProviderKey;
@IsString()
@IsNotEmpty()
@MaxLength(128)
providerId!: string;
}
@@ -1,5 +1,13 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';
import { ArrayMaxSize, IsArray, IsInt, IsOptional, IsString, IsUrl, Matches, Max, MaxLength, Min } from 'class-validator';
const MAX_AUTHOR_LIST_ITEMS = 50;
const MIN_LIFE_YEAR = -9999;
const MAX_LIFE_YEAR = 9999;
/**
* Every field beyond `name`, `sortName` and `description` arrived with the `author-metadata-match`
* server feature; clients must only send them to servers advertising it.
*/
export class UpdateAuthorDto {
@IsOptional()
@IsString()
@@ -15,4 +23,48 @@ export class UpdateAuthorDto {
@IsString()
@MaxLength(10_000)
description?: string | null;
@IsOptional()
@IsString()
@MaxLength(2048)
@IsUrl({ protocols: ['http', 'https'], require_protocol: true })
website?: string | null;
/** A full date; its year replaces `birthYear`. */
@IsOptional()
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
birthDate?: string | null;
/** Sent without `birthDate`, it clears the full date, which may name another year. */
@IsOptional()
@IsInt()
@Min(MIN_LIFE_YEAR)
@Max(MAX_LIFE_YEAR)
birthYear?: number | null;
@IsOptional()
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
deathDate?: string | null;
@IsOptional()
@IsInt()
@Min(MIN_LIFE_YEAR)
@Max(MAX_LIFE_YEAR)
deathYear?: number | null;
@IsOptional()
@IsArray()
@ArrayMaxSize(MAX_AUTHOR_LIST_ITEMS)
@IsString({ each: true })
@MaxLength(200, { each: true })
genres?: string[] | null;
@IsOptional()
@IsArray()
@ArrayMaxSize(MAX_AUTHOR_LIST_ITEMS)
@IsString({ each: true })
@MaxLength(500, { each: true })
influences?: string[] | null;
}
@@ -306,6 +306,20 @@ describe('AuthorMetadataFetchService', () => {
await expect(service.lookupById('audnexus', '42')).resolves.toBeNull();
});
it('lookupByIdDetailed reports a provider failure apart from an id that matched nobody', async () => {
const lookupById = vi
.fn()
.mockRejectedValueOnce(new AuthorMetadataProviderError('HTTP 503', { httpStatus: 503, transient: true }))
.mockResolvedValueOnce(null);
registry.find.mockReturnValue({ key: 'audnexus', label: 'Audnexus', identifiable: true, search: vi.fn(), lookupById });
await expect(service.lookupByIdDetailed('audnexus', '42')).resolves.toMatchObject({
candidate: null,
failure: { provider: 'audnexus', message: 'HTTP 503', httpStatus: 503, transient: true },
});
await expect(service.lookupByIdDetailed('audnexus', '43')).resolves.toMatchObject({ candidate: null, failure: null });
});
describe('cache integration', () => {
function cachedProvider(spec = cacheSpec()) {
return {
@@ -57,6 +57,8 @@ export type AuthorMetadataSearchResult = AuthorMetadataFetchProvenance & {
export type AuthorMetadataLookupResult = AuthorMetadataFetchProvenance & {
candidate: AuthorMetadataSearchCandidateEvent | null;
/** Set when the provider failed, so a caller can tell an outage from an id that matched nobody. */
failure: AuthorMetadataFetchFailure | null;
};
export type AuthorMetadataCollectResult = AuthorMetadataFetchProvenance & {
@@ -206,12 +208,13 @@ export class AuthorMetadataFetchService {
options?: LookupOptions,
): Promise<AuthorMetadataLookupResult> {
const provider = this.registry.find(key);
if (!provider || !isIdentifiableAuthorProvider(provider)) return { candidate: null, ...summarize([]) };
if (!provider || !isIdentifiableAuthorProvider(provider)) return { candidate: null, failure: null, ...summarize([]) };
const run = await this.startRun(provider, options?.directive ?? DEFAULT_CACHE_DIRECTIVE);
const outcome = await run.lookup(canonicalId(providerId) ?? '', { name: '', region: canonicalAuthorRegion(region) });
const candidate = outcome.ok && outcome.value ? withCache(outcome.value, outcome.cache) : null;
return { candidate, ...summarize([run]) };
if (!outcome.ok) return { candidate: null, failure: this.toFailure(key, outcome.error), ...summarize([run]) };
const candidate = outcome.value ? withCache(outcome.value, outcome.cache) : null;
return { candidate, failure: null, ...summarize([run]) };
}
/** The per-operation deadline; today's provider timeout, now enforced by the executor. */
@@ -35,7 +35,7 @@ const AUDIO_FORMAT_SQL = sql.join(
sql`, `,
);
type OuterBookId = number | typeof books.id | typeof bookMetadata.bookId;
type OuterBookId = number | typeof books.id | typeof bookMetadata.bookId | typeof bookCovers.bookId;
/**
* Drizzle leaves a column unqualified when the outer query reads a single table, and inside these
+2 -2
View File
@@ -1620,7 +1620,7 @@ export class BookRepository {
absolutePath?: string;
relPath?: string | null;
sizeBytes?: number;
mediaOverlayAvailable?: boolean | null;
mediaOverlayAvailable?: boolean;
mediaOverlayDurationSeconds?: number | null;
mediaOverlayCheckedAt?: Date | null;
},
@@ -2158,7 +2158,7 @@ export class BookRepository {
absolutePath: string;
format: string | null;
sizeBytes: number | null;
sortOrder: number;
sortOrder: number | null;
mediaOverlayAvailable: boolean;
}[]
> {
+3 -1
View File
@@ -262,6 +262,8 @@ export class UploadService {
const ino = fileStat.ino;
const relPath = relative(bookRow.libraryFolderPath, destination);
const mediaOverlayFields = await this.inspectMediaOverlayFields(destination, format);
// The transaction callback cannot see the narrowing of the outer `let`.
const absolutePath = destination;
const { inserted, isPrimary, finalStatus } = await this.db.transaction(async (tx) => {
const [lockedBook] = await tx
@@ -283,7 +285,7 @@ export class UploadService {
.values({
bookId,
libraryFolderId: bookRow.libraryFolderId,
absolutePath: destination,
absolutePath,
relPath,
ino,
sizeBytes,
@@ -1,10 +1,10 @@
{
"totalRoutes": 665,
"totalRoutes": 667,
"byPermission": {
"(none)": 287,
"Permission.ManageMetadataConfig": 41,
"Permission.EmailSend": 34,
"Permission.LibraryEditMetadata": 35,
"Permission.LibraryEditMetadata": 37,
"Permission.BookDockAccess": 17,
"Permission.ManageAppSettings": 62,
"Permission.ManageLibraries": 24,
@@ -47,6 +47,30 @@
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/authors/authors.controller.ts",
"className": "AuthorsController",
"methodName": "pinMetadataMatch",
"httpMethod": "PUT",
"path": "/authors/:id/metadata-match",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/authors/authors.controller.ts",
"className": "AuthorsController",
"methodName": "unpinMetadataMatch",
"httpMethod": "DELETE",
"path": "/authors/:id/metadata-match",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/authors/authors.controller.ts",
"className": "AuthorsController",