From 7d823039b0e6673762bdf17e02ed465ff832cb3f Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:05:32 +0000 Subject: [PATCH] feat(metadata): rebuild book auto-fetch as library scopes with counts Show the global rule and each library's own rule on Field Rules-style tabs, with live counts per condition, the score histogram and the books a run would take. Store only what a library changes, run every library with its own rule, and record each library's run. Also fix the DjVu queue test that assumed which request waits, and inventory the DjVu reader routes. --- client/src/components/AppLayout.vue | 2 +- .../BookMetadataFetchWidget.spec.ts | 9 + .../components/BookMetadataFetchWidget.vue | 7 +- .../composables/useBookMetadataFetchConfig.ts | 51 --- .../useEligibleCountPreview.spec.ts | 111 ----- .../composables/useEligibleCountPreview.ts | 60 --- .../src/features/settings/lib/settings-nav.ts | 3 + .../BookMetadataFetchSettings.vue | 260 ++++++++++-- .../BookMetadataFetchSettings.spec.ts | 224 ++++++++++ .../components/AutoFetchConditionColumns.vue | 214 ++++++++++ .../components/AutoFetchFieldChips.vue | 85 ++++ .../components/AutoFetchPreviewList.vue | 134 ++++++ .../components/AutoFetchQueueBand.vue | 177 ++++++++ .../components/AutoFetchScoreHistogram.vue | 116 +++++ .../components/AutoFetchThresholdStepper.vue | 73 ++++ .../components/ConditionConfigurator.vue | 98 ----- .../components/GlobalAutoFetchConfig.spec.ts | 340 --------------- .../components/GlobalAutoFetchConfig.vue | 191 --------- .../components/LibraryAutoFetchConfig.spec.ts | 395 ------------------ .../components/LibraryAutoFetchConfig.vue | 281 ------------- .../components/MissingFieldsPicker.vue | 49 --- .../__tests__/AutoFetchComponents.spec.ts | 180 ++++++++ .../composables/useAutoFetchPreview.ts | 64 +++ .../composables/useAutoFetchScopes.ts | 229 ++++++++++ .../lib/__tests__/auto-fetch-rules.spec.ts | 90 ++++ .../lib/auto-fetch-rules.ts | 137 ++++++ .../components/FieldRulesScopeTabs.vue | 12 +- client/src/locales/en.json | 86 ++++ client/src/router/index.ts | 2 +- client/src/views/SettingsView.vue | 2 +- packages/types/src/book-metadata-fetch.ts | 57 +++ ...book-metadata-fetch-config.service.test.ts | 36 ++ .../book-metadata-fetch-config.service.ts | 44 +- ...etadata-fetch-orchestrator.service.test.ts | 27 ++ ...ook-metadata-fetch-orchestrator.service.ts | 24 +- ...ook-metadata-fetch-preview.service.test.ts | 49 +++ .../book-metadata-fetch-preview.service.ts | 32 ++ .../book-metadata-fetch-queue.repository.ts | 152 ++++++- .../book-metadata-fetch.controller.test.ts | 28 +- .../book-metadata-fetch.controller.ts | 28 +- .../book-metadata-fetch.module.ts | 2 + .../dto/update-config.dto.ts | 29 +- .../modules/reader/djvu/djvu.service.test.ts | 18 +- .../route-inventory/manifest.json | 6 +- .../route-inventory/routes-02.json | 36 ++ .../route-inventory/routes-10.json | 36 ++ 46 files changed, 2660 insertions(+), 1626 deletions(-) delete mode 100644 client/src/features/book-metadata-fetch/composables/useBookMetadataFetchConfig.ts delete mode 100644 client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.spec.ts delete mode 100644 client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.ts create mode 100644 client/src/features/settings/metadata-auto-fetch/__tests__/BookMetadataFetchSettings.spec.ts create mode 100644 client/src/features/settings/metadata-auto-fetch/components/AutoFetchConditionColumns.vue create mode 100644 client/src/features/settings/metadata-auto-fetch/components/AutoFetchFieldChips.vue create mode 100644 client/src/features/settings/metadata-auto-fetch/components/AutoFetchPreviewList.vue create mode 100644 client/src/features/settings/metadata-auto-fetch/components/AutoFetchQueueBand.vue create mode 100644 client/src/features/settings/metadata-auto-fetch/components/AutoFetchScoreHistogram.vue create mode 100644 client/src/features/settings/metadata-auto-fetch/components/AutoFetchThresholdStepper.vue delete mode 100644 client/src/features/settings/metadata-auto-fetch/components/ConditionConfigurator.vue delete mode 100644 client/src/features/settings/metadata-auto-fetch/components/GlobalAutoFetchConfig.spec.ts delete mode 100644 client/src/features/settings/metadata-auto-fetch/components/GlobalAutoFetchConfig.vue delete mode 100644 client/src/features/settings/metadata-auto-fetch/components/LibraryAutoFetchConfig.spec.ts delete mode 100644 client/src/features/settings/metadata-auto-fetch/components/LibraryAutoFetchConfig.vue delete mode 100644 client/src/features/settings/metadata-auto-fetch/components/MissingFieldsPicker.vue create mode 100644 client/src/features/settings/metadata-auto-fetch/components/__tests__/AutoFetchComponents.spec.ts create mode 100644 client/src/features/settings/metadata-auto-fetch/composables/useAutoFetchPreview.ts create mode 100644 client/src/features/settings/metadata-auto-fetch/composables/useAutoFetchScopes.ts create mode 100644 client/src/features/settings/metadata-auto-fetch/lib/__tests__/auto-fetch-rules.spec.ts create mode 100644 client/src/features/settings/metadata-auto-fetch/lib/auto-fetch-rules.ts create mode 100644 server/src/modules/book-metadata-fetch/book-metadata-fetch-preview.service.test.ts create mode 100644 server/src/modules/book-metadata-fetch/book-metadata-fetch-preview.service.ts diff --git a/client/src/components/AppLayout.vue b/client/src/components/AppLayout.vue index 2dfdff057..5726327aa 100644 --- a/client/src/components/AppLayout.vue +++ b/client/src/components/AppLayout.vue @@ -78,7 +78,7 @@ const viewKey = computed(() => { - + diff --git a/client/src/features/book-metadata-fetch/components/BookMetadataFetchWidget.spec.ts b/client/src/features/book-metadata-fetch/components/BookMetadataFetchWidget.spec.ts index 2a6e4c295..4ec5721ac 100644 --- a/client/src/features/book-metadata-fetch/components/BookMetadataFetchWidget.spec.ts +++ b/client/src/features/book-metadata-fetch/components/BookMetadataFetchWidget.spec.ts @@ -135,6 +135,15 @@ describe('BookMetadataFetchWidget', () => { expect(wrapper.text()).toContain('Author enrichment finished') }) + it('leaves the book card to a page that shows the queue itself, and still shows author work', () => { + bookStatus.value = idleBookStatus({ queued: 4, sessionTotal: 4 }) + authorStatus.value = idleAuthorStatus({ queued: 2, total: 2, sessionTotal: 2 }) + const wrapper = shallowMount(BookMetadataFetchWidget, { props: { hideBookCard: true } }) + + expect(wrapper.text()).not.toContain('Fetching metadata') + expect(wrapper.text()).toContain('Enriching authors') + }) + it('keeps a dismissed card hidden for the current run and restores it for later work', async () => { authorStatus.value = idleAuthorStatus({ queued: 2, total: 2, sessionTotal: 2 }) const wrapper = shallowMount(BookMetadataFetchWidget) diff --git a/client/src/features/book-metadata-fetch/components/BookMetadataFetchWidget.vue b/client/src/features/book-metadata-fetch/components/BookMetadataFetchWidget.vue index 8cbe75780..e12d29dfa 100644 --- a/client/src/features/book-metadata-fetch/components/BookMetadataFetchWidget.vue +++ b/client/src/features/book-metadata-fetch/components/BookMetadataFetchWidget.vue @@ -13,6 +13,11 @@ import BookMetadataFetchReportModal from './BookMetadataFetchReportModal.vue' import AuthorEnrichmentExpanded from './AuthorEnrichmentExpanded.vue' import AuthorEnrichmentReportModal from './AuthorEnrichmentReportModal.vue' +const props = defineProps<{ + /** Set where the page already shows the book queue, so the card does not repeat it over the page's own controls. */ + hideBookCard?: boolean +}>() + const { status, subscribe } = useBookMetadataFetchStatus() const { status: authorStatus, subscribe: subscribeAuthors } = useAuthorEnrichmentStatus() const { hasPermission, isSuperuser } = usePermissions() @@ -52,7 +57,7 @@ const hasAuthorStatus = computed(() => hasAuthorWork.value || authorStatus.value const cardUserId = computed(() => user.value?.id ?? null) const { dismissed: bookCardDismissed, dismiss: dismissBookCard } = useMetadataCardDismissal('books', status, hasBookWork, cardUserId) const { dismissed: authorCardDismissed, dismiss: dismissAuthorCard } = useMetadataCardDismissal('authors', authorStatus, hasAuthorWork, cardUserId) -const showBookCard = computed(() => hasBookStatus.value && !bookCardDismissed.value) +const showBookCard = computed(() => hasBookStatus.value && !bookCardDismissed.value && !props.hideBookCard) const showAuthorCard = computed(() => hasAuthorStatus.value && !authorCardDismissed.value) watch(hasAuthorWork, (active) => { diff --git a/client/src/features/book-metadata-fetch/composables/useBookMetadataFetchConfig.ts b/client/src/features/book-metadata-fetch/composables/useBookMetadataFetchConfig.ts deleted file mode 100644 index 225ba5361..000000000 --- a/client/src/features/book-metadata-fetch/composables/useBookMetadataFetchConfig.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ref } from 'vue' -import type { BookMetadataFetchConfig, BookMetadataFetchConfigOverride, BookMetadataFetchLibraryConfig } from '@bookorbit/types' -import { api } from '@/lib/api' - -const globalConfig = ref(null) - -export function useBookMetadataFetchConfig() { - async function fetchGlobalConfig(): Promise { - const res = await api('/api/v1/book-metadata-fetch/config') - if (!res.ok) throw new Error('Failed to load config') - const data: BookMetadataFetchConfig = await res.json() - globalConfig.value = data - return data - } - - async function saveGlobalConfig(config: BookMetadataFetchConfig): Promise { - const res = await api('/api/v1/book-metadata-fetch/config', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config), - }) - if (!res.ok) throw new Error('Failed to save config') - const data: BookMetadataFetchConfig = await res.json() - globalConfig.value = data - return data - } - - async function fetchLibraryConfig(libraryId: number): Promise { - const res = await api(`/api/v1/book-metadata-fetch/config/libraries/${libraryId}`) - if (!res.ok) throw new Error('Failed to load library config') - return res.json() - } - - async function saveLibraryConfig(libraryId: number, override: BookMetadataFetchConfigOverride): Promise { - const res = await api(`/api/v1/book-metadata-fetch/config/libraries/${libraryId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(override ?? {}), - }) - if (!res.ok) throw new Error('Failed to save library config') - return res.json() - } - - return { - globalConfig, - fetchGlobalConfig, - saveGlobalConfig, - fetchLibraryConfig, - saveLibraryConfig, - } -} diff --git a/client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.spec.ts b/client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.spec.ts deleted file mode 100644 index 503b47a75..000000000 --- a/client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { flushPromises } from '@vue/test-utils' -import { effectScope, nextTick, ref, type EffectScope } from 'vue' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { BookMetadataFetchConditions } from '@bookorbit/types' -import { invalidateEligibleCountPreviews, useEligibleCountPreview } from './useEligibleCountPreview' - -const apiMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<{ ok: boolean; json: () => Promise }>>()) - -vi.mock('@/lib/api', () => ({ - api: (...args: unknown[]) => apiMock(...args), -})) - -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((r) => { - resolve = r - }) - return { promise, resolve } -} - -function response(count: number) { - return { - ok: true, - json: async () => ({ count }), - } -} - -function makeConditions(threshold: number): BookMetadataFetchConditions { - return { - neverFetched: { enabled: false }, - scoreThreshold: { enabled: true, threshold }, - missingFields: { enabled: false, fields: [] }, - } -} - -describe('useEligibleCountPreview', () => { - let scope: EffectScope - - beforeEach(() => { - vi.useFakeTimers() - apiMock.mockReset() - scope = effectScope() - }) - - afterEach(() => { - scope.stop() - vi.useRealTimers() - }) - - it('ignores stale in-flight count responses after conditions change', async () => { - const first = deferred<{ ok: boolean; json: () => Promise }>() - const second = deferred<{ ok: boolean; json: () => Promise }>() - apiMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) - - const conditions = ref(makeConditions(70)) - const preview = scope.run(() => useEligibleCountPreview(conditions))! - - await vi.advanceTimersByTimeAsync(400) - expect(apiMock).toHaveBeenCalledTimes(1) - - conditions.value = makeConditions(45) - await nextTick() - first.resolve(response(12)) - await flushPromises() - - expect(preview.count.value).toBeNull() - - await vi.advanceTimersByTimeAsync(400) - second.resolve(response(8)) - await flushPromises() - - expect(preview.count.value).toBe(8) - }) - - it('refreshes all previews when invalidated', async () => { - apiMock.mockResolvedValue(response(3)) - const conditions = ref(makeConditions(70)) - - scope.run(() => useEligibleCountPreview(conditions, 5)) - await vi.advanceTimersByTimeAsync(400) - await flushPromises() - - invalidateEligibleCountPreviews() - await nextTick() - await vi.advanceTimersByTimeAsync(400) - await flushPromises() - - expect(apiMock).toHaveBeenCalledTimes(2) - const secondCall = apiMock.mock.calls[1] - expect(secondCall).toBeDefined() - const [, init] = secondCall as [string, RequestInit] - expect(JSON.parse(String(init.body))).toMatchObject({ - libraryId: 5, - conditions: { - scoreThreshold: { threshold: 70 }, - }, - }) - }) - - it('clears count without fetching when conditions are unavailable', async () => { - const conditions = ref(null) - const preview = scope.run(() => useEligibleCountPreview(conditions))! - - await vi.advanceTimersByTimeAsync(400) - await flushPromises() - - expect(apiMock).not.toHaveBeenCalled() - expect(preview.count.value).toBeNull() - expect(preview.loading.value).toBe(false) - }) -}) diff --git a/client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.ts b/client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.ts deleted file mode 100644 index 18e3ad951..000000000 --- a/client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { onScopeDispose, ref, watch, type Ref } from 'vue' -import type { BookMetadataFetchConditions } from '@bookorbit/types' -import { api } from '@/lib/api' - -const refreshToken = ref(0) - -export function invalidateEligibleCountPreviews() { - refreshToken.value += 1 -} - -export function useEligibleCountPreview(conditions: Ref, libraryId?: number) { - const count = ref(null) - const loading = ref(false) - let debounceTimer: ReturnType | null = null - let requestId = 0 - - async function fetchCount() { - const currentRequestId = ++requestId - const current = conditions.value - if (!current) { - count.value = null - loading.value = false - return - } - loading.value = true - try { - const body: { conditions: BookMetadataFetchConditions; libraryId?: number } = { conditions: current } - if (libraryId !== undefined) body.libraryId = libraryId - const res = await api('/api/v1/book-metadata-fetch/preview-count', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - if (res.ok) { - const data: { count: number } = await res.json() - if (currentRequestId !== requestId) return - count.value = data.count - } - } finally { - if (currentRequestId === requestId) loading.value = false - } - } - - watch( - [conditions, refreshToken], - () => { - requestId += 1 - if (debounceTimer) clearTimeout(debounceTimer) - debounceTimer = setTimeout(fetchCount, 400) - }, - { deep: true, immediate: true }, - ) - - onScopeDispose(() => { - requestId += 1 - if (debounceTimer) clearTimeout(debounceTimer) - }) - - return { count, loading } -} diff --git a/client/src/features/settings/lib/settings-nav.ts b/client/src/features/settings/lib/settings-nav.ts index 8f3ab026b..dbd675001 100644 --- a/client/src/features/settings/lib/settings-nav.ts +++ b/client/src/features/settings/lib/settings-nav.ts @@ -61,6 +61,8 @@ export interface SettingsNavItem { id: string routeName: string labelKey: string + /** The page heading, when the short nav label would not say what the page is on its own. */ + titleKey?: string descriptionKey?: string icon: Component /** Extra English search terms so the rail search finds a page by concept, not just by title. */ @@ -321,6 +323,7 @@ export const SETTINGS_NAV: readonly SettingsNavGroup[] = [ id: 'metadata-auto-fetch', routeName: 'settings-metadata-auto-fetch', labelKey: 'settings.metadata.tabs.auto-fetch', + titleKey: 'titles.metadata.auto-fetch', descriptionKey: 'settings.metadata.tabSubtitles.auto-fetch', icon: Zap, keywords: 'auto fetch schedule missing fields import books conditions', diff --git a/client/src/features/settings/metadata-auto-fetch/BookMetadataFetchSettings.vue b/client/src/features/settings/metadata-auto-fetch/BookMetadataFetchSettings.vue index 3a288bdbb..9adf0511e 100644 --- a/client/src/features/settings/metadata-auto-fetch/BookMetadataFetchSettings.vue +++ b/client/src/features/settings/metadata-auto-fetch/BookMetadataFetchSettings.vue @@ -1,42 +1,248 @@ diff --git a/client/src/features/settings/metadata-auto-fetch/__tests__/BookMetadataFetchSettings.spec.ts b/client/src/features/settings/metadata-auto-fetch/__tests__/BookMetadataFetchSettings.spec.ts new file mode 100644 index 000000000..c9d8316b9 --- /dev/null +++ b/client/src/features/settings/metadata-auto-fetch/__tests__/BookMetadataFetchSettings.spec.ts @@ -0,0 +1,224 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { ref } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { BookMetadataFetchBreakdown, BookMetadataFetchConfig, BookMetadataFetchLibraryConfigEntry } from '@bookorbit/types' +import BookMetadataFetchSettings from '../BookMetadataFetchSettings.vue' + +const apiMock = vi.hoisted(() => vi.fn<(path: string, init?: RequestInit) => Promise<{ ok: boolean; json: () => Promise }>>()) +const toastMock = vi.hoisted(() => ({ success: vi.fn<(message: string) => void>(), error: vi.fn<(message: string) => void>() })) + +vi.mock('@/lib/api', () => ({ api: (path: string, init?: RequestInit) => apiMock(path, init) })) +vi.mock('vue-sonner', () => ({ toast: toastMock })) +vi.mock('@vueuse/core', () => ({ useMediaQuery: () => ref(false) })) +vi.mock('../components/AutoFetchQueueBand.vue', () => ({ default: { template: '
' } })) +vi.mock('../components/AutoFetchPreviewList.vue', () => ({ + default: { + props: ['items', 'matching', 'total', 'conditions', 'libraryNames'], + template: '
', + }, +})) + +const global: BookMetadataFetchConfig = { + enabled: false, + triggerOnImport: false, + conditions: { + neverFetched: { enabled: true }, + scoreThreshold: { enabled: true, threshold: 60 }, + missingFields: { enabled: true, fields: ['description', 'cover'] }, + }, +} + +function entry( + libraryId: number, + libraryName: string, + override: BookMetadataFetchLibraryConfigEntry['override'] = null, +): BookMetadataFetchLibraryConfigEntry { + return { libraryId, libraryName, ...global, override, lastRunAt: null, lastQueuedCount: null } +} + +const breakdown: BookMetadataFetchBreakdown = { + total: 480, + matching: 456, + neverFetched: 456, + lowScore: 5, + missingAny: 27, + missingByField: { + title: 0, + subtitle: 292, + description: 27, + cover: 0, + audioCover: 0, + authors: 1, + publisher: 20, + isbn: 136, + publishedYear: 24, + language: 9, + pageCount: 189, + communityRating: 236, + seriesName: 351, + seriesIndex: 359, + genres: 113, + narrators: 3, + duration: 3, + abridged: 0, + }, + withAudio: 16, + scoreBins: Array.from({ length: 20 }, (_, i) => (i > 12 ? 30 : 1)), + unscored: 0, +} + +function ok(data: unknown) { + return { ok: true, json: async () => data } +} + +let libraries: BookMetadataFetchLibraryConfigEntry[] + +function lastCall(path: string, method: string) { + const calls = apiMock.mock.calls.filter(([p, init]) => p === path && (init?.method ?? 'GET') === method) + return calls[calls.length - 1] +} + +async function mountPage() { + const wrapper = mount(BookMetadataFetchSettings, { global: { stubs: { RouterLink: true } } }) + await flushPromises() + await vi.advanceTimersByTimeAsync(300) + await flushPromises() + return wrapper +} + +function tab(wrapper: Awaited>, name: string) { + return wrapper.findAll('[role="tab"]').find((item) => item.text().startsWith(name))! +} + +describe('BookMetadataFetchSettings', () => { + beforeEach(() => { + vi.useFakeTimers() + apiMock.mockReset() + toastMock.success.mockReset() + toastMock.error.mockReset() + libraries = [entry(2, 'Audiobooks'), entry(6, 'Comics', { conditions: { scoreThreshold: { enabled: true, threshold: 75 } } })] + apiMock.mockImplementation(async (path, init) => { + const method = init?.method ?? 'GET' + if (path === '/api/v1/book-metadata-fetch/config' && method === 'GET') return ok(global) + if (path === '/api/v1/book-metadata-fetch/config' && method === 'PUT') return ok(JSON.parse(String(init!.body))) + if (path === '/api/v1/book-metadata-fetch/config/libraries') return ok(libraries) + if (path.startsWith('/api/v1/book-metadata-fetch/config/libraries/') && method === 'PUT') { + const body = JSON.parse(String(init!.body)) + return ok({ ...global, override: Object.keys(body).length ? body : null, lastRunAt: null, lastQueuedCount: null }) + } + if (path === '/api/v1/book-metadata-fetch/preview-breakdown') return ok(breakdown) + if (path === '/api/v1/book-metadata-fetch/preview-books') return ok({ items: [], total: 456 }) + if (path.startsWith('/api/v1/book-metadata-fetch/run')) return ok({ queued: 4 }) + return ok({}) + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('opens on the global rule with a tab per library and counts for the libraries that follow it', async () => { + const wrapper = await mountPage() + + expect(wrapper.findAll('[role="tab"]').map((item) => item.text())).toEqual(['Global defaults', 'Audiobooks', 'Comics1']) + expect(wrapper.text()).toContain('Applies to 1 library, 480 books.') + expect(wrapper.text()).toContain('Comics has its own rule.') + const scope = JSON.parse(String(lastCall('/api/v1/book-metadata-fetch/preview-breakdown', 'POST')![1]!.body)) + expect(scope).toEqual({ conditions: global.conditions, libraryIds: [2] }) + expect(wrapper.text()).toContain('456') + }) + + it('writes both import flags from the one switch', async () => { + const wrapper = await mountPage() + + await wrapper.get('[aria-labelledby="auto-fetch-import-label"]').trigger('click') + await wrapper + .findAll('button') + .find((button) => button.text() === 'Save Global defaults')! + .trigger('click') + await flushPromises() + + const body = JSON.parse(String(lastCall('/api/v1/book-metadata-fetch/config', 'PUT')![1]!.body)) + expect(body).toMatchObject({ enabled: true, triggerOnImport: true }) + expect(toastMock.success).toHaveBeenCalledWith('Saved Global defaults') + }) + + it('saves only what a library changes, and marks each condition as its own or the global one', async () => { + const wrapper = await mountPage() + await tab(wrapper, 'Audiobooks').trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('Audiobooks follows the global rule.') + expect(wrapper.findAll('section[data-condition] h3 + span').map((item) => item.text())).toEqual(['Global', 'Global', 'Global']) + + await wrapper.get('[data-condition="scoreThreshold"] button[aria-label="Raise the threshold"]').trigger('click') + await flushPromises() + expect(wrapper.get('[data-condition="scoreThreshold"] h3 + span').text()).toContain('Own') + expect(wrapper.text()).toContain('Global rule: Score below 60') + expect(wrapper.get('[data-testid="auto-fetch-run"]').attributes('disabled')).toBeDefined() + + await wrapper + .findAll('button') + .find((button) => button.text() === 'Save Audiobooks')! + .trigger('click') + await flushPromises() + + const body = JSON.parse(String(lastCall('/api/v1/book-metadata-fetch/config/libraries/2', 'PUT')![1]!.body)) + expect(body).toEqual({ conditions: { scoreThreshold: { enabled: true, threshold: 65 } } }) + expect(tab(wrapper, 'Audiobooks').text()).toBe('Audiobooks1') + }) + + it('keeps turning a library back to the global rule a draft until it is saved', async () => { + const wrapper = await mountPage() + await tab(wrapper, 'Comics').trigger('click') + await flushPromises() + + await wrapper + .findAll('button') + .find((button) => button.text() === 'Use the global rule')! + .trigger('click') + await flushPromises() + expect(lastCall('/api/v1/book-metadata-fetch/config/libraries/6', 'PUT')).toBeUndefined() + expect(wrapper.text()).toContain('1 unsaved change in Comics') + + await wrapper + .findAll('button') + .find((button) => button.text() === 'Discard')! + .trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('Comics has its own rule for 1 setting.') + + await wrapper + .findAll('button') + .find((button) => button.text() === 'Use the global rule')! + .trigger('click') + await wrapper + .findAll('button') + .find((button) => button.text() === 'Save Comics')! + .trigger('click') + await flushPromises() + expect(JSON.parse(String(lastCall('/api/v1/book-metadata-fetch/config/libraries/6', 'PUT')![1]!.body))).toEqual({}) + }) + + it('runs every library from the global tab and the one library from its tab, passing ignore-cache only when asked', async () => { + const wrapper = await mountPage() + + await wrapper.get('[data-testid="auto-fetch-run"]').trigger('click') + await flushPromises() + expect(lastCall('/api/v1/book-metadata-fetch/run', 'POST')![1]).toEqual({ method: 'POST' }) + expect(toastMock.success).toHaveBeenCalledWith('Queued 4 books') + + await tab(wrapper, 'Comics').trigger('click') + await wrapper.get('[data-testid="ignore-cache-checkbox"]').setValue(true) + await wrapper.get('[data-testid="auto-fetch-run"]').trigger('click') + await flushPromises() + expect(JSON.parse(String(lastCall('/api/v1/book-metadata-fetch/run/6', 'POST')![1]!.body))).toEqual({ ignoreCache: true }) + expect((wrapper.get('[data-testid="ignore-cache-checkbox"]').element as HTMLInputElement).checked).toBe(false) + }) + + it('shows an error instead of an empty form when the settings cannot load', async () => { + apiMock.mockImplementation(async () => ({ ok: false, json: async () => ({}) })) + const wrapper = await mountPage() + + expect(wrapper.get('[role="alert"]').text()).toContain('could not be loaded') + }) +}) diff --git a/client/src/features/settings/metadata-auto-fetch/components/AutoFetchConditionColumns.vue b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchConditionColumns.vue new file mode 100644 index 000000000..0a862ae96 --- /dev/null +++ b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchConditionColumns.vue @@ -0,0 +1,214 @@ + + + diff --git a/client/src/features/settings/metadata-auto-fetch/components/AutoFetchFieldChips.vue b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchFieldChips.vue new file mode 100644 index 000000000..21a2e8be3 --- /dev/null +++ b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchFieldChips.vue @@ -0,0 +1,85 @@ + + + diff --git a/client/src/features/settings/metadata-auto-fetch/components/AutoFetchPreviewList.vue b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchPreviewList.vue new file mode 100644 index 000000000..63f7b1934 --- /dev/null +++ b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchPreviewList.vue @@ -0,0 +1,134 @@ + + + diff --git a/client/src/features/settings/metadata-auto-fetch/components/AutoFetchQueueBand.vue b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchQueueBand.vue new file mode 100644 index 000000000..de0886f20 --- /dev/null +++ b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchQueueBand.vue @@ -0,0 +1,177 @@ + + + diff --git a/client/src/features/settings/metadata-auto-fetch/components/AutoFetchScoreHistogram.vue b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchScoreHistogram.vue new file mode 100644 index 000000000..cf87a0e7d --- /dev/null +++ b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchScoreHistogram.vue @@ -0,0 +1,116 @@ + + + diff --git a/client/src/features/settings/metadata-auto-fetch/components/AutoFetchThresholdStepper.vue b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchThresholdStepper.vue new file mode 100644 index 000000000..7dd47bda8 --- /dev/null +++ b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchThresholdStepper.vue @@ -0,0 +1,73 @@ + + + diff --git a/client/src/features/settings/metadata-auto-fetch/components/ConditionConfigurator.vue b/client/src/features/settings/metadata-auto-fetch/components/ConditionConfigurator.vue deleted file mode 100644 index 50c6d5aae..000000000 --- a/client/src/features/settings/metadata-auto-fetch/components/ConditionConfigurator.vue +++ /dev/null @@ -1,98 +0,0 @@ - - - diff --git a/client/src/features/settings/metadata-auto-fetch/components/GlobalAutoFetchConfig.spec.ts b/client/src/features/settings/metadata-auto-fetch/components/GlobalAutoFetchConfig.spec.ts deleted file mode 100644 index 986a6b00e..000000000 --- a/client/src/features/settings/metadata-auto-fetch/components/GlobalAutoFetchConfig.spec.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { flushPromises, mount } from '@vue/test-utils' -import { nextTick, ref } from 'vue' -import type { BookMetadataFetchConfig, BookMetadataFetchStatusEvent } from '@bookorbit/types' - -// --- hoisted mocks --------------------------------------------------------- - -const mockTriggerGlobal = vi.fn<(options?: { ignoreCache?: boolean }) => Promise<{ queued: number }>>() -const mockSaveGlobalConfig = vi.fn<(c: BookMetadataFetchConfig) => Promise>() -const mockStatus = ref({ - queued: 0, - processing: 0, - failed: 0, - latestFailureAt: null, - paused: false, - sessionTotal: 0, - sessionDone: 0, - currentItemName: null, -}) -const mockEligibleCount = ref(null) -const mockCountLoading = ref(false) -const mockInvalidate = vi.fn<() => void>() - -vi.mock('@/features/book-metadata-fetch/composables/useBookMetadataFetchActions', () => ({ - useBookMetadataFetchActions: () => ({ triggerGlobal: mockTriggerGlobal }), -})) - -vi.mock('@/features/book-metadata-fetch/composables/useBookMetadataFetchConfig', () => ({ - useBookMetadataFetchConfig: () => ({ saveGlobalConfig: mockSaveGlobalConfig }), -})) - -vi.mock('@/features/book-metadata-fetch/composables/useBookMetadataFetchStatus', () => ({ - useBookMetadataFetchStatus: () => ({ status: mockStatus }), -})) - -vi.mock('@/features/book-metadata-fetch/composables/useEligibleCountPreview', () => ({ - useEligibleCountPreview: () => ({ count: mockEligibleCount, loading: mockCountLoading }), - invalidateEligibleCountPreviews: () => mockInvalidate(), -})) - -vi.mock('@vueuse/core', () => ({ - useMediaQuery: () => ref(false), -})) - -vi.mock('./ConditionConfigurator.vue', () => ({ - default: { template: '
' }, -})) - -vi.mock('@/components/ui/ToggleSwitch.vue', () => ({ - default: { template: '', props: ['modelValue', 'disabled'] }, -})) - -// --------------------------------------------------------------------------- - -const { default: GlobalAutoFetchConfig } = await import('./GlobalAutoFetchConfig.vue') - -function makeConfig(overrides: Partial = {}): BookMetadataFetchConfig { - return { - enabled: true, - triggerOnImport: false, - conditions: { - neverFetched: { enabled: true }, - scoreThreshold: { enabled: false, threshold: 60 }, - missingFields: { enabled: false, fields: [] }, - }, - ...overrides, - } -} - -function idleStatus(): BookMetadataFetchStatusEvent { - return { queued: 0, processing: 0, failed: 0, latestFailureAt: null, paused: false, sessionTotal: 0, sessionDone: 0, currentItemName: null } -} - -function buttonByText(wrapper: ReturnType, text: string) { - return wrapper.findAll('button').find((button) => button.text().trim() === text)! -} - -describe('GlobalAutoFetchConfig', () => { - beforeEach(() => { - vi.clearAllMocks() - mockStatus.value = idleStatus() - mockEligibleCount.value = null - mockCountLoading.value = false - mockTriggerGlobal.mockResolvedValue({ queued: 0 }) - mockSaveGlobalConfig.mockImplementation(async (c) => c) - }) - - // ------------------------------------------------------------------------- - // statusLabel - idle queue - // ------------------------------------------------------------------------- - - it('shows eligible count when queue is idle and count is loaded', async () => { - mockEligibleCount.value = 42 - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - await nextTick() - - expect(wrapper.find('.hidden.md\\:flex span').text()).toBe('~42 eligible') - }) - - it('shows nothing when queue is idle and eligible count is null', async () => { - mockEligibleCount.value = null - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - await nextTick() - - expect(wrapper.find('.hidden.md\\:flex span').exists()).toBe(false) - }) - - it('suppresses the label while eligible count is still loading', async () => { - mockEligibleCount.value = 10 - mockCountLoading.value = true - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - await nextTick() - - expect(wrapper.find('.hidden.md\\:flex span').exists()).toBe(false) - }) - - // ------------------------------------------------------------------------- - // statusLabel - active queue - // ------------------------------------------------------------------------- - - it('shows remaining count when books are queued and processing', async () => { - mockStatus.value = { ...idleStatus(), queued: 48, processing: 2 } - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - await nextTick() - - expect(wrapper.find('.hidden.md\\:flex span').text()).toBe('50 remaining') - }) - - it('shows remaining count when only queued (processing=0)', async () => { - mockStatus.value = { ...idleStatus(), queued: 10, processing: 0 } - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - await nextTick() - - expect(wrapper.find('.hidden.md\\:flex span').text()).toBe('10 remaining') - }) - - it('shows paused label when queue is paused with items', async () => { - mockStatus.value = { ...idleStatus(), queued: 30, processing: 0, paused: true } - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - await nextTick() - - expect(wrapper.find('.hidden.md\\:flex span').text()).toBe('30 in queue - paused') - }) - - it('queue remaining takes priority over eligible count', async () => { - mockEligibleCount.value = 5 - mockStatus.value = { ...idleStatus(), queued: 20, processing: 1 } - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - await nextTick() - - expect(wrapper.find('.hidden.md\\:flex span').text()).toBe('21 remaining') - }) - - // ------------------------------------------------------------------------- - // statusLabel - after trigger - // ------------------------------------------------------------------------- - - it('shows "Queued N books" after a successful trigger', async () => { - mockTriggerGlobal.mockResolvedValue({ queued: 2048 }) - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - - await buttonByText(wrapper, 'Run now').trigger('click') - await flushPromises() - - expect(wrapper.find('.hidden.md\\:flex span').text()).toBe('Queued 2,048 books') - }) - - it('shows "No eligible books found" when trigger returns 0', async () => { - mockTriggerGlobal.mockResolvedValue({ queued: 0 }) - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - - await buttonByText(wrapper, 'Run now').trigger('click') - await flushPromises() - - expect(wrapper.find('.hidden.md\\:flex span').text()).toBe('No eligible books found') - }) - - it('trigger result takes priority over active queue status', async () => { - mockStatus.value = { ...idleStatus(), queued: 100, processing: 1 } - mockTriggerGlobal.mockResolvedValue({ queued: 50 }) - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - - await buttonByText(wrapper, 'Run now').trigger('click') - await flushPromises() - - expect(wrapper.find('.hidden.md\\:flex span').text()).toBe('Queued 50 books') - }) - - it('runs with cached results unless the ignore-cache box is checked', async () => { - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - - await buttonByText(wrapper, 'Run now').trigger('click') - await flushPromises() - expect(mockTriggerGlobal).toHaveBeenLastCalledWith({ ignoreCache: false }) - - await wrapper.get('[data-testid="ignore-cache-checkbox"]').setValue(true) - await buttonByText(wrapper, 'Run now').trigger('click') - await flushPromises() - expect(mockTriggerGlobal).toHaveBeenLastCalledWith({ ignoreCache: true }) - }) - - it('invalidates eligible count previews after trigger', async () => { - mockTriggerGlobal.mockResolvedValue({ queued: 10 }) - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - - await buttonByText(wrapper, 'Run now').trigger('click') - await flushPromises() - - expect(mockInvalidate).toHaveBeenCalledTimes(1) - }) - - // ------------------------------------------------------------------------- - // Mobile footer mirrors statusLabel - // ------------------------------------------------------------------------- - - it('mobile footer also shows statusLabel', async () => { - mockEligibleCount.value = 7 - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - await nextTick() - - const mobileSpan = wrapper.find('.md\\:hidden.sticky span.text-muted-foreground') - expect(mobileSpan.text()).toBe('~7 eligible') - }) - - it('mobile footer shows remaining count when queue is active', async () => { - mockStatus.value = { ...idleStatus(), queued: 15, processing: 0 } - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - await nextTick() - - const mobileSpan = wrapper.find('.md\\:hidden.sticky span.text-muted-foreground') - expect(mobileSpan.text()).toBe('15 remaining') - }) - - // ------------------------------------------------------------------------- - // Save - // ------------------------------------------------------------------------- - - it('calls saveGlobalConfig and emits updated when save is clicked', async () => { - const updated = makeConfig({ enabled: false }) - mockSaveGlobalConfig.mockResolvedValue(updated) - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - - await buttonByText(wrapper, 'Save').trigger('click') - await flushPromises() - - expect(mockSaveGlobalConfig).toHaveBeenCalledTimes(1) - expect(wrapper.emitted('updated')).toEqual([[updated]]) - }) - - it('disables save button while saving', async () => { - let resolvePromise!: (v: BookMetadataFetchConfig) => void - mockSaveGlobalConfig.mockReturnValue(new Promise((r) => (resolvePromise = r))) - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - - const btn = buttonByText(wrapper, 'Save') - await btn.trigger('click') - await nextTick() - - expect((btn.element as HTMLButtonElement).disabled).toBe(true) - resolvePromise(makeConfig()) - await flushPromises() - expect((btn.element as HTMLButtonElement).disabled).toBe(false) - }) - - it('disables trigger button while triggering', async () => { - let resolvePromise!: (v: { queued: number }) => void - mockTriggerGlobal.mockReturnValue(new Promise((r) => (resolvePromise = r))) - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - - const btn = buttonByText(wrapper, 'Run now') - await btn.trigger('click') - await nextTick() - - expect((btn.element as HTMLButtonElement).disabled).toBe(true) - resolvePromise({ queued: 0 }) - await flushPromises() - expect((btn.element as HTMLButtonElement).disabled).toBe(false) - }) - - // ------------------------------------------------------------------------- - // Conditions panel toggle - // ------------------------------------------------------------------------- - - it('hides ConditionConfigurator when conditions panel is collapsed', async () => { - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - await nextTick() - - // ChevronUp is shown when open (conditionsOpen=true by default) - expect(wrapper.findComponent({ name: 'ChevronUp' }).exists()).toBe(false) - - // Click to collapse - await wrapper.find('.bg-card button.w-full').trigger('click') - await nextTick() - - // After collapse ChevronDown takes over for conditions toggle - collapsed state - expect(wrapper.find('.bg-card button.w-full').exists()).toBe(true) - }) - - it('updates local config when config prop changes', async () => { - const original = makeConfig({ enabled: true }) - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: original } }) - await nextTick() - - const updated = makeConfig({ enabled: false }) - await wrapper.setProps({ config: updated }) - await nextTick() - - const toggles = wrapper.findAll('input[type="checkbox"]') - expect(toggles.length).toBeGreaterThan(0) - }) - - it('activeConditionSummary lists missingFields when enabled with fields', async () => { - const config = makeConfig({ - conditions: { - neverFetched: { enabled: false }, - scoreThreshold: { enabled: false, threshold: 60 }, - missingFields: { enabled: true, fields: ['cover', 'description'] }, - }, - }) - const wrapper = mount(GlobalAutoFetchConfig, { props: { config } }) - await nextTick() - - expect(wrapper.text()).toContain('Missing 2 fields') - }) - - it('activeConditionSummary uses singular "field" for exactly one missing field', async () => { - const config = makeConfig({ - conditions: { - neverFetched: { enabled: false }, - scoreThreshold: { enabled: false, threshold: 60 }, - missingFields: { enabled: true, fields: ['cover'] }, - }, - }) - const wrapper = mount(GlobalAutoFetchConfig, { props: { config } }) - await nextTick() - - expect(wrapper.text()).toContain('Missing 1 field') - expect(wrapper.text()).not.toContain('fields') - }) -}) diff --git a/client/src/features/settings/metadata-auto-fetch/components/GlobalAutoFetchConfig.vue b/client/src/features/settings/metadata-auto-fetch/components/GlobalAutoFetchConfig.vue deleted file mode 100644 index c05c17162..000000000 --- a/client/src/features/settings/metadata-auto-fetch/components/GlobalAutoFetchConfig.vue +++ /dev/null @@ -1,191 +0,0 @@ - - - diff --git a/client/src/features/settings/metadata-auto-fetch/components/LibraryAutoFetchConfig.spec.ts b/client/src/features/settings/metadata-auto-fetch/components/LibraryAutoFetchConfig.spec.ts deleted file mode 100644 index 7dc8e6e3b..000000000 --- a/client/src/features/settings/metadata-auto-fetch/components/LibraryAutoFetchConfig.spec.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { flushPromises, mount } from '@vue/test-utils' -import { ref } from 'vue' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { BookMetadataFetchConfig, BookMetadataFetchLibraryConfig, Library } from '@bookorbit/types' -import LibraryAutoFetchConfig from './LibraryAutoFetchConfig.vue' - -const apiMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<{ ok: boolean; json: () => Promise }>>()) - -vi.mock('@/lib/api', () => ({ - api: (...args: unknown[]) => apiMock(...args), -})) - -vi.mock('@vueuse/core', () => ({ - useMediaQuery: () => ref(false), -})) - -vi.mock('@/components/ui/ToggleSwitch.vue', () => ({ - default: { - props: ['modelValue'], - emits: ['update:modelValue'], - template: '