diff --git a/client/src/components/SelectionActionBar.vue b/client/src/components/SelectionActionBar.vue index 77e4ccd57..bbbda404b 100644 --- a/client/src/components/SelectionActionBar.vue +++ b/client/src/components/SelectionActionBar.vue @@ -1,5 +1,5 @@ + + diff --git a/client/src/features/metadata-cache/components/MetadataCacheBadge.vue b/client/src/features/metadata-cache/components/MetadataCacheBadge.vue new file mode 100644 index 000000000..a18f78f68 --- /dev/null +++ b/client/src/features/metadata-cache/components/MetadataCacheBadge.vue @@ -0,0 +1,36 @@ + + + diff --git a/client/src/features/metadata-cache/components/MetadataCacheNotice.vue b/client/src/features/metadata-cache/components/MetadataCacheNotice.vue new file mode 100644 index 000000000..9d72f166e --- /dev/null +++ b/client/src/features/metadata-cache/components/MetadataCacheNotice.vue @@ -0,0 +1,42 @@ + + + diff --git a/client/src/features/metadata-cache/components/SearchFreshButton.vue b/client/src/features/metadata-cache/components/SearchFreshButton.vue new file mode 100644 index 000000000..efeefd3e6 --- /dev/null +++ b/client/src/features/metadata-cache/components/SearchFreshButton.vue @@ -0,0 +1,29 @@ + + + diff --git a/client/src/features/metadata-cache/components/__tests__/MetadataCacheComponents.spec.ts b/client/src/features/metadata-cache/components/__tests__/MetadataCacheComponents.spec.ts new file mode 100644 index 000000000..ce60005f7 --- /dev/null +++ b/client/src/features/metadata-cache/components/__tests__/MetadataCacheComponents.spec.ts @@ -0,0 +1,55 @@ +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { activateI18nLocale } from '@/i18n' +import IgnoreCacheCheckbox from '../IgnoreCacheCheckbox.vue' +import MetadataCacheBadge from '../MetadataCacheBadge.vue' +import MetadataCacheNotice from '../MetadataCacheNotice.vue' + +describe('metadata cache components', () => { + beforeEach(() => { + activateI18nLocale('en') + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-10-10T12:00:00.000Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('says how old the oldest saved result is and emits searchFresh', async () => { + const wrapper = mount(MetadataCacheNotice, { + props: { summary: { cachedCount: 2, staleCount: 0, oldestFetchedAt: '2026-10-07T12:00:00.000Z' } }, + }) + + expect(wrapper.get('[role="status"]').text()).toBe('Some results came from saved searches (oldest: 3 days ago).') + await wrapper.get('button').trigger('click') + expect(wrapper.emitted('searchFresh')).toHaveLength(1) + }) + + it('explains stale results when a provider was unavailable', () => { + const wrapper = mount(MetadataCacheNotice, { + props: { summary: { cachedCount: 1, staleCount: 1, oldestFetchedAt: '2026-10-09T12:00:00.000Z' } }, + }) + + expect(wrapper.get('[role="status"]').text()).toContain('unavailable') + }) + + it('marks cached and stale results differently', () => { + const cached = mount(MetadataCacheBadge, { props: { info: { fetchedAt: '2026-10-09T12:00:00.000Z', stale: false } } }) + const stale = mount(MetadataCacheBadge, { props: { info: { fetchedAt: '2026-10-09T12:00:00.000Z', stale: true } } }) + + expect(cached.get('[data-testid="metadata-cache-badge"]').attributes('data-stale')).toBe('false') + expect(stale.get('[data-testid="metadata-cache-badge"]').attributes('data-stale')).toBe('true') + expect(cached.text()).toContain('Cached') + expect(stale.text()).toContain('Stale') + }) + + it('binds the ignore-cache checkbox and describes it', async () => { + const wrapper = mount(IgnoreCacheCheckbox, { props: { modelValue: false } }) + const input = wrapper.get('input[type="checkbox"]') + + expect(input.attributes('aria-describedby')).toBeTruthy() + await input.setValue(true) + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true]) + }) +}) diff --git a/client/src/features/metadata-cache/lib/__tests__/cache-feedback.spec.ts b/client/src/features/metadata-cache/lib/__tests__/cache-feedback.spec.ts new file mode 100644 index 000000000..b3c5a009e --- /dev/null +++ b/client/src/features/metadata-cache/lib/__tests__/cache-feedback.spec.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { activateI18nLocale } from '@/i18n' + +const toastWarning = vi.hoisted(() => vi.fn<(message: string, options?: unknown) => void>()) + +vi.mock('vue-sonner', () => ({ toast: { warning: toastWarning } })) + +import { cacheFallbackMessage, showCacheFallbackToast } from '../cache-feedback' + +function fallback(provider: string) { + return { provider, source: 'fallback' as const, stale: true, cachedAt: '2026-10-01T00:00:00.000Z' } +} + +describe('cache fallback feedback', () => { + beforeEach(() => { + activateI18nLocale('en') + toastWarning.mockReset() + }) + + it('names each unavailable provider once, using the label resolver', () => { + const message = cacheFallbackMessage([fallback('google'), fallback('google'), fallback('amazon')], (provider) => provider.toUpperCase()) + expect(message).toBe('GOOGLE and AMAZON were unavailable, so saved results were used.') + }) + + it('uses the singular form for one provider', () => { + expect(cacheFallbackMessage([fallback('goodreads')])).toBe('goodreads was unavailable, so saved results were used.') + }) + + it('shows a warning only when a provider fell back', () => { + expect(showCacheFallbackToast([])).toBe(false) + expect(toastWarning).not.toHaveBeenCalled() + + expect(showCacheFallbackToast([fallback('google')])).toBe(true) + expect(toastWarning).toHaveBeenCalledWith('google was unavailable, so saved results were used.', { closeButton: true }) + }) +}) diff --git a/client/src/features/metadata-cache/lib/__tests__/metadata-cache.spec.ts b/client/src/features/metadata-cache/lib/__tests__/metadata-cache.spec.ts new file mode 100644 index 000000000..e72f31f8e --- /dev/null +++ b/client/src/features/metadata-cache/lib/__tests__/metadata-cache.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import type { MetadataFetchDiagnostics } from '@bookorbit/types' +import { + diagnosticsFallbackProviders, + ignoreCachePostInit, + splitCandidateCache, + splitMetadataCacheUsage, + summarizeCandidateCache, + usageFallbackProviders, + withIgnoreCache, +} from '../metadata-cache' + +describe('summarizeCandidateCache', () => { + it('counts cached and stale answers and finds the oldest', () => { + expect( + summarizeCandidateCache([ + { fetchedAt: '2026-10-05T00:00:00.000Z', stale: false }, + null, + { fetchedAt: '2026-09-01T00:00:00.000Z', stale: true }, + undefined, + { fetchedAt: '2026-10-09T00:00:00.000Z', stale: false }, + ]), + ).toEqual({ cachedCount: 3, staleCount: 1, oldestFetchedAt: '2026-09-01T00:00:00.000Z' }) + }) + + it('ignores an unreadable date when picking the oldest answer', () => { + expect(summarizeCandidateCache([{ fetchedAt: 'not a date', stale: false }])).toEqual({ cachedCount: 1, staleCount: 0, oldestFetchedAt: null }) + }) + + it('reports nothing for live results', () => { + expect(summarizeCandidateCache([null, undefined])).toEqual({ cachedCount: 0, staleCount: 0, oldestFetchedAt: null }) + }) +}) + +describe('splitCandidateCache', () => { + it('takes the marker off the candidate', () => { + const { candidate, cache } = splitCandidateCache({ + provider: 'google', + providerId: 'g1', + cache: { fetchedAt: '2026-10-01T00:00:00.000Z', stale: true }, + }) + expect(candidate).toEqual({ provider: 'google', providerId: 'g1' }) + expect(cache).toEqual({ fetchedAt: '2026-10-01T00:00:00.000Z', stale: true }) + }) + + it('treats a missing or malformed marker as a live answer', () => { + expect(splitCandidateCache({ provider: 'google' }).cache).toBeNull() + expect(splitCandidateCache({ provider: 'google', cache: 'yesterday' } as never).cache).toBeNull() + }) +}) + +describe('refresh responses', () => { + it('splits the cache report from the record', () => { + const usage = { fallbackProviders: [{ provider: 'google', source: 'fallback' as const, stale: true, cachedAt: '2026-10-01T00:00:00.000Z' }] } + expect(splitMetadataCacheUsage({ id: 1, metadataCache: usage })).toEqual({ value: { id: 1 }, usage }) + expect(splitMetadataCacheUsage({ id: 1 })).toEqual({ value: { id: 1 }, usage: null }) + }) + + it('keeps only fallback providers', () => { + const served = [ + { provider: 'google', source: 'cache' as const, stale: false, cachedAt: '2026-10-01T00:00:00.000Z' }, + { provider: 'amazon', source: 'fallback' as const, stale: true, cachedAt: '2026-09-01T00:00:00.000Z' }, + ] + expect(usageFallbackProviders({ fallbackProviders: served }).map((entry) => entry.provider)).toEqual(['amazon']) + expect(diagnosticsFallbackProviders({ cacheServedProviders: served } as MetadataFetchDiagnostics).map((entry) => entry.provider)).toEqual([ + 'amazon', + ]) + expect(diagnosticsFallbackProviders({} as MetadataFetchDiagnostics)).toEqual([]) + expect(usageFallbackProviders(null)).toEqual([]) + }) +}) + +describe('ignore cache requests', () => { + it('leaves a trigger POST bodiless unless the cache is ignored', () => { + expect(ignoreCachePostInit()).toEqual({ method: 'POST' }) + expect(ignoreCachePostInit({ ignoreCache: false })).toEqual({ method: 'POST' }) + expect(ignoreCachePostInit({ ignoreCache: true })).toEqual({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ignoreCache: true }), + }) + }) + + it('adds ignoreCache to a body only when it is true', () => { + expect(withIgnoreCache({ bookIds: [1] })).toEqual({ bookIds: [1] }) + expect(withIgnoreCache({ bookIds: [1] }, { ignoreCache: false })).toEqual({ bookIds: [1] }) + expect(withIgnoreCache({ bookIds: [1] }, { ignoreCache: true })).toEqual({ bookIds: [1], ignoreCache: true }) + }) +}) diff --git a/client/src/features/metadata-cache/lib/cache-feedback.ts b/client/src/features/metadata-cache/lib/cache-feedback.ts new file mode 100644 index 000000000..d0590e23d --- /dev/null +++ b/client/src/features/metadata-cache/lib/cache-feedback.ts @@ -0,0 +1,34 @@ +import { toast } from 'vue-sonner' +import type { MetadataCacheServedProvider } from '@bookorbit/types' +import { i18n } from '@/i18n' +import { formatListParts } from '@/i18n/formatters' + +export type ProviderLabelResolver = (provider: string) => string + +function identityLabel(provider: string): string { + return provider +} + +/** "Google Books and Amazon" in the active locale, without hardcoding English separators. */ +export function formatProviderSentenceList(labels: string[]): string { + return formatListParts(labels) + .map((part) => part.value) + .join('') +} + +/** Tells the user which providers could not be reached, so the saved answer they got is not mistaken for a live one. */ +export function cacheFallbackMessage( + providers: readonly MetadataCacheServedProvider[], + labelOf: ProviderLabelResolver = identityLabel, +): string | null { + const labels = [...new Set(providers.map((entry) => entry.provider))].map((provider) => labelOf(provider)) + if (labels.length === 0) return null + return i18n.global.t('metadataCache.fallback.usedSaved', { count: labels.length, providers: formatProviderSentenceList(labels) }) +} + +export function showCacheFallbackToast(providers: readonly MetadataCacheServedProvider[], labelOf: ProviderLabelResolver = identityLabel): boolean { + const message = cacheFallbackMessage(providers, labelOf) + if (!message) return false + toast.warning(message, { closeButton: true }) + return true +} diff --git a/client/src/features/metadata-cache/lib/metadata-cache.ts b/client/src/features/metadata-cache/lib/metadata-cache.ts new file mode 100644 index 000000000..60739dda0 --- /dev/null +++ b/client/src/features/metadata-cache/lib/metadata-cache.ts @@ -0,0 +1,93 @@ +import type { + MetadataCacheRequestPolicy, + MetadataCacheServedProvider, + MetadataCacheUsage, + MetadataCandidateCacheInfo, + MetadataFetchDiagnostics, +} from '@bookorbit/types' + +/** Asks the server to skip saved answers and call every provider live. */ +export const METADATA_CACHE_REFRESH: MetadataCacheRequestPolicy = 'refresh' + +/** What a set of search results owes to the metadata cache. */ +export interface MetadataCacheSummary { + cachedCount: number + staleCount: number + oldestFetchedAt: string | null +} + +export const EMPTY_METADATA_CACHE_SUMMARY: MetadataCacheSummary = { cachedCount: 0, staleCount: 0, oldestFetchedAt: null } + +/** A refresh response that may say which providers answered from saved results. */ +export type WithMetadataCacheUsage = T & { metadataCache?: MetadataCacheUsage } + +export interface IgnoreCacheOptions { + ignoreCache?: boolean +} + +export function summarizeCandidateCache(infos: Iterable): MetadataCacheSummary { + let cachedCount = 0 + let staleCount = 0 + let oldestTime = Number.POSITIVE_INFINITY + let oldestFetchedAt: string | null = null + for (const info of infos) { + if (!info) continue + cachedCount += 1 + if (info.stale) staleCount += 1 + const time = Date.parse(info.fetchedAt) + if (Number.isFinite(time) && time < oldestTime) { + oldestTime = time + oldestFetchedAt = info.fetchedAt + } + } + return { cachedCount, staleCount, oldestFetchedAt } +} + +/** + * Separates the cache marker from a streamed candidate. The marker describes the answer, not the + * record, so it must not travel with the candidate into forms or requests the server validates. + */ +export function splitCandidateCache( + event: T & { cache?: MetadataCandidateCacheInfo }, +): { candidate: T; cache: MetadataCandidateCacheInfo | null } { + const { cache, ...candidate } = event + return { candidate: candidate as T, cache: isCandidateCacheInfo(cache) ? cache : null } +} + +function isCandidateCacheInfo(value: unknown): value is MetadataCandidateCacheInfo { + if (!value || typeof value !== 'object') return false + return typeof (value as Partial).fetchedAt === 'string' +} + +/** Strips the cache report from a refresh response, returning the record and the report apart. */ +export function splitMetadataCacheUsage(value: WithMetadataCacheUsage): { value: T; usage: MetadataCacheUsage | null } { + const { metadataCache, ...rest } = value + return { value: rest as T, usage: metadataCache ?? null } +} + +export function fallbackProviders(served: readonly MetadataCacheServedProvider[] | null | undefined): MetadataCacheServedProvider[] { + return (served ?? []).filter((entry) => entry.source === 'fallback') +} + +export function usageFallbackProviders(usage: MetadataCacheUsage | null | undefined): MetadataCacheServedProvider[] { + return fallbackProviders(usage?.fallbackProviders) +} + +export function diagnosticsFallbackProviders(diagnostics: MetadataFetchDiagnostics | null | undefined): MetadataCacheServedProvider[] { + return fallbackProviders(diagnostics?.cacheServedProviders) +} + +/** A POST that takes an optional `{ ignoreCache }` body. Without the flag it is sent exactly as before the cache existed. */ +export function ignoreCachePostInit(options: IgnoreCacheOptions = {}): RequestInit { + if (options.ignoreCache !== true) return { method: 'POST' } + return { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ignoreCache: true }), + } +} + +/** Adds `ignoreCache: true` to a request body only when it was asked for, so older servers keep accepting the default. */ +export function withIgnoreCache(body: T, options: IgnoreCacheOptions = {}): T | (T & { ignoreCache: true }) { + return options.ignoreCache === true ? { ...body, ignoreCache: true } : body +} diff --git a/client/src/features/settings/AuthorEnrichmentSettings.vue b/client/src/features/settings/AuthorEnrichmentSettings.vue index 78286846b..6987d6efd 100644 --- a/client/src/features/settings/AuthorEnrichmentSettings.vue +++ b/client/src/features/settings/AuthorEnrichmentSettings.vue @@ -8,7 +8,9 @@ import ToggleSwitch from '@/components/ui/ToggleSwitch.vue' import type { AuthorAutoEnrichmentConfig } from '@bookorbit/types' import { api } from '@/lib/api' import { useAuthorEligibleCountPreview } from './composables/useAuthorEligibleCountPreview' +import { useAuthorEnrichmentActions } from './composables/useAuthorEnrichmentActions' import AuthorMetadataPreferences from './components/AuthorMetadataPreferences.vue' +import IgnoreCacheCheckbox from '@/features/metadata-cache/components/IgnoreCacheCheckbox.vue' const { t } = useI18n() @@ -26,6 +28,8 @@ const saving = ref(false) const metadataPreferencesRef = ref | null>(null) const authorBackfillRunning = ref(false) const authorBackfillAllRunning = ref(false) +const ignoreCache = ref(false) +const { backfill, backfillAll } = useAuthorEnrichmentActions() const eligibleConditions = computed(() => config.value.conditions) const { count: eligibleCount, loading: countLoading } = useAuthorEligibleCountPreview(eligibleConditions) @@ -85,11 +89,7 @@ async function runAuthorBackfill() { if (authorBackfillRunning.value) return authorBackfillRunning.value = true try { - const res = await api('/api/v1/authors/enrichment/backfill', { - method: 'POST', - }) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - const { queued } = await res.json() + const { queued } = await backfill({ ignoreCache: ignoreCache.value }) if (queued === 0) toast.info(t('settings.admin.authorEnrichment.noEligibleAuthors')) } catch { toast.error(t('settings.admin.authorEnrichment.queueFailed')) @@ -102,11 +102,7 @@ async function runAuthorBackfillAll() { if (authorBackfillAllRunning.value) return authorBackfillAllRunning.value = true try { - const res = await api('/api/v1/authors/enrichment/backfill-all', { - method: 'POST', - }) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - const { queued } = await res.json() + const { queued } = await backfillAll({ ignoreCache: ignoreCache.value }) if (queued === 0) toast.info(t('settings.admin.authorEnrichment.noAuthorsToEnrich')) } catch { toast.error(t('settings.admin.authorEnrichment.queueFailed')) @@ -223,6 +219,14 @@ async function runAuthorBackfillAll() { +
+ +
+ +
+ +
+
+
+ +
+