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