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.
This commit is contained in:
neon
2026-10-16 19:05:32 +00:00
parent c378354dac
commit 7d823039b0
46 changed files with 2660 additions and 1626 deletions
+1 -1
View File
@@ -78,7 +78,7 @@ const viewKey = computed(() => {
</router-view>
</div>
</SidebarInset>
<BookMetadataFetchWidget />
<BookMetadataFetchWidget :hide-book-card="route.name === 'settings-metadata-auto-fetch'" />
<CommandPalette />
</SidebarProvider>
</template>
@@ -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)
@@ -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) => {
@@ -1,51 +0,0 @@
import { ref } from 'vue'
import type { BookMetadataFetchConfig, BookMetadataFetchConfigOverride, BookMetadataFetchLibraryConfig } from '@bookorbit/types'
import { api } from '@/lib/api'
const globalConfig = ref<BookMetadataFetchConfig | null>(null)
export function useBookMetadataFetchConfig() {
async function fetchGlobalConfig(): Promise<BookMetadataFetchConfig> {
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<BookMetadataFetchConfig> {
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<BookMetadataFetchLibraryConfig> {
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<BookMetadataFetchLibraryConfig> {
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,
}
}
@@ -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<unknown> }>>())
vi.mock('@/lib/api', () => ({
api: (...args: unknown[]) => apiMock(...args),
}))
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((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<unknown> }>()
const second = deferred<{ ok: boolean; json: () => Promise<unknown> }>()
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<BookMetadataFetchConditions | null>(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)
})
})
@@ -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<BookMetadataFetchConditions | null>, libraryId?: number) {
const count = ref<number | null>(null)
const loading = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | 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 }
}
@@ -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',
@@ -1,42 +1,248 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { BookMetadataFetchConfig } from '@bookorbit/types'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useBookMetadataFetchConfig } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchConfig'
import GlobalAutoFetchConfig from './components/GlobalAutoFetchConfig.vue'
import LibraryAutoFetchConfig from './components/LibraryAutoFetchConfig.vue'
import { toast } from 'vue-sonner'
import { useMediaQuery } from '@vueuse/core'
import { RefreshCw } from '@lucide/vue'
import type { BookMetadataFetchConditions } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { formatList, formatRelativeTimeFromNow } from '@/i18n/formatters'
import IgnoreCacheCheckbox from '@/features/metadata-cache/components/IgnoreCacheCheckbox.vue'
import FieldRulesScopeTabs from '@/features/settings/metadata-preferences/components/FieldRulesScopeTabs.vue'
import FieldRulesSaveBar from '@/features/settings/metadata-preferences/components/FieldRulesSaveBar.vue'
import { useBookMetadataFetchActions } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchActions'
import AutoFetchConditionColumns from './components/AutoFetchConditionColumns.vue'
import AutoFetchPreviewList from './components/AutoFetchPreviewList.vue'
import AutoFetchQueueBand from './components/AutoFetchQueueBand.vue'
import { useAutoFetchPreview } from './composables/useAutoFetchPreview'
import { useAutoFetchScopes, type AutoFetchScopeId } from './composables/useAutoFetchScopes'
import { fetchesOnImport, onImportChoice, type ConditionKey, type OnImportChoice } from './lib/auto-fetch-rules'
const JUST_NOW_MS = 2 * 60 * 1000
const { t } = useI18n()
const { globalConfig, fetchGlobalConfig } = useBookMetadataFetchConfig()
const { libraries, fetchLibraries } = useLibraries()
const loading = ref(true)
const scopes = useAutoFetchScopes()
const { triggerGlobal, triggerForLibrary } = useBookMetadataFetchActions()
const isPhone = useMediaQuery('(max-width: 767px)')
onMounted(async () => {
try {
await Promise.all([fetchGlobalConfig(), fetchLibraries()])
} finally {
loading.value = false
}
const ignoreCache = ref(false)
const running = ref(false)
const conditions = computed(() => scopes.activeConfig.value?.conditions ?? null)
const previewLimit = computed(() => (isPhone.value ? 6 : 12))
const preview = useAutoFetchPreview(conditions, scopes.activeLibraryIds, previewLimit)
const library = computed(() => scopes.activeLibrary.value)
const globalOn = computed(() => (scopes.draftGlobal.value ? fetchesOnImport(scopes.draftGlobal.value) : false))
const importChoice = computed<OnImportChoice>(() => (library.value ? onImportChoice(library.value.draft) : globalOn.value ? 'on' : 'off'))
const importOwn = computed(() => scopes.activeOwnKeys.value.includes('onImport'))
const libraryNames = computed(() => (library.value ? null : new Map(scopes.libraries.value.map((item) => [item.id, item.name]))))
const globalCoverage = computed(() =>
t('settings.metadata.autoFetch.scope.globalCoverage', {
libraryCount: scopes.followerIds.value.length,
bookCount: preview.breakdown.value?.total ?? 0,
}),
)
const ownLibrariesText = computed(() => {
const own = scopes.ownRuleLibraries.value
return own.length ? t('settings.metadata.autoFetch.scope.ownLibraries', { names: formatList(own.map((item) => item.name)), count: own.length }) : ''
})
const lastRunText = computed(() => {
const current = library.value
if (!current) return ''
if (!current.lastRunAt) return t('settings.metadata.autoFetch.scope.neverRun')
const recent = Date.now() - new Date(current.lastRunAt).getTime() < JUST_NOW_MS
const when = recent ? t('settings.metadata.autoFetch.lastRun.justNow') : formatRelativeTimeFromNow(current.lastRunAt, { smallestUnit: 'minute' })
return t('settings.metadata.autoFetch.scope.lastRun', { when, count: current.lastQueuedCount ?? 0 })
})
const runLabel = computed(() =>
library.value ? t('settings.metadata.autoFetch.run.library', { library: library.value.name }) : t('settings.metadata.autoFetch.run.all'),
)
const importOptions = computed(() => [
{
value: 'follow' as const,
label: t('settings.metadata.autoFetch.onImport.follow'),
sub: globalOn.value ? t('settings.metadata.autoFetch.onImport.on') : t('settings.metadata.autoFetch.onImport.off'),
},
{ value: 'on' as const, label: t('settings.metadata.autoFetch.onImport.on'), sub: '' },
{ value: 'off' as const, label: t('settings.metadata.autoFetch.onImport.off'), sub: '' },
])
function handleGlobalUpdated(updated: BookMetadataFetchConfig) {
globalConfig.value = updated
onMounted(scopes.load)
function selectScope(id: AutoFetchScopeId) {
scopes.select(id)
}
function setCondition(key: ConditionKey, value: BookMetadataFetchConditions[ConditionKey]) {
scopes.setCondition(key, value)
}
function followGlobal(key: ConditionKey) {
scopes.followGlobal(key)
}
function followGlobalRule() {
scopes.followGlobal('all')
}
function toggleGlobalImport(on: boolean) {
scopes.setOnImport(on ? 'on' : 'off')
}
function chooseImport(choice: OnImportChoice) {
scopes.setOnImport(choice)
}
async function handleSave() {
const name = scopes.activeName.value
if (await scopes.save()) toast.success(t('settings.metadata.autoFetch.scope.saved', { scope: name }))
else toast.error(t('settings.metadata.autoFetch.scope.saveFailed', { scope: name }))
}
function handleDiscard() {
scopes.discard()
}
async function handleRun() {
if (running.value || scopes.runBlocked.value) return
running.value = true
try {
const options = { ignoreCache: ignoreCache.value }
const { queued } = library.value ? await triggerForLibrary(library.value.id, options) : await triggerGlobal(options)
toast.success(
queued > 0 ? t('settings.metadata.autoFetch.trigger.queued', { count: queued }) : t('settings.metadata.autoFetch.trigger.noneFound'),
)
ignoreCache.value = false
await scopes.refreshRuns()
} catch {
toast.error(t('settings.metadata.autoFetch.run.failed'))
} finally {
running.value = false
}
}
</script>
<template>
<div v-if="loading" class="settings-loading-state">{{ t('common.loading') }}</div>
<div v-if="scopes.loading.value && !scopes.draftGlobal.value" class="settings-loading-state">{{ t('common.loading') }}</div>
<div v-else-if="scopes.loadFailed.value || !conditions" class="settings-error-state" role="alert">
{{ t('settings.metadata.autoFetch.scope.loadFailed') }}
</div>
<template v-else>
<p class="settings-group-label">{{ t('settings.metadata.autoFetch.globalSettings') }}</p>
<GlobalAutoFetchConfig v-if="globalConfig" :config="globalConfig" @updated="handleGlobalUpdated" />
<div v-else class="flex flex-col gap-6">
<AutoFetchQueueBand />
<template v-if="libraries.length > 0">
<p class="settings-group-label mt-8">{{ t('settings.metadata.autoFetch.perLibraryOverrides') }}</p>
<div class="flex flex-col gap-3">
<LibraryAutoFetchConfig v-for="lib in libraries" :key="lib.id" :library="lib" :global-config="globalConfig!" />
<!-- overflow-clip, not hidden: hidden would make the card the save bar's scroll container and stop it sticking. -->
<div class="@container overflow-clip rounded-lg border border-border bg-card shadow-xs" data-testid="auto-fetch-card">
<FieldRulesScopeTabs
:scopes="scopes.scopes.value"
:active-id="scopes.activeId.value"
override-count-key="settings.metadata.autoFetch.scope.ownSettings"
@select="selectScope"
/>
<div class="flex flex-col gap-3 px-4 py-3 md:min-h-14 md:flex-row md:items-center md:justify-between md:px-6">
<p v-if="library" class="min-w-0 text-[13px] text-foreground">
{{
library.draft
? t('settings.metadata.autoFetch.scope.libraryOwn', { library: library.name, count: scopes.activeOwnKeys.value.length })
: t('settings.metadata.autoFetch.scope.libraryFollows', { library: library.name })
}}
<span class="text-muted-foreground">{{ lastRunText }}</span>
</p>
<p v-else class="min-w-0 text-[13px] text-foreground">
{{ t('settings.metadata.autoFetch.scope.globalIntro') }}
<span class="text-muted-foreground">{{ globalCoverage }} {{ ownLibrariesText }}</span>
</p>
<div class="flex shrink-0 flex-wrap items-center gap-3">
<Button v-if="library?.draft" variant="ghost" size="sm" @click="followGlobalRule">{{
t('settings.metadata.autoFetch.scope.useGlobalRule')
}}</Button>
<IgnoreCacheCheckbox v-model="ignoreCache" compact :disabled="running" :hint="t('metadataCache.ignoreCache.runHint')" />
<Button
variant="outline"
size="sm"
:disabled="running || scopes.runBlocked.value"
:title="scopes.runBlocked.value ? t('settings.metadata.autoFetch.run.saveFirst') : undefined"
data-testid="auto-fetch-run"
@click="handleRun"
>
<RefreshCw :size="13" :class="running ? 'animate-spin motion-reduce:animate-none' : ''" aria-hidden="true" />
{{ runLabel }}
</Button>
</div>
</div>
</template>
</template>
<div
class="flex flex-col gap-3 border-t border-border px-4 py-3.5 md:flex-row md:items-center md:justify-between md:gap-6 md:px-6"
:class="importOwn ? 'shadow-[inset_0_2px_0_color-mix(in_oklch,var(--primary)_65%,transparent)]' : ''"
>
<div class="min-w-0">
<p id="auto-fetch-import-label" class="settings-label">{{ t('settings.metadata.autoFetch.onImport.label') }}</p>
<p class="settings-hint settings-prose">{{ t('settings.metadata.autoFetch.onImport.hint') }}</p>
</div>
<div
v-if="library"
role="radiogroup"
aria-labelledby="auto-fetch-import-label"
class="inline-flex min-w-0 shrink-0 gap-0.5 self-start rounded-md border border-input bg-muted p-0.5 md:self-center"
>
<button
v-for="option in importOptions"
:key="option.value"
type="button"
role="radio"
:aria-checked="importChoice === option.value"
class="inline-flex h-[26px] items-center justify-center gap-1.5 rounded-xs px-2.5 text-xs transition-colors focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
:class="
importChoice === option.value
? 'bg-background font-semibold text-foreground shadow-xs'
: 'font-medium text-muted-foreground hover:text-foreground'
"
@click="chooseImport(option.value)"
>
{{ option.label }}
<span v-if="option.sub" class="font-medium text-muted-foreground">{{ option.sub }}</span>
</button>
</div>
<ToggleSwitch
v-else
class="self-start md:self-center"
:model-value="globalOn"
aria-labelledby="auto-fetch-import-label"
@update:model-value="toggleGlobalImport"
/>
</div>
<div class="border-t border-border">
<AutoFetchConditionColumns
:conditions="conditions"
:breakdown="preview.breakdown.value"
:global-conditions="library ? scopes.draftGlobal.value?.conditions : null"
:own-keys="scopes.activeOwnKeys.value"
@set="setCondition"
@follow-global="followGlobal"
/>
</div>
<AutoFetchPreviewList
:items="preview.books.value?.items ?? []"
:matching="preview.breakdown.value?.matching ?? null"
:total="preview.breakdown.value?.total ?? null"
:conditions="conditions"
:library-names="libraryNames"
/>
<FieldRulesSaveBar
:dirty="scopes.activeUnsaved.value > 0"
:unsaved-count="scopes.activeUnsaved.value"
:scope-name="scopes.activeName.value"
:saving="scopes.saving.value"
@save="handleSave"
@discard="handleDiscard"
/>
</div>
</div>
</template>
@@ -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<unknown> }>>())
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: '<div data-testid="queue-band" />' } }))
vi.mock('../components/AutoFetchPreviewList.vue', () => ({
default: {
props: ['items', 'matching', 'total', 'conditions', 'libraryNames'],
template: '<div data-testid="preview" :data-matching="matching" />',
},
}))
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<ReturnType<typeof mountPage>>, 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')
})
})
@@ -0,0 +1,214 @@
<script setup lang="ts">
import { computed, useId } from 'vue'
import { useI18n } from 'vue-i18n'
import type { BookMetadataFetchBreakdown, BookMetadataFetchConditions, MetadataField } from '@bookorbit/types'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { formatList, formatNumber } from '@/i18n/formatters'
import type { ConditionKey, OverrideKey } from '../lib/auto-fetch-rules'
import AutoFetchFieldChips from './AutoFetchFieldChips.vue'
import AutoFetchScoreHistogram from './AutoFetchScoreHistogram.vue'
import AutoFetchThresholdStepper from './AutoFetchThresholdStepper.vue'
const props = defineProps<{
conditions: BookMetadataFetchConditions
breakdown: BookMetadataFetchBreakdown | null
/** Set on a library tab: each condition then says whether it follows the global rule. */
globalConditions?: BookMetadataFetchConditions | null
ownKeys?: OverrideKey[]
}>()
const emit = defineEmits<{
set: [key: ConditionKey, value: BookMetadataFetchConditions[ConditionKey]]
followGlobal: [key: ConditionKey]
}>()
const { t } = useI18n()
const ids = { neverFetched: useId(), scoreThreshold: useId(), missingFields: useId(), scoreBelow: useId() }
const isLibrary = computed(() => Boolean(props.globalConditions))
const total = computed(() => props.breakdown?.total ?? null)
const fetchedBefore = computed(() => (props.breakdown ? props.breakdown.total - props.breakdown.neverFetched : 0))
const neverShare = computed(() => (props.breakdown && props.breakdown.total > 0 ? (props.breakdown.neverFetched / props.breakdown.total) * 100 : 0))
function isOwn(key: ConditionKey): boolean {
return Boolean(props.ownKeys?.includes(key))
}
function countText(value: number | undefined): string {
return value === undefined ? '-' : formatNumber(value)
}
function globalValue(key: ConditionKey): string {
const global = props.globalConditions
if (!global) return ''
const off = t('settings.metadata.autoFetch.columns.off')
if (key === 'neverFetched') return global.neverFetched.enabled ? t('settings.metadata.autoFetch.columns.on') : off
if (key === 'scoreThreshold')
return global.scoreThreshold.enabled
? t('settings.metadata.autoFetch.columns.scoreBelowValue', { threshold: global.scoreThreshold.threshold })
: off
if (!global.missingFields.enabled || global.missingFields.fields.length === 0) return off
return formatList(global.missingFields.fields.map((field) => t(`settings.metadata.fields.${field}`)))
}
const columns = computed(() => [
{
key: 'neverFetched' as const,
titleId: ids.neverFetched,
title: t('settings.metadata.autoFetch.conditions.neverFetched.label'),
hint: t('settings.metadata.autoFetch.conditions.neverFetched.hint'),
on: props.conditions.neverFetched.enabled,
count: props.breakdown?.neverFetched,
},
{
key: 'scoreThreshold' as const,
titleId: ids.scoreThreshold,
title: t('settings.metadata.autoFetch.columns.lowScoreLabel'),
hint: t('settings.metadata.autoFetch.columns.lowScoreHint'),
on: props.conditions.scoreThreshold.enabled,
count: props.breakdown?.lowScore,
},
{
key: 'missingFields' as const,
titleId: ids.missingFields,
title: t('settings.metadata.autoFetch.conditions.missingFields.label'),
hint: t('settings.metadata.autoFetch.columns.missingHint'),
on: props.conditions.missingFields.enabled && props.conditions.missingFields.fields.length > 0,
count: props.breakdown?.missingAny,
},
])
function handleToggle(key: ConditionKey) {
const current = props.conditions[key]
emit('set', key, { ...current, enabled: !current.enabled })
}
function toggleNeverFetched() {
handleToggle('neverFetched')
}
function toggleScore() {
handleToggle('scoreThreshold')
}
function toggleMissing() {
const current = props.conditions.missingFields
// Switching the condition on with nothing picked would match no book, so start from the global picks.
const fields = !current.enabled && current.fields.length === 0 ? (props.globalConditions?.missingFields.fields ?? []) : current.fields
emit('set', 'missingFields', { enabled: !current.enabled, fields: [...fields] })
}
const TOGGLES: Record<ConditionKey, () => void> = { neverFetched: toggleNeverFetched, scoreThreshold: toggleScore, missingFields: toggleMissing }
function setThreshold(threshold: number) {
emit('set', 'scoreThreshold', { enabled: true, threshold })
}
function toggleField(field: MetadataField) {
const current = props.conditions.missingFields
const fields = current.fields.includes(field) ? current.fields.filter((item) => item !== field) : [...current.fields, field]
emit('set', 'missingFields', { enabled: true, fields })
}
function handleFollowGlobal(event: MouseEvent) {
const key = (event.currentTarget as HTMLElement).dataset.key as ConditionKey | undefined
if (key) emit('followGlobal', key)
}
</script>
<template>
<div class="grid grid-cols-1 @min-[48rem]:grid-cols-2 @min-[64rem]:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)_minmax(0,1.75fr)]">
<section
v-for="(column, index) in columns"
:key="column.key"
class="min-w-0 border-border px-4 py-4 md:px-6 md:pt-[18px] md:pb-[22px]"
:class="[
index === 0 ? '' : 'border-t',
index === 1 ? '@min-[48rem]:border-t-0 @min-[48rem]:border-s' : '',
index === 2 ? '@min-[48rem]:col-span-2 @min-[64rem]:col-span-1 @min-[64rem]:border-t-0 @min-[64rem]:border-s' : '',
isOwn(column.key) ? 'shadow-[inset_0_2px_0_color-mix(in_oklch,var(--primary)_65%,transparent)]' : '',
]"
:data-condition="column.key"
:aria-labelledby="column.titleId"
>
<div class="flex min-h-[22px] items-center gap-2.5">
<ToggleSwitch :model-value="column.on" :aria-labelledby="column.titleId" @update:model-value="TOGGLES[column.key]" />
<h3 :id="column.titleId" class="min-w-0 flex-1 text-sm font-semibold tracking-[-0.005em] text-foreground">{{ column.title }}</h3>
<span v-if="isLibrary && isOwn(column.key)" class="inline-flex shrink-0 items-center gap-2">
<span class="settings-state-pill">{{ t('settings.metadata.autoFetch.columns.own') }}</span>
<button
type="button"
class="rounded-xs text-xs font-medium text-primary underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
:data-key="column.key"
@click="handleFollowGlobal"
>
{{ t('settings.metadata.autoFetch.columns.useGlobal') }}
</button>
</span>
<span v-else-if="isLibrary" class="shrink-0 text-xs text-muted-foreground">{{ t('settings.metadata.autoFetch.columns.global') }}</span>
</div>
<p v-if="column.on" class="mt-3.5 mb-1.5 flex items-baseline gap-1.5">
<span class="text-[28px] leading-none font-[650] tracking-[-0.025em] text-foreground tabular-nums">{{ countText(column.count) }}</span>
<span class="text-[13px] font-medium text-foreground">{{
t('settings.metadata.autoFetch.columns.books', { count: column.count ?? 0 })
}}</span>
<span v-if="total !== null" class="text-[13px] text-muted-foreground">{{
t('settings.metadata.autoFetch.columns.ofTotal', { total: formatNumber(total) })
}}</span>
</p>
<p v-else class="mt-3.5 mb-1.5 text-xl leading-7 font-semibold text-muted-foreground">{{ t('settings.metadata.autoFetch.columns.off') }}</p>
<p class="settings-hint">{{ column.hint }}</p>
<p v-if="isLibrary && isOwn(column.key)" class="mt-1 text-xs text-muted-foreground">
{{ t('settings.metadata.autoFetch.columns.globalValue', { value: globalValue(column.key) }) }}
</p>
<template v-if="column.key === 'neverFetched' && breakdown">
<div class="mt-4 flex h-1.5 overflow-hidden rounded-full bg-foreground/12" aria-hidden="true">
<span class="bg-primary" :style="{ width: `${neverShare}%` }" />
</div>
<p class="mt-2 flex flex-wrap gap-x-3.5 gap-y-1 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1.5"
><span class="size-2 rounded-[3px] bg-primary" aria-hidden="true" />{{
t('settings.metadata.autoFetch.columns.neverFetchedSplit', { count: breakdown.neverFetched })
}}</span
>
<span class="inline-flex items-center gap-1.5"
><span class="size-2 rounded-[3px] bg-foreground/12" aria-hidden="true" />{{
t('settings.metadata.autoFetch.columns.fetchedBeforeSplit', { count: fetchedBefore })
}}</span
>
</p>
</template>
<div v-if="column.key === 'scoreThreshold' && conditions.scoreThreshold.enabled" class="mt-3.5">
<div class="mb-1 flex items-center gap-2">
<span :id="ids.scoreBelow" class="text-xs text-muted-foreground">{{
t('settings.metadata.autoFetch.conditions.scoreThreshold.scoreBelow')
}}</span>
<AutoFetchThresholdStepper
:model-value="conditions.scoreThreshold.threshold"
:changed="isOwn('scoreThreshold')"
:labelled-by="ids.scoreBelow"
@update:model-value="setThreshold"
/>
<span class="text-xs text-muted-foreground">{{ t('settings.metadata.autoFetch.columns.outOf') }}</span>
</div>
<AutoFetchScoreHistogram
v-if="breakdown"
:bins="breakdown.scoreBins"
:threshold="conditions.scoreThreshold.threshold"
@update:threshold="setThreshold"
/>
</div>
<div v-if="column.key === 'missingFields' && conditions.missingFields.enabled" class="mt-3.5">
<AutoFetchFieldChips
:selected="conditions.missingFields.fields"
:counts="breakdown?.missingByField ?? null"
:with-audio="breakdown?.withAudio ?? null"
@toggle="toggleField"
/>
</div>
</section>
</div>
</template>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Check } from '@lucide/vue'
import type { MetadataField } from '@bookorbit/types'
import { formatNumber } from '@/i18n/formatters'
import { AUDIO_ONLY_FIELDS, EVERY_BOOK_FIELDS } from '../lib/auto-fetch-rules'
const props = defineProps<{
selected: MetadataField[]
/** Books in scope lacking each field; null until the first count arrives. */
counts: Record<MetadataField, number> | null
withAudio: number | null
disabled?: boolean
}>()
const emit = defineEmits<{ toggle: [field: MetadataField] }>()
const { t } = useI18n()
const groups = computed(() => [
{ key: 'every', fields: EVERY_BOOK_FIELDS, heading: null as string | null, hint: null as string | null },
{
key: 'audio',
fields: AUDIO_ONLY_FIELDS,
heading: t('settings.metadata.autoFetch.columns.audioGroup'),
hint: props.withAudio === null ? null : t('settings.metadata.autoFetch.columns.audioHint', { count: props.withAudio }),
},
])
function isOn(field: MetadataField): boolean {
return props.selected.includes(field)
}
function countOf(field: MetadataField): string {
return props.counts ? formatNumber(props.counts[field] ?? 0) : '-'
}
function chipLabel(field: MetadataField): string {
const name = t(`settings.metadata.fields.${field}`)
return props.counts ? t('settings.metadata.autoFetch.columns.fieldCount', { field: name, count: props.counts[field] ?? 0 }) : name
}
function toggle(field: MetadataField) {
emit('toggle', field)
}
</script>
<template>
<div class="flex flex-col gap-3">
<div v-for="group in groups" :key="group.key">
<p v-if="group.heading" class="mb-[7px] flex flex-wrap items-baseline gap-x-2 text-xs">
<span class="font-semibold text-foreground">{{ group.heading }}</span>
<span v-if="group.hint" class="text-muted-foreground">{{ group.hint }}</span>
</p>
<div class="flex flex-wrap gap-1.5">
<button
v-for="field in group.fields"
:key="field"
type="button"
class="inline-flex h-[26px] items-center gap-1.5 rounded-full border text-xs font-medium whitespace-nowrap transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50"
:class="
isOn(field)
? 'border-primary bg-primary ps-2 pe-[9px] text-primary-foreground'
: 'border-border bg-transparent ps-2.5 pe-[9px] text-foreground hover:border-primary/50'
"
:aria-pressed="isOn(field)"
:aria-label="chipLabel(field)"
:disabled="disabled"
:data-field="field"
@click="toggle(field)"
>
<Check v-if="isOn(field)" :size="12" :stroke-width="2.5" class="-me-0.5" aria-hidden="true" />
<span>{{ t(`settings.metadata.fields.${field}`) }}</span>
<span
class="border-s ps-[7px] leading-[14px] tabular-nums"
:class="isOn(field) ? 'border-primary-foreground/35 font-semibold text-primary-foreground' : 'border-border text-muted-foreground'"
aria-hidden="true"
>
{{ countOf(field) }}
</span>
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { BookMetadataFetchConditions, BookMetadataFetchPreviewBook } from '@bookorbit/types'
import { formatNumber } from '@/i18n/formatters'
import BookCoverArtwork from '@/features/book/components/BookCoverArtwork.vue'
import BookCoverSurface from '@/features/book/components/BookCoverSurface.vue'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
const MAX_REASONS = 3
const props = defineProps<{
items: BookMetadataFetchPreviewBook[]
matching: number | null
total: number | null
conditions: BookMetadataFetchConditions
/** Library names by id, shown on the global tab where the list spans libraries. */
libraryNames?: Map<number, string> | null
}>()
const { t } = useI18n()
const { coverUrl } = useCoverVersions()
type Reason = { key: string; label: string; tone: 'never' | 'poor' | 'fair' | 'good' | 'missing' }
const TONE_CLASS: Record<Reason['tone'], string> = {
never: 'bg-foreground/8 text-foreground',
poor: 'bg-destructive/15 text-destructive',
fair: 'bg-warning/18 text-warning',
good: 'bg-success/15 text-success',
missing: 'bg-primary/13 text-primary',
}
function reasonsFor(book: BookMetadataFetchPreviewBook): Reason[] {
const out: Reason[] = []
const c = props.conditions
if (c.neverFetched.enabled && book.neverFetched)
out.push({ key: 'never', label: t('settings.metadata.autoFetch.conditions.neverFetched.label'), tone: 'never' })
if (c.scoreThreshold.enabled && (book.metadataScore === null || book.metadataScore < c.scoreThreshold.threshold)) {
const score = book.metadataScore ?? 0
out.push({
key: 'score',
label: t('settings.metadata.autoFetch.preview.score', { score: book.metadataScore === null ? '-' : formatNumber(score) }),
tone: score < 50 ? 'poor' : score < 70 ? 'fair' : 'good',
})
}
for (const field of book.missingFields) out.push({ key: field, label: t(`settings.metadata.autoFetch.preview.missing.${field}`), tone: 'missing' })
return out
}
const rows = computed(() =>
props.items.map((book) => {
const reasons = reasonsFor(book)
const library = props.libraryNames?.get(book.libraryId) ?? null
return {
book,
reasons: reasons.slice(0, MAX_REASONS),
more: Math.max(0, reasons.length - MAX_REASONS),
byline: [book.author ?? t('settings.metadata.autoFetch.preview.unknownAuthor'), library].filter(Boolean).join(' · '),
src: coverUrl(book.bookId, 'thumbnail', book.coverVersion),
frame: book.coverAspectRatio ?? '2/3',
}
}),
)
const matchingText = computed(() =>
props.matching === null || props.total === null
? ''
: t('settings.metadata.autoFetch.preview.matching', { matching: props.matching, total: formatNumber(props.total) }),
)
const showingText = computed(() => (props.matching ? t('settings.metadata.autoFetch.preview.showing', { count: props.items.length }) : ''))
</script>
<template>
<section class="border-t border-border px-4 pt-4 pb-5 md:px-6" data-testid="auto-fetch-preview">
<div class="mb-3 flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4">
<h3 class="settings-card-title">{{ t('settings.metadata.autoFetch.preview.title') }}</h3>
<p class="flex flex-wrap items-baseline gap-x-1.5 text-xs text-muted-foreground tabular-nums" aria-live="polite">
<span>{{ matchingText }}</span>
<template v-if="showingText">
<span aria-hidden="true">·</span>
<span>{{ showingText }}</span>
</template>
</p>
</div>
<p v-if="matching === 0" class="py-2 text-sm text-muted-foreground">{{ t('settings.metadata.autoFetch.preview.empty') }}</p>
<ul v-else class="grid grid-cols-1 gap-x-7 @min-[48rem]:grid-cols-2 @min-[72rem]:grid-cols-3">
<li
v-for="row in rows"
:key="row.book.bookId"
class="flex min-w-0 items-center gap-3 border-t border-border/70 py-2 first:border-t-0 @min-[48rem]:[&:nth-child(2)]:border-t-0 @min-[72rem]:[&:nth-child(3)]:border-t-0"
>
<BookCoverSurface
size="mini"
:disable-spine="row.frame === '1/1'"
class="book-cover-surface--spine-fitted relative flex h-[45px] w-[30px] shrink-0 overflow-hidden rounded-[3px]"
>
<BookCoverArtwork
:src="row.src"
:has-cover="row.book.hasCover"
:title="row.book.title"
:author-line="row.book.author"
:seed="row.book.title ?? String(row.book.bookId)"
:frame-aspect-ratio="row.frame"
:spine="row.frame !== '1/1'"
alt=""
/>
</BookCoverSurface>
<div class="min-w-0 flex-1">
<RouterLink
:to="{ name: 'book-detail', params: { bookId: row.book.bookId } }"
class="block truncate text-[13px] font-medium text-foreground transition-colors hover:text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
{{ row.book.title }}
</RouterLink>
<p class="mt-px truncate text-xs text-muted-foreground">{{ row.byline }}</p>
<div class="mt-[5px] flex flex-wrap items-center gap-1">
<span
v-for="reason in row.reasons"
:key="reason.key"
class="inline-flex h-[19px] items-center rounded-[5px] px-1.5 text-[11px] font-medium whitespace-nowrap"
:class="TONE_CLASS[reason.tone]"
>
{{ reason.label }}
</span>
<span v-if="row.more" class="text-[11px] text-muted-foreground">{{
t('settings.metadata.autoFetch.preview.more', { count: row.more })
}}</span>
</div>
</div>
</li>
</ul>
</section>
</template>
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { CirclePause, LoaderCircle, Pause, Play, RefreshCw, TriangleAlert } from '@lucide/vue'
import { Button } from '@/components/ui/button'
import { formatNumber } from '@/i18n/formatters'
import BookMetadataFetchReportModal from '@/features/book-metadata-fetch/components/BookMetadataFetchReportModal.vue'
import { useBookMetadataFetchActions } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchActions'
import { useBookMetadataFetchStatus } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchStatus'
const { t } = useI18n()
const { status, subscribe } = useBookMetadataFetchStatus()
const { pause, resume, cancelPending, retryFailed } = useBookMetadataFetchActions()
const acting = ref(false)
const confirmingCancel = ref(false)
const showReport = ref(false)
onMounted(subscribe)
// Kept between items, like the floating widget, so the line does not flicker empty on every book.
const lastItemName = ref<string | null>(null)
watch(
() => status.value.currentItemName,
(name) => {
if (name) lastItemName.value = name
},
{ immediate: true },
)
watch(
() => status.value.sessionTotal,
(total) => {
if (total === 0) lastItemName.value = null
},
)
const remaining = computed(() => status.value.queued + status.value.processing)
const state = computed<'hidden' | 'running' | 'paused' | 'failed'>(() => {
if (remaining.value > 0) return status.value.paused ? 'paused' : 'running'
return status.value.failed > 0 ? 'failed' : 'hidden'
})
const progress = computed(() => (status.value.sessionTotal > 0 ? Math.min(100, (status.value.sessionDone / status.value.sessionTotal) * 100) : 0))
async function run(action: () => Promise<unknown>, failureKey: string) {
if (acting.value) return
acting.value = true
try {
await action()
} catch {
toast.error(t(failureKey))
} finally {
acting.value = false
}
}
function handlePause() {
return run(pause, 'bookMetadataFetch.toast.failedToPause')
}
function handleResume() {
return run(resume, 'bookMetadataFetch.toast.failedToResume')
}
function askCancel() {
confirmingCancel.value = true
}
function keepRunning() {
confirmingCancel.value = false
}
function handleCancel() {
confirmingCancel.value = false
return run(cancelPending, 'bookMetadataFetch.toast.failedToCancel')
}
async function handleRetry() {
await run(async () => {
await retryFailed()
toast.success(t('bookMetadataFetch.toast.failedItemsRequeued'))
}, 'bookMetadataFetch.toast.failedToRetry')
}
function openReport() {
showReport.value = true
}
function closeReport() {
showReport.value = false
}
</script>
<template>
<section
v-if="state !== 'hidden'"
class="flex flex-wrap items-center gap-x-3.5 gap-y-3 rounded-lg border border-border bg-card px-4 py-3 shadow-xs"
role="status"
aria-live="polite"
data-testid="auto-fetch-queue"
>
<span
class="grid size-[34px] shrink-0 place-items-center rounded-[9px]"
:class="state === 'failed' ? 'bg-warning/16 text-warning' : state === 'paused' ? 'bg-muted text-foreground' : 'bg-primary/13 text-primary'"
aria-hidden="true"
>
<TriangleAlert v-if="state === 'failed'" :size="16" />
<CirclePause v-else-if="state === 'paused'" :size="16" />
<LoaderCircle v-else :size="16" class="animate-spin motion-reduce:animate-none" />
</span>
<div class="min-w-0 flex-1 basis-[calc(100%-48px)] sm:basis-0">
<template v-if="state === 'failed'">
<p class="text-sm font-medium text-foreground">{{ t('settings.metadata.autoFetch.queue.failed', { count: status.failed }) }}</p>
</template>
<template v-else>
<p class="flex flex-wrap items-baseline gap-x-2 text-sm font-medium text-foreground">
{{ state === 'paused' ? t('bookMetadataFetch.label.paused') : t('bookMetadataFetch.card.fetchingMetadata') }}
<span v-if="status.sessionTotal > 0" class="text-xs font-medium text-muted-foreground tabular-nums">
{{
t('settings.metadata.autoFetch.queue.progress', { done: formatNumber(status.sessionDone), total: formatNumber(status.sessionTotal) })
}}
</span>
</p>
<div class="mt-[7px] h-1 max-w-[40rem] overflow-hidden rounded-full bg-muted" aria-hidden="true">
<span
class="block h-full rounded-full transition-[width] duration-500 motion-reduce:transition-none"
:class="state === 'paused' ? 'bg-muted-foreground' : 'bg-primary'"
:style="{ width: `${progress}%` }"
/>
</div>
<p class="mt-[5px] flex min-w-0 items-baseline gap-1.5 text-xs text-muted-foreground">
<span class="shrink-0">{{ t('bookMetadataFetch.label.remaining', { count: formatNumber(remaining) }) }}</span>
<template v-if="lastItemName && state === 'running'">
<span aria-hidden="true">·</span>
<span class="truncate text-foreground">{{ lastItemName }}</span>
</template>
<template v-if="status.failed > 0">
<span aria-hidden="true">·</span>
<button type="button" class="shrink-0 font-medium text-primary underline-offset-4 hover:underline" @click="openReport">
{{ t('bookMetadataFetch.viewFailed', { count: status.failed }) }}
</button>
</template>
</p>
</template>
</div>
<div v-if="confirmingCancel" class="flex w-full flex-wrap items-center gap-2 ps-12 sm:w-auto sm:ps-0">
<p class="text-xs text-muted-foreground">{{ t('bookMetadataFetch.cancel.processingWillFinish') }}</p>
<Button size="sm" variant="destructive" :disabled="acting" @click="handleCancel">{{ t('bookMetadataFetch.cancel.confirm') }}</Button>
<Button size="sm" variant="outline" :disabled="acting" @click="keepRunning">{{ t('bookMetadataFetch.cancel.keepRunning') }}</Button>
</div>
<div v-else class="flex w-full shrink-0 items-center gap-2 ps-12 sm:w-auto sm:ps-0">
<template v-if="state === 'failed'">
<Button size="sm" variant="ghost" @click="openReport">{{ t('bookMetadataFetch.viewFailed', { count: status.failed }) }}</Button>
<Button size="sm" variant="outline" :disabled="acting" @click="handleRetry">
<RefreshCw :size="13" aria-hidden="true" />
{{ t('bookMetadataFetch.report.retryAllFailed') }}
</Button>
</template>
<template v-else>
<Button v-if="state === 'running'" size="sm" variant="outline" :disabled="acting" @click="handlePause">
<Pause :size="13" aria-hidden="true" />
{{ t('bookMetadataFetch.actions.pause') }}
</Button>
<Button v-else size="sm" variant="outline" :disabled="acting" @click="handleResume">
<Play :size="13" aria-hidden="true" />
{{ t('bookMetadataFetch.actions.resume') }}
</Button>
<Button v-if="status.queued > 0" size="sm" variant="ghost" :disabled="acting" @click="askCancel">{{
t('bookMetadataFetch.actions.cancelQueued')
}}</Button>
</template>
</div>
</section>
<BookMetadataFetchReportModal v-if="showReport" @close="closeReport" />
</template>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { BOOK_METADATA_FETCH_SCORE_BIN_WIDTH } from '@bookorbit/types'
const props = defineProps<{
/** Scored books per 5-point bucket, lowest first. */
bins: number[]
threshold: number
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:threshold': [value: number] }>()
const { t } = useI18n()
const track = ref<HTMLElement | null>(null)
const BANDS = [
{ key: 'poor', from: 0, to: 50, fill: 'color-mix(in oklch, var(--destructive) 26%, transparent)', ink: 'var(--destructive)' },
{ key: 'fair', from: 50, to: 70, fill: 'color-mix(in oklch, var(--warning) 26%, transparent)', ink: 'var(--warning)' },
{ key: 'good', from: 70, to: 90, fill: 'color-mix(in oklch, var(--success) 20%, transparent)', ink: 'var(--success)' },
{ key: 'great', from: 90, to: 100, fill: 'color-mix(in oklch, var(--success) 38%, transparent)', ink: 'var(--success)' },
]
// Square-root heights so a bucket of three books still reads beside one of a hundred.
const bars = computed(() => {
const max = Math.max(1, ...props.bins)
return props.bins.map((count, index) => {
const from = index * BOOK_METADATA_FETCH_SCORE_BIN_WIDTH
const to = from + BOOK_METADATA_FETCH_SCORE_BIN_WIDTH
const below = Math.max(0, Math.min(1, (props.threshold - from) / (to - from)))
return { key: from, height: count ? Math.max(7, Math.sqrt(count / max) * 100) : 0, below: count ? below * 100 : 0 }
})
})
const valueText = computed(() => t('settings.metadata.autoFetch.histogram.valueText', { threshold: props.threshold }))
function clamp(value: number): number {
return Math.max(0, Math.min(100, Math.round(value)))
}
function valueAt(clientX: number): number {
const rect = track.value?.getBoundingClientRect()
if (!rect || rect.width === 0) return props.threshold
return clamp(((clientX - rect.left) / rect.width) * 100)
}
function handlePointerDown(event: PointerEvent) {
if (props.disabled) return
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
emit('update:threshold', valueAt(event.clientX))
}
function handlePointerMove(event: PointerEvent) {
if (props.disabled || !(event.currentTarget as HTMLElement).hasPointerCapture(event.pointerId)) return
const next = valueAt(event.clientX)
if (next !== props.threshold) emit('update:threshold', next)
}
const KEY_STEPS: Record<string, number> = { ArrowLeft: -1, ArrowDown: -1, ArrowRight: 1, ArrowUp: 1, PageDown: -10, PageUp: 10 }
function handleKeydown(event: KeyboardEvent) {
if (props.disabled) return
let next: number | null = null
if (event.key in KEY_STEPS) next = props.threshold + KEY_STEPS[event.key]!
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = 100
if (next === null) return
event.preventDefault()
emit('update:threshold', clamp(next))
}
</script>
<template>
<div
class="relative touch-none select-none rounded-md pt-5 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary"
:class="disabled ? 'pointer-events-none opacity-50' : 'cursor-ew-resize'"
role="slider"
:tabindex="disabled ? -1 : 0"
:aria-label="t('settings.metadata.autoFetch.histogram.label')"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="threshold"
:aria-valuetext="valueText"
:aria-disabled="disabled || undefined"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@keydown="handleKeydown"
>
<div ref="track" class="relative flex h-14 items-end gap-0.5">
<div v-for="bar in bars" :key="bar.key" class="flex h-full flex-1 items-end">
<div class="relative w-full overflow-hidden rounded-t-[2px] bg-foreground/20" :style="{ height: `${bar.height}%` }">
<span class="absolute inset-x-0 bottom-0 bg-primary" :style="{ height: `${bar.below}%` }" />
</div>
</div>
<div
class="pointer-events-none absolute -top-1.5 -bottom-0.5 w-0 border-s-[1.5px] border-primary"
:style="{ insetInlineStart: `${threshold}%` }"
>
<span
class="absolute -top-4 start-0 -translate-x-1/2 rounded-[4px] bg-primary px-1.5 text-[10.5px] font-bold leading-[15px] text-primary-foreground tabular-nums rtl:translate-x-1/2"
>
{{ threshold }}
</span>
</div>
</div>
<div class="mt-1 flex h-[18px] overflow-hidden rounded-[5px] border border-border" aria-hidden="true">
<div
v-for="band in BANDS"
:key="band.key"
class="flex min-w-0 items-center justify-center overflow-hidden whitespace-nowrap text-[9.5px] font-bold tracking-[0.06em]"
:style="{ width: `${band.to - band.from}%`, backgroundColor: band.fill, color: band.ink }"
>
{{ t(`settings.admin.scoreWeights.rail.bands.${band.key}`) }}
</div>
</div>
</div>
</template>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Minus, Plus } from '@lucide/vue'
const STEP = 5
const props = defineProps<{
modelValue: number
/** Marks a value a library set for itself, as the Score page marks a changed weight. */
changed?: boolean
disabled?: boolean
labelledBy?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: number] }>()
const { t } = useI18n()
function clamp(value: number): number {
return Math.max(0, Math.min(100, Math.round(value)))
}
function decrease() {
emit('update:modelValue', clamp(Math.ceil(props.modelValue / STEP) * STEP - STEP))
}
function increase() {
emit('update:modelValue', clamp(Math.floor(props.modelValue / STEP) * STEP + STEP))
}
function handleChange(event: Event) {
const value = Number.parseInt((event.target as HTMLInputElement).value, 10)
emit('update:modelValue', Number.isFinite(value) ? clamp(value) : props.modelValue)
}
</script>
<template>
<div
class="inline-flex h-8 items-center overflow-hidden rounded-md border bg-background transition-colors focus-within:ring-1 focus-within:ring-ring"
:class="changed ? 'border-primary/60 bg-primary/5' : 'border-input'"
>
<button
type="button"
class="flex h-full w-7 items-center justify-center text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40 disabled:hover:text-muted-foreground focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
:disabled="disabled || modelValue <= 0"
:aria-label="t('settings.metadata.autoFetch.columns.lowerThreshold')"
@click="decrease"
>
<Minus :size="12" aria-hidden="true" />
</button>
<input
type="number"
inputmode="numeric"
min="0"
max="100"
step="1"
:value="modelValue"
:disabled="disabled"
:aria-labelledby="labelledBy"
class="h-full w-9 border-0 bg-transparent text-center text-sm font-semibold tabular-nums text-foreground outline-none [appearance:textfield] disabled:opacity-60 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
data-testid="auto-fetch-threshold-input"
@change="handleChange"
/>
<button
type="button"
class="flex h-full w-7 items-center justify-center text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40 disabled:hover:text-muted-foreground focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
:disabled="disabled || modelValue >= 100"
:aria-label="t('settings.metadata.autoFetch.columns.raiseThreshold')"
@click="increase"
>
<Plus :size="12" aria-hidden="true" />
</button>
</div>
</template>
@@ -1,98 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { BookMetadataFetchConditions, MetadataField } from '@bookorbit/types'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import MissingFieldsPicker from './MissingFieldsPicker.vue'
const { t } = useI18n()
const props = defineProps<{
modelValue: BookMetadataFetchConditions
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [BookMetadataFetchConditions] }>()
function updateScoreEnabled(enabled: boolean) {
emit('update:modelValue', { ...props.modelValue, scoreThreshold: { ...props.modelValue.scoreThreshold, enabled } })
}
function updateScoreThreshold(value: string) {
const num = Math.max(0, Math.min(100, parseInt(value, 10) || 0))
emit('update:modelValue', { ...props.modelValue, scoreThreshold: { ...props.modelValue.scoreThreshold, threshold: num } })
}
function updateMissingEnabled(enabled: boolean) {
emit('update:modelValue', { ...props.modelValue, missingFields: { ...props.modelValue.missingFields, enabled } })
}
function updateMissingFields(fields: MetadataField[]) {
emit('update:modelValue', { ...props.modelValue, missingFields: { ...props.modelValue.missingFields, fields } })
}
function updateNeverFetched(enabled: boolean) {
emit('update:modelValue', { ...props.modelValue, neverFetched: { enabled } })
}
</script>
<template>
<div class="flex flex-col gap-5">
<div class="flex items-start gap-3">
<ToggleSwitch
:model-value="modelValue.neverFetched.enabled"
:disabled="props.disabled"
@update:model-value="updateNeverFetched"
class="mt-0.5 shrink-0"
/>
<div>
<p class="settings-label">{{ t('settings.metadata.autoFetch.conditions.neverFetched.label') }}</p>
<p class="settings-hint">{{ t('settings.metadata.autoFetch.conditions.neverFetched.hint') }}</p>
</div>
</div>
<div class="flex items-start gap-3">
<ToggleSwitch
:model-value="modelValue.scoreThreshold.enabled"
:disabled="props.disabled"
@update:model-value="updateScoreEnabled"
class="mt-0.5 shrink-0"
/>
<div class="flex-1">
<p class="settings-label">{{ t('settings.metadata.autoFetch.conditions.scoreThreshold.label') }}</p>
<p class="settings-hint">{{ t('settings.metadata.autoFetch.conditions.scoreThreshold.hint') }}</p>
<div v-if="modelValue.scoreThreshold.enabled" class="flex items-center gap-2 mt-2">
<span class="text-xs text-muted-foreground">{{ t('settings.metadata.autoFetch.conditions.scoreThreshold.scoreBelow') }}</span>
<input
type="number"
min="0"
max="100"
:value="modelValue.scoreThreshold.threshold"
:disabled="props.disabled"
@input="updateScoreThreshold(($event.target as HTMLInputElement).value)"
class="w-16 h-7 px-2 text-xs border border-border rounded-md bg-background text-foreground"
/>
<span class="text-xs text-muted-foreground">/ 100</span>
</div>
</div>
</div>
<div class="flex items-start gap-3">
<ToggleSwitch
:model-value="modelValue.missingFields.enabled"
:disabled="props.disabled"
@update:model-value="updateMissingEnabled"
class="mt-0.5 shrink-0"
/>
<div class="flex-1">
<p class="settings-label">{{ t('settings.metadata.autoFetch.conditions.missingFields.label') }}</p>
<p class="settings-hint">{{ t('settings.metadata.autoFetch.conditions.missingFields.hint') }}</p>
<MissingFieldsPicker
v-if="modelValue.missingFields.enabled"
:model-value="modelValue.missingFields.fields"
:disabled="props.disabled"
class="mt-2"
@update:model-value="updateMissingFields"
/>
</div>
</div>
</div>
</template>
@@ -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<BookMetadataFetchConfig>>()
const mockStatus = ref<BookMetadataFetchStatusEvent>({
queued: 0,
processing: 0,
failed: 0,
latestFailureAt: null,
paused: false,
sessionTotal: 0,
sessionDone: 0,
currentItemName: null,
})
const mockEligibleCount = ref<number | null>(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: '<div />' },
}))
vi.mock('@/components/ui/ToggleSwitch.vue', () => ({
default: { template: '<input type="checkbox" />', props: ['modelValue', 'disabled'] },
}))
// ---------------------------------------------------------------------------
const { default: GlobalAutoFetchConfig } = await import('./GlobalAutoFetchConfig.vue')
function makeConfig(overrides: Partial<BookMetadataFetchConfig> = {}): 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<typeof mount>, 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')
})
})
@@ -1,191 +0,0 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChevronDown, ChevronUp, Save } from '@lucide/vue'
import type { BookMetadataFetchConfig } from '@bookorbit/types'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import ConditionConfigurator from './ConditionConfigurator.vue'
import { useBookMetadataFetchConfig } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchConfig'
import { useBookMetadataFetchActions } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchActions'
import { useBookMetadataFetchStatus } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchStatus'
import { invalidateEligibleCountPreviews, useEligibleCountPreview } from '@/features/book-metadata-fetch/composables/useEligibleCountPreview'
import { useMediaQuery } from '@vueuse/core'
import IgnoreCacheCheckbox from '@/features/metadata-cache/components/IgnoreCacheCheckbox.vue'
const { t } = useI18n()
const { saveGlobalConfig } = useBookMetadataFetchConfig()
const { triggerGlobal } = useBookMetadataFetchActions()
const { status } = useBookMetadataFetchStatus()
const props = defineProps<{ config: BookMetadataFetchConfig }>()
const emit = defineEmits<{ updated: [BookMetadataFetchConfig] }>()
const local = ref<BookMetadataFetchConfig>(JSON.parse(JSON.stringify(props.config)))
const saving = ref(false)
const triggering = ref(false)
const triggerResult = ref<string | null>(null)
const ignoreCache = ref(false)
const isMobile = useMediaQuery('(max-width: 767px)')
const conditionsOpen = ref(true)
const conditions = computed(() => local.value.conditions)
const { count: eligibleCount, loading: countLoading } = useEligibleCountPreview(conditions)
const statusLabel = computed<string | null>(() => {
if (triggerResult.value) return triggerResult.value
const remaining = status.value.queued + status.value.processing
if (remaining > 0) {
return status.value.paused
? t('settings.metadata.autoFetch.status.inQueuePaused', {
count: remaining,
})
: t('settings.metadata.autoFetch.status.remaining', { count: remaining })
}
if (eligibleCount.value !== null) {
return countLoading.value
? null
: t('settings.metadata.autoFetch.status.eligible', {
count: eligibleCount.value,
})
}
return null
})
const activeConditionSummary = computed(() => {
const c = local.value.conditions
const parts: string[] = []
if (c.neverFetched.enabled) parts.push(t('settings.metadata.autoFetch.conditions.neverFetched.summary'))
if (c.scoreThreshold.enabled)
parts.push(
t('settings.metadata.autoFetch.conditions.scoreThreshold.summary', {
threshold: c.scoreThreshold.threshold,
}),
)
if (c.missingFields.enabled && c.missingFields.fields.length > 0)
parts.push(
t('settings.metadata.autoFetch.conditions.missingFields.summary', {
count: c.missingFields.fields.length,
}),
)
return parts.length > 0 ? parts.join(' • ') : t('settings.metadata.autoFetch.conditions.noneEnabled')
})
watch(
() => props.config,
(c) => {
local.value = JSON.parse(JSON.stringify(c))
},
{ deep: true },
)
watch(
isMobile,
() => {
conditionsOpen.value = true
},
{ immediate: true },
)
async function handleSave() {
saving.value = true
try {
const updated = await saveGlobalConfig(local.value)
emit('updated', updated)
} finally {
saving.value = false
}
}
async function handleTrigger() {
triggering.value = true
triggerResult.value = null
try {
const { queued } = await triggerGlobal({ ignoreCache: ignoreCache.value })
triggerResult.value =
queued > 0 ? t('settings.metadata.autoFetch.trigger.queued', { count: queued }) : t('settings.metadata.autoFetch.trigger.noneFound')
invalidateEligibleCountPreviews()
} finally {
triggering.value = false
}
}
function toggleConditions() {
conditionsOpen.value = !conditionsOpen.value
}
</script>
<template>
<div class="settings-card">
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p class="settings-label">
{{ t('settings.metadata.autoFetch.enable.label') }}
</p>
<p class="settings-hint">
{{ t('settings.metadata.autoFetch.enable.hint') }}
</p>
</div>
<ToggleSwitch class="self-start" v-model="local.enabled" />
</div>
</div>
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p class="settings-label">
{{ t('settings.metadata.autoFetch.triggerOnImport.label') }}
</p>
<p class="settings-hint">
{{ t('settings.metadata.autoFetch.triggerOnImport.hint') }}
</p>
</div>
<ToggleSwitch class="self-start" v-model="local.triggerOnImport" :disabled="!local.enabled" />
</div>
</div>
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<button class="w-full flex items-center justify-between gap-2 text-left" @click="toggleConditions">
<p class="settings-label">
{{ t('settings.metadata.autoFetch.conditions.title') }}
</p>
<ChevronUp v-if="conditionsOpen" :size="15" class="text-muted-foreground shrink-0" />
<ChevronDown v-else :size="15" class="text-muted-foreground shrink-0" />
</button>
<p class="settings-hint mt-1 mb-4">
{{ t('settings.metadata.autoFetch.conditions.hint') }}
</p>
<p class="text-xs text-muted-foreground mb-3">
{{ activeConditionSummary }}
</p>
<ConditionConfigurator v-if="conditionsOpen" v-model="local.conditions" />
</div>
<div class="px-4 py-3 md:px-5 bg-card">
<IgnoreCacheCheckbox v-model="ignoreCache" :disabled="triggering" :hint="t('metadataCache.ignoreCache.runHint')" />
</div>
<div class="md:hidden sticky bottom-2 z-10 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2">
<div class="flex items-center gap-2 flex-wrap">
<Button size="sm" :disabled="saving" class="px-3" @click="handleSave" type="button">
<Save class="size-3.5" />
{{ saving ? t('settings.metadata.autoFetch.saving') : t('common.save') }}
</Button>
<Button variant="outline" size="sm" :disabled="triggering" class="h-9 px-3" @click="handleTrigger" type="button">
{{ triggering ? t('settings.metadata.autoFetch.running') : t('settings.metadata.autoFetch.runNow') }}
</Button>
<span v-if="statusLabel" class="text-xs text-muted-foreground">{{ statusLabel }}</span>
</div>
</div>
<div class="hidden md:flex items-center gap-3 px-5 py-4 bg-card">
<Button size="sm" :disabled="saving" @click="handleSave" type="button">
<Save class="size-3.5" />
{{ saving ? t('settings.metadata.autoFetch.saving') : t('common.save') }}
</Button>
<div class="w-px h-4 bg-border shrink-0" />
<Button variant="outline" size="sm" :disabled="triggering" @click="handleTrigger" type="button">
{{ triggering ? t('settings.metadata.autoFetch.running') : t('settings.metadata.autoFetch.runForEligible') }}
</Button>
<span v-if="statusLabel" class="text-xs text-muted-foreground">{{ statusLabel }}</span>
</div>
</div>
</template>
@@ -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<unknown> }>>())
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: '<button type="button" data-testid="toggle-switch" @click="$emit(\'update:modelValue\', !modelValue)" />',
},
}))
vi.mock('./ConditionConfigurator.vue', () => ({
default: {
props: ['modelValue', 'disabled'],
emits: ['update:modelValue'],
template: '<div data-testid="condition-configurator" :data-disabled="String(disabled)" />',
},
}))
function response(data: unknown) {
return {
ok: true,
json: async () => data,
}
}
function makeConfig(threshold: number): BookMetadataFetchConfig {
return {
enabled: true,
triggerOnImport: false,
conditions: {
neverFetched: { enabled: false },
scoreThreshold: { enabled: true, threshold },
missingFields: { enabled: false, fields: [] },
},
}
}
function makeLibraryConfig(): BookMetadataFetchLibraryConfig {
return {
...makeConfig(45),
override: {
enabled: true,
conditions: {
scoreThreshold: { threshold: 45 },
},
},
lastRunAt: null,
lastQueuedCount: null,
}
}
function makeInheritedLibraryConfig(): BookMetadataFetchLibraryConfig {
return {
...makeConfig(70),
override: null,
lastRunAt: null,
lastQueuedCount: null,
}
}
function makeRunHistoryConfig(lastRunAt: string, lastQueuedCount: number | null): BookMetadataFetchLibraryConfig {
return {
...makeInheritedLibraryConfig(),
lastRunAt,
lastQueuedCount,
}
}
function makeLibrary(): Library {
return {
id: 2,
type: 'books',
name: 'PDFs',
icon: null,
displayOrder: 0,
coverAspectRatio: '2/3',
watch: false,
autoScanCronExpression: null,
metadataPrecedence: [],
formatPriority: [],
allowedFormats: [],
organizationMode: 'book_per_file',
addedAtSource: 'imported',
excludePatterns: [],
readingThreshold: 10,
markAsFinishedPercentComplete: 90,
fileNamingPattern: null,
fileWriteEnabled: false,
fileWriteWriteCover: false,
fileWriteEpubEnabled: false,
fileWriteEpubMaxFileSizeMb: 100,
fileWriteFb2Enabled: false,
fileWriteFb2MaxFileSizeMb: 100,
fileWritePdfEnabled: false,
fileWritePdfMaxFileSizeMb: 100,
fileWriteCbxEnabled: false,
fileWriteCbxMaxFileSizeMb: 500,
fileWriteKindleEnabled: false,
fileWriteKindleMaxFileSizeMb: 100,
fileWriteAudioEnabled: false,
fileWriteAudioMaxFileSizeMb: 500,
fileWriteAllFiles: false,
fileWriteReadAlongEnabled: false,
fileWriteReadAlongMaxFileSizeMb: 1000,
fileRenameEnabled: false,
folders: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
}
}
function mountComponent() {
return mount(LibraryAutoFetchConfig, {
props: {
library: makeLibrary(),
globalConfig: makeConfig(70),
},
})
}
describe('LibraryAutoFetchConfig', () => {
beforeEach(() => {
vi.useFakeTimers()
apiMock.mockReset()
apiMock.mockImplementation(async (url, init) => {
const path = String(url)
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && !init) return response(makeLibraryConfig())
if (path === '/api/v1/book-metadata-fetch/preview-count') return response({ count: 8 })
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && (init as RequestInit).method === 'PUT') return response(makeLibraryConfig())
return response({})
})
})
afterEach(() => {
vi.useRealTimers()
})
it('renders stored overrides instead of assuming global inheritance', async () => {
const wrapper = mountComponent()
await flushPromises()
await vi.advanceTimersByTimeAsync(401)
await flushPromises()
expect(wrapper.text()).not.toContain('Inheriting global defaults')
expect(wrapper.text()).toContain('Score < 45')
expect(wrapper.text()).toContain('Save override')
const previewCall = apiMock.mock.calls.find(([url]) => String(url) === '/api/v1/book-metadata-fetch/preview-count')
expect(previewCall).toBeDefined()
const [, init] = previewCall as [string, RequestInit]
expect(JSON.parse(String(init.body))).toMatchObject({
libraryId: 2,
conditions: {
scoreThreshold: { threshold: 45 },
},
})
})
it('saves only config fields for a library override', async () => {
const wrapper = mountComponent()
await flushPromises()
const saveButton = wrapper.findAll('button').find((button) => button.text().includes('Save override'))
expect(saveButton).toBeDefined()
await saveButton!.trigger('click')
await flushPromises()
const putCall = apiMock.mock.calls.find(
([url, init]) => String(url) === '/api/v1/book-metadata-fetch/config/libraries/2' && (init as RequestInit)?.method === 'PUT',
)
expect(putCall).toBeDefined()
const [, init] = putCall as [string, RequestInit]
expect(JSON.parse(String(init.body))).toEqual(makeConfig(45))
})
it('persists null override when switching back to global inheritance', async () => {
apiMock.mockImplementation(async (url, init) => {
const path = String(url)
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && !init) return response(makeLibraryConfig())
if (path === '/api/v1/book-metadata-fetch/preview-count') return response({ count: 8 })
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && (init as RequestInit).method === 'PUT')
return response(makeInheritedLibraryConfig())
return response({})
})
const wrapper = mountComponent()
await flushPromises()
await wrapper.get('[data-testid="toggle-switch"]').trigger('click')
await flushPromises()
const putCall = apiMock.mock.calls.find(
([url, init]) => String(url) === '/api/v1/book-metadata-fetch/config/libraries/2' && (init as RequestInit)?.method === 'PUT',
)
expect(putCall).toBeDefined()
const [, init] = putCall as [string, RequestInit]
expect(JSON.parse(String(init.body))).toEqual({})
expect(wrapper.text()).toContain('Inheriting global defaults')
expect(wrapper.text()).not.toContain('Save override')
})
it('records a zero-queued manual run in the library card', async () => {
apiMock.mockImplementation(async (url, init) => {
const path = String(url)
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && !init) return response(makeInheritedLibraryConfig())
if (path === '/api/v1/book-metadata-fetch/preview-count') return response({ count: 8 })
if (path === '/api/v1/book-metadata-fetch/run/2' && (init as RequestInit).method === 'POST') return response({ queued: 0 })
return response({})
})
const wrapper = mountComponent()
await flushPromises()
const runButton = wrapper.findAll('button').find((button) => button.text().includes('Run now'))
expect(runButton).toBeDefined()
await runButton!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('No eligible books found')
expect(wrapper.text()).toContain('Last run: just now - no eligible books')
})
it('asks the library run to ignore cached results only when the box is checked', async () => {
apiMock.mockImplementation(async (url, init) => {
const path = String(url)
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && !init) return response(makeInheritedLibraryConfig())
if (path === '/api/v1/book-metadata-fetch/preview-count') return response({ count: 8 })
if (path === '/api/v1/book-metadata-fetch/run/2') return response({ queued: 1 })
return response({})
})
const wrapper = mountComponent()
await flushPromises()
const runNow = () => wrapper.findAll('button').find((button) => button.text().includes('Run now'))!
await runNow().trigger('click')
await flushPromises()
await wrapper.get('[data-testid="ignore-cache-checkbox"]').setValue(true)
await runNow().trigger('click')
await flushPromises()
const runs = apiMock.mock.calls.filter(([url]) => String(url) === '/api/v1/book-metadata-fetch/run/2').map(([, init]) => init as RequestInit)
expect(runs).toHaveLength(2)
expect(runs[0]).toEqual({ method: 'POST' })
expect(JSON.parse(String(runs[1]!.body))).toEqual({ ignoreCache: true })
})
it('records a queued manual run in the library card', async () => {
apiMock.mockImplementation(async (url, init) => {
const path = String(url)
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && !init) return response(makeInheritedLibraryConfig())
if (path === '/api/v1/book-metadata-fetch/preview-count') return response({ count: 8 })
if (path === '/api/v1/book-metadata-fetch/run/2' && (init as RequestInit).method === 'POST') return response({ queued: 5 })
return response({})
})
const wrapper = mountComponent()
await flushPromises()
const runButton = wrapper.findAll('button').find((button) => button.text().includes('Run now'))
expect(runButton).toBeDefined()
await runButton!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Queued 5 books')
expect(wrapper.text()).toContain('Last run: just now - queued 5 books')
})
it('allows an inherited library to start a local override without saving immediately', async () => {
apiMock.mockImplementation(async (url, init) => {
const path = String(url)
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && !init) return response(makeInheritedLibraryConfig())
if (path === '/api/v1/book-metadata-fetch/preview-count') return response({ count: 8 })
return response({})
})
const wrapper = mountComponent()
await flushPromises()
await wrapper.setProps({ globalConfig: makeConfig(80) })
await flushPromises()
await wrapper.get('[data-testid="toggle-switch"]').trigger('click')
await flushPromises()
const putCall = apiMock.mock.calls.find(
([url, init]) => String(url) === '/api/v1/book-metadata-fetch/config/libraries/2' && (init as RequestInit)?.method === 'PUT',
)
expect(putCall).toBeUndefined()
expect(wrapper.text()).toContain('Score < 80')
expect(wrapper.text()).toContain('Save override')
})
it('collapses the card and condition editor without losing state', async () => {
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.find('[data-testid="condition-configurator"]').exists()).toBe(true)
const conditionButton = wrapper.findAll('button').find((button) => button.text().includes('Eligibility conditions'))
expect(conditionButton).toBeDefined()
await conditionButton!.trigger('click')
await flushPromises()
expect(wrapper.find('[data-testid="condition-configurator"]').exists()).toBe(false)
await wrapper.get('button').trigger('click')
await flushPromises()
expect(wrapper.text()).not.toContain('Run now')
await wrapper.get('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Score < 45')
})
it('summarizes missing-field and disabled override conditions', async () => {
const disabledConfig = makeLibraryConfig()
disabledConfig.enabled = false
disabledConfig.conditions.neverFetched.enabled = true
disabledConfig.conditions.missingFields = { enabled: true, fields: ['cover'] }
disabledConfig.override = {
enabled: false,
conditions: {
neverFetched: { enabled: true },
missingFields: { enabled: true, fields: ['cover'] },
},
}
apiMock.mockImplementation(async (url, init) => {
const path = String(url)
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && !init) return response(disabledConfig)
if (path === '/api/v1/book-metadata-fetch/preview-count') return response({ count: 8 })
return response({})
})
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).toContain('Never fetched')
expect(wrapper.text()).toContain('Score < 45')
expect(wrapper.text()).toContain('Missing 1 field')
expect(wrapper.get('[data-testid="condition-configurator"]').attributes('data-disabled')).toBe('true')
})
it('summarizes empty and plural missing-field override conditions', async () => {
const config = makeLibraryConfig()
config.conditions = {
neverFetched: { enabled: false },
scoreThreshold: { enabled: false, threshold: 45 },
missingFields: { enabled: false, fields: ['cover', 'description'] },
}
apiMock.mockImplementation(async (url, init) => {
const path = String(url)
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && !init) return response(config)
if (path === '/api/v1/book-metadata-fetch/preview-count') return response({ count: 8 })
return response({})
})
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).toContain('No conditions enabled')
config.conditions.missingFields.enabled = true
wrapper.unmount()
const wrapper2 = mountComponent()
await flushPromises()
expect(wrapper2.text()).toContain('Missing 2 fields')
})
it.each([
['2026-01-10T11:50:00.000Z', null, 'Last run: 10m ago'],
['2026-01-10T09:00:00.000Z', null, 'Last run: 3h ago'],
['2026-01-09T12:00:00.000Z', null, 'Last run: yesterday'],
['2026-01-06T12:00:00.000Z', null, 'Last run: 4 days ago'],
])('formats previous run time %s', async (lastRunAt, lastQueuedCount, expected) => {
vi.setSystemTime(new Date('2026-01-10T12:00:00.000Z'))
apiMock.mockImplementation(async (url, init) => {
const path = String(url)
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && !init) return response(makeRunHistoryConfig(lastRunAt, lastQueuedCount))
if (path === '/api/v1/book-metadata-fetch/preview-count') return response({ count: 8 })
return response({})
})
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).toContain(expected)
})
})
@@ -1,281 +0,0 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChevronDown, ChevronUp, Save } from '@lucide/vue'
import type { BookMetadataFetchConfig, BookMetadataFetchLibraryConfig, Library } from '@bookorbit/types'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import ConditionConfigurator from './ConditionConfigurator.vue'
import { useBookMetadataFetchConfig } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchConfig'
import { useBookMetadataFetchActions } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchActions'
import { useBookMetadataFetchStatus } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchStatus'
import { invalidateEligibleCountPreviews, useEligibleCountPreview } from '@/features/book-metadata-fetch/composables/useEligibleCountPreview'
import { useMediaQuery } from '@vueuse/core'
import IgnoreCacheCheckbox from '@/features/metadata-cache/components/IgnoreCacheCheckbox.vue'
const props = defineProps<{
library: Library
globalConfig: BookMetadataFetchConfig
}>()
const { t } = useI18n()
const { fetchLibraryConfig, saveLibraryConfig } = useBookMetadataFetchConfig()
const { triggerForLibrary } = useBookMetadataFetchActions()
const { status } = useBookMetadataFetchStatus()
const libraryData = ref<BookMetadataFetchLibraryConfig | null>(null)
const inheriting = ref(true)
const local = ref<BookMetadataFetchConfig | null>(null)
const saving = ref(false)
const triggering = ref(false)
const triggerResult = ref<string | null>(null)
const ignoreCache = ref(false)
const loading = ref(true)
const isMobile = useMediaQuery('(max-width: 767px)')
const cardOpen = ref(true)
const conditionsOpen = ref(true)
const conditions = computed(() => (inheriting.value ? props.globalConfig.conditions : (local.value?.conditions ?? null)))
const { count: eligibleCount, loading: countLoading } = useEligibleCountPreview(conditions, props.library.id)
const statusLabel = computed<string | null>(() => {
if (triggerResult.value) return triggerResult.value
const remaining = status.value.queued + status.value.processing
if (remaining > 0) {
return status.value.paused
? t('settings.metadata.autoFetch.status.inQueuePaused', {
count: remaining,
})
: t('settings.metadata.autoFetch.status.remaining', { count: remaining })
}
if (eligibleCount.value !== null) {
return countLoading.value
? null
: t('settings.metadata.autoFetch.status.eligible', {
count: eligibleCount.value,
})
}
return null
})
const activeConditionSummary = computed(() => {
const c = displayConfig.value.conditions
const parts: string[] = []
if (c.neverFetched.enabled) parts.push(t('settings.metadata.autoFetch.conditions.neverFetched.summary'))
if (c.scoreThreshold.enabled)
parts.push(
t('settings.metadata.autoFetch.conditions.scoreThreshold.summary', {
threshold: c.scoreThreshold.threshold,
}),
)
if (c.missingFields.enabled && c.missingFields.fields.length > 0)
parts.push(
t('settings.metadata.autoFetch.conditions.missingFields.summary', {
count: c.missingFields.fields.length,
}),
)
return parts.length > 0 ? parts.join(' • ') : t('settings.metadata.autoFetch.conditions.noneEnabled')
})
const lastRunLabel = computed(() => {
if (!libraryData.value?.lastRunAt) return null
const date = new Date(libraryData.value.lastRunAt)
const diffMs = Date.now() - date.getTime()
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffMins = Math.floor(diffMs / (1000 * 60))
let when: string
if (diffMins < 2) when = t('settings.metadata.autoFetch.lastRun.justNow')
else if (diffMins < 60)
when = t('settings.metadata.autoFetch.lastRun.minutesAgo', {
count: diffMins,
})
else if (diffHours < 24)
when = t('settings.metadata.autoFetch.lastRun.hoursAgo', {
count: diffHours,
})
else if (diffDays === 1) when = t('settings.metadata.autoFetch.lastRun.yesterday')
else
when = t('settings.metadata.autoFetch.lastRun.daysAgo', {
count: diffDays,
})
const queued = libraryData.value.lastQueuedCount
if (queued === null) return t('settings.metadata.autoFetch.lastRun.label', { when })
return queued > 0
? t('settings.metadata.autoFetch.lastRun.labelQueued', {
when,
count: queued,
})
: t('settings.metadata.autoFetch.lastRun.labelNoneEligible', { when })
})
onMounted(async () => {
try {
applyLibraryData(await fetchLibraryConfig(props.library.id))
} finally {
loading.value = false
}
})
const displayConfig = computed(() => (inheriting.value ? props.globalConfig : local.value) ?? props.globalConfig)
async function handleInheritToggle(isInheriting: boolean) {
const wasInheriting = inheriting.value
inheriting.value = isInheriting
triggerResult.value = null
const nextConfig = isInheriting || wasInheriting ? props.globalConfig : (libraryData.value ?? props.globalConfig)
local.value = cloneConfigOnly(nextConfig)
if (!isInheriting) return
saving.value = true
try {
applyLibraryData(await saveLibraryConfig(props.library.id, null))
} finally {
saving.value = false
}
}
async function handleSave() {
if (!local.value) return
saving.value = true
try {
const override = inheriting.value ? null : local.value
applyLibraryData(await saveLibraryConfig(props.library.id, override))
} finally {
saving.value = false
}
}
async function handleTrigger() {
triggering.value = true
triggerResult.value = null
try {
const { queued } = await triggerForLibrary(props.library.id, { ignoreCache: ignoreCache.value })
triggerResult.value =
queued > 0 ? t('settings.metadata.autoFetch.trigger.queued', { count: queued }) : t('settings.metadata.autoFetch.trigger.noneFound')
invalidateEligibleCountPreviews()
if (libraryData.value) {
libraryData.value.lastRunAt = new Date().toISOString()
libraryData.value.lastQueuedCount = queued
}
} finally {
triggering.value = false
}
}
watch(
isMobile,
(mobile) => {
cardOpen.value = !mobile
conditionsOpen.value = true
},
{ immediate: true },
)
watch(
() => props.globalConfig,
(config) => {
if (inheriting.value) local.value = cloneConfigOnly(config)
},
{ deep: true },
)
function applyLibraryData(data: BookMetadataFetchLibraryConfig) {
libraryData.value = data
inheriting.value = data.override === null
local.value = cloneConfigOnly(data)
}
function cloneConfigOnly(config: BookMetadataFetchConfig): BookMetadataFetchConfig {
return {
enabled: config.enabled,
triggerOnImport: config.triggerOnImport,
conditions: JSON.parse(JSON.stringify(config.conditions)),
}
}
function toggleCard() {
cardOpen.value = !cardOpen.value
}
function toggleConditions() {
conditionsOpen.value = !conditionsOpen.value
}
</script>
<template>
<div class="settings-card">
<button
class="w-full flex flex-col gap-2 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card text-left"
@click="toggleCard"
>
<div>
<p class="settings-label">{{ props.library.name }}</p>
<p v-if="lastRunLabel" class="text-xs text-muted-foreground mt-0.5">
{{ lastRunLabel }}
</p>
<p class="text-xs text-muted-foreground mt-1 line-clamp-1">
{{ inheriting ? t('settings.metadata.autoFetch.library.inheritingGlobal') : activeConditionSummary }}
</p>
</div>
<div class="flex items-center gap-2 self-start">
<span class="text-xs text-muted-foreground">{{ t('settings.metadata.autoFetch.library.inheritFromGlobal') }}</span>
<ToggleSwitch :model-value="inheriting" @update:model-value="handleInheritToggle" @click.stop />
<ChevronUp v-if="cardOpen" :size="15" class="text-muted-foreground md:hidden ml-1" />
<ChevronDown v-else :size="15" class="text-muted-foreground md:hidden ml-1" />
</div>
</button>
<div v-if="loading && cardOpen" class="px-4 py-3.5 md:px-5 md:py-4 bg-card text-xs text-muted-foreground">
{{ t('common.loading') }}
</div>
<template v-else-if="cardOpen">
<div v-if="inheriting" class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<p class="text-xs text-muted-foreground italic">
{{ t('settings.metadata.autoFetch.library.usingGlobalDefaults') }}
</p>
</div>
<div v-else class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<button class="w-full flex items-center justify-between gap-2 text-left" @click="toggleConditions">
<p class="settings-label">
{{ t('settings.metadata.autoFetch.conditions.title') }}
</p>
<ChevronUp v-if="conditionsOpen" :size="15" class="text-muted-foreground shrink-0" />
<ChevronDown v-else :size="15" class="text-muted-foreground shrink-0" />
</button>
<p class="text-xs text-muted-foreground mt-1 mb-3">
{{ activeConditionSummary }}
</p>
<ConditionConfigurator v-if="conditionsOpen" v-model="local!.conditions" :disabled="!displayConfig.enabled" />
</div>
<div class="px-4 py-3 md:px-5 bg-card">
<IgnoreCacheCheckbox v-model="ignoreCache" :disabled="triggering" :hint="t('metadataCache.ignoreCache.runHint')" />
</div>
<div class="hidden md:flex items-center gap-2 md:gap-3 flex-wrap px-4 py-3.5 md:px-5 md:py-4 bg-card">
<Button size="sm" v-if="!inheriting" :disabled="saving" @click="handleSave" type="button">
<Save class="size-3.5" />
{{ saving ? t('settings.metadata.autoFetch.saving') : t('settings.metadata.autoFetch.library.saveOverride') }}
</Button>
<Button variant="outline" size="sm" :disabled="triggering" @click="handleTrigger" type="button">
{{ triggering ? t('settings.metadata.autoFetch.running') : t('settings.metadata.autoFetch.runNow') }}
</Button>
<span v-if="statusLabel" class="text-xs text-muted-foreground">{{ statusLabel }}</span>
</div>
<div class="md:hidden sticky bottom-2 z-10 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2">
<div class="flex items-center gap-2 flex-wrap">
<Button size="sm" v-if="!inheriting" :disabled="saving" class="px-3" @click="handleSave" type="button">
<Save class="size-3.5" />
{{ saving ? t('settings.metadata.autoFetch.saving') : t('settings.metadata.autoFetch.library.saveOverride') }}
</Button>
<Button variant="outline" size="sm" :disabled="triggering" class="h-9 px-3" @click="handleTrigger" type="button">
{{ triggering ? t('settings.metadata.autoFetch.running') : t('settings.metadata.autoFetch.runNow') }}
</Button>
<span v-if="statusLabel" class="text-xs text-muted-foreground">{{ statusLabel }}</span>
</div>
</div>
</template>
</div>
</template>
@@ -1,49 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { MetadataField } from '@bookorbit/types'
import { ALL_METADATA_FIELDS } from '@bookorbit/types'
const { t } = useI18n()
const props = defineProps<{
modelValue: MetadataField[]
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [MetadataField[]] }>()
function fieldLabel(field: MetadataField): string {
return t(`settings.metadata.fields.${field}`)
}
function toggle(field: MetadataField) {
if (props.disabled) return
const current = new Set(props.modelValue)
if (current.has(field)) {
current.delete(field)
} else {
current.add(field)
}
emit('update:modelValue', [...current])
}
</script>
<template>
<div class="flex flex-wrap gap-1.5">
<button
v-for="field in ALL_METADATA_FIELDS"
:key="field"
type="button"
@click="toggle(field)"
:disabled="props.disabled"
:class="[
'px-2 py-0.5 text-xs rounded-full border transition-colors',
props.modelValue.includes(field)
? 'bg-primary text-primary-foreground border-primary'
: 'bg-transparent text-muted-foreground border-border hover:border-primary/50',
props.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
]"
>
{{ fieldLabel(field) }}
</button>
</div>
</template>
@@ -0,0 +1,180 @@
import { flushPromises, mount } from '@vue/test-utils'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BookMetadataFetchConditions, BookMetadataFetchPreviewBook, BookMetadataFetchStatusEvent } from '@bookorbit/types'
import AutoFetchConditionColumns from '../AutoFetchConditionColumns.vue'
import AutoFetchPreviewList from '../AutoFetchPreviewList.vue'
import AutoFetchQueueBand from '../AutoFetchQueueBand.vue'
import AutoFetchScoreHistogram from '../AutoFetchScoreHistogram.vue'
const status = vi.hoisted(() => ({ value: null as unknown }))
const actions = vi.hoisted(() => ({
pause: vi.fn<() => Promise<void>>(),
resume: vi.fn<() => Promise<void>>(),
cancelPending: vi.fn<() => Promise<void>>(),
retryFailed: vi.fn<() => Promise<unknown>>(),
}))
vi.mock('@/features/book-metadata-fetch/composables/useBookMetadataFetchStatus', () => ({
useBookMetadataFetchStatus: () => ({ status: status.value, subscribe: vi.fn<() => void>() }),
}))
vi.mock('@/features/book-metadata-fetch/composables/useBookMetadataFetchActions', () => ({ useBookMetadataFetchActions: () => actions }))
vi.mock('@/features/book-metadata-fetch/components/BookMetadataFetchReportModal.vue', () => ({
default: { template: '<div data-testid="report" />' },
}))
vi.mock('vue-sonner', () => ({ toast: { success: vi.fn<(message: string) => void>(), error: vi.fn<(message: string) => void>() } }))
const conditions: BookMetadataFetchConditions = {
neverFetched: { enabled: true },
scoreThreshold: { enabled: true, threshold: 60 },
missingFields: { enabled: true, fields: ['description'] },
}
function idle(overrides: Partial<BookMetadataFetchStatusEvent> = {}): BookMetadataFetchStatusEvent {
return {
queued: 0,
processing: 0,
failed: 0,
latestFailureAt: null,
paused: false,
sessionTotal: 0,
sessionDone: 0,
currentItemName: null,
...overrides,
}
}
describe('AutoFetchScoreHistogram', () => {
it('moves the line with the keyboard and states the value for assistive technology', async () => {
const wrapper = mount(AutoFetchScoreHistogram, { props: { bins: Array.from({ length: 20 }, () => 2), threshold: 60 } })
const slider = wrapper.get('[role="slider"]')
expect(slider.attributes('aria-valuetext')).toBe('Score below 60')
await slider.trigger('keydown', { key: 'ArrowRight' })
await slider.trigger('keydown', { key: 'PageDown' })
await slider.trigger('keydown', { key: 'End' })
await slider.trigger('keydown', { key: 'Home' })
await slider.trigger('keydown', { key: 'a' })
expect(wrapper.emitted('update:threshold')).toEqual([[61], [50], [100], [0]])
})
it('fills each bar up to the line', () => {
const wrapper = mount(AutoFetchScoreHistogram, { props: { bins: Array.from({ length: 20 }, () => 4), threshold: 62 } })
const fills = wrapper.findAll('[role="slider"] span.bg-primary').map((fill) => (fill.element as HTMLElement).style.height)
expect(fills[11]).toBe('100%')
expect(fills[12]).toBe('40%')
expect(fills[13]).toBe('0%')
})
})
describe('AutoFetchConditionColumns', () => {
it('adds a picked field to the rule and starts an empty field list from the global picks', async () => {
const wrapper = mount(AutoFetchConditionColumns, { props: { conditions, breakdown: null } })
await wrapper.get('[data-field="genres"]').trigger('click')
expect(wrapper.emitted('set')![0]).toEqual(['missingFields', { enabled: true, fields: ['description', 'genres'] }])
const off: BookMetadataFetchConditions = { ...conditions, missingFields: { enabled: false, fields: [] } }
const library = mount(AutoFetchConditionColumns, {
props: { conditions: off, breakdown: null, globalConditions: conditions, ownKeys: ['missingFields'] },
})
await library.get('[data-condition="missingFields"] [role="switch"]').trigger('click')
expect(library.emitted('set')![0]).toEqual(['missingFields', { enabled: true, fields: ['description'] }])
await library.get('[data-condition="missingFields"] button[data-key="missingFields"]').trigger('click')
expect(library.emitted('followGlobal')![0]).toEqual(['missingFields'])
})
})
describe('AutoFetchPreviewList', () => {
const book: BookMetadataFetchPreviewBook = {
bookId: 9,
libraryId: 6,
title: 'Arkham Asylum',
author: null,
metadataScore: 26,
neverFetched: true,
missingFields: ['description', 'cover', 'seriesName'],
hasCover: true,
coverAspectRatio: '2/3',
coverVersion: 'ebook:1',
}
it('shows at most three reasons, coloured by kind, and names the library on the global tab', () => {
const wrapper = mount(AutoFetchPreviewList, {
props: { items: [book], matching: 10, total: 10, conditions, libraryNames: new Map([[6, 'Comics']]) },
global: { stubs: { RouterLink: { template: '<a><slot /></a>' } } },
})
const reasons = wrapper
.findAll('li span.rounded-\\[5px\\]')
.map((reason) => [reason.text(), reason.classes().find((name) => /^text-(?!\[)/.test(name))])
expect(reasons).toEqual([
['Never fetched', 'text-foreground'],
['Score 26', 'text-destructive'],
['No description', 'text-primary'],
])
expect(wrapper.text()).toContain('+2')
expect(wrapper.text()).toContain('Unknown author · Comics')
expect(wrapper.text()).toContain('10 of 10 books match')
})
it('says so when no book matches', () => {
const wrapper = mount(AutoFetchPreviewList, { props: { items: [], matching: 0, total: 10, conditions } })
expect(wrapper.text()).toContain('No book matches this rule, so a run would queue nothing.')
})
})
describe('AutoFetchQueueBand', () => {
beforeEach(() => {
Object.values(actions).forEach((action) => action.mockReset().mockResolvedValue(undefined))
})
it('stays hidden while nothing is queued or failed', () => {
status.value = ref(idle())
expect(mount(AutoFetchQueueBand).find('[data-testid="auto-fetch-queue"]').exists()).toBe(false)
})
it('shows progress with the book in hand, and asks before cancelling the queue', async () => {
status.value = ref(idle({ queued: 300, processing: 1, sessionTotal: 466, sessionDone: 165, currentItemName: 'Hex' }))
const wrapper = mount(AutoFetchQueueBand)
expect(wrapper.text()).toContain('Fetching metadata')
expect(wrapper.text()).toContain('165 of 466')
expect(wrapper.text()).toContain('Hex')
await wrapper
.findAll('button')
.find((button) => button.text() === 'Cancel queued')!
.trigger('click')
expect(actions.cancelPending).not.toHaveBeenCalled()
await wrapper
.findAll('button')
.find((button) => button.text() === 'Confirm cancel')!
.trigger('click')
await flushPromises()
expect(actions.cancelPending).toHaveBeenCalledOnce()
})
it('offers resume while paused and retry once only failures are left', async () => {
status.value = ref(idle({ queued: 4, paused: true, sessionTotal: 10, sessionDone: 6 }))
const paused = mount(AutoFetchQueueBand)
await paused
.findAll('button')
.find((button) => button.text() === 'Resume')!
.trigger('click')
expect(actions.resume).toHaveBeenCalledOnce()
status.value = ref(idle({ failed: 3 }))
const failed = mount(AutoFetchQueueBand)
expect(failed.text()).toContain('3 books could not be fetched')
await failed
.findAll('button')
.find((button) => button.text() === 'Retry all failed')!
.trigger('click')
await flushPromises()
expect(actions.retryFailed).toHaveBeenCalledOnce()
await failed
.findAll('button')
.find((button) => button.text() === 'View 3 failed')!
.trigger('click')
expect(failed.find('[data-testid="report"]').exists()).toBe(true)
})
})
@@ -0,0 +1,64 @@
import { onScopeDispose, ref, watch, type Ref } from 'vue'
import type {
BookMetadataFetchBreakdown,
BookMetadataFetchConditions,
BookMetadataFetchPreviewBooks,
BookMetadataFetchPreviewScope,
} from '@bookorbit/types'
import { api } from '@/lib/api'
const DEBOUNCE_MS = 250
async function post<T>(path: string, body: unknown): Promise<T | null> {
const res = await api(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
return res.ok ? ((await res.json()) as T) : null
}
/**
* Live counts and the books a draft rule would take. Earlier answers stay on screen while a newer one loads,
* and an answer that arrives after the rule has moved on is dropped.
*/
export function useAutoFetchPreview(conditions: Ref<BookMetadataFetchConditions | null>, libraryIds: Ref<number[]>, limit: Ref<number>) {
const breakdown = ref<BookMetadataFetchBreakdown | null>(null)
const books = ref<BookMetadataFetchPreviewBooks | null>(null)
const pending = ref(false)
const failed = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
let request = 0
async function load() {
const current = ++request
const draft = conditions.value
if (!draft) return
pending.value = true
const scope: BookMetadataFetchPreviewScope = { conditions: draft, libraryIds: libraryIds.value }
try {
const [nextBreakdown, nextBooks] = await Promise.all([
post<BookMetadataFetchBreakdown>('/api/v1/book-metadata-fetch/preview-breakdown', scope),
post<BookMetadataFetchPreviewBooks>('/api/v1/book-metadata-fetch/preview-books', { ...scope, limit: limit.value }),
])
if (current !== request) return
failed.value = !nextBreakdown || !nextBooks
if (nextBreakdown) breakdown.value = nextBreakdown
if (nextBooks) books.value = nextBooks
} catch {
if (current === request) failed.value = true
} finally {
if (current === request) pending.value = false
}
}
function schedule() {
request += 1
if (timer) clearTimeout(timer)
timer = setTimeout(load, DEBOUNCE_MS)
}
watch([conditions, libraryIds, limit], schedule, { deep: true, immediate: true })
onScopeDispose(() => {
request += 1
if (timer) clearTimeout(timer)
})
return { breakdown, books, pending, failed, refresh: schedule }
}
@@ -0,0 +1,229 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
BookMetadataFetchConditions,
BookMetadataFetchConfig,
BookMetadataFetchConfigOverride,
BookMetadataFetchLibraryConfig,
BookMetadataFetchLibraryConfigEntry,
} from '@bookorbit/types'
import { api } from '@/lib/api'
import { GLOBAL_SCOPE, type FieldRuleScope } from '@/features/settings/metadata-preferences/composables/useFieldRuleScopes'
import {
clearOverrideKey,
globalChangeCount,
mergeConfig,
overriddenKeys,
overrideChangeCount,
overrideRequestBody,
setOverrideCondition,
setOverrideOnImport,
withOnImport,
type ConditionKey,
type OnImportChoice,
type OverrideKey,
} from '../lib/auto-fetch-rules'
export type AutoFetchScopeId = typeof GLOBAL_SCOPE | number
export interface AutoFetchLibraryScope {
id: number
name: string
saved: BookMetadataFetchConfigOverride
draft: BookMetadataFetchConfigOverride
lastRunAt: string | null
lastQueuedCount: number | null
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
}
/** Drafts for the global rule and every library's own rule, saved one scope at a time like Field Rules. */
export function useAutoFetchScopes() {
const { t } = useI18n()
const loading = ref(true)
const loadFailed = ref(false)
const saving = ref(false)
const savedGlobal = ref<BookMetadataFetchConfig | null>(null)
const draftGlobal = ref<BookMetadataFetchConfig | null>(null)
const libraries = ref<AutoFetchLibraryScope[]>([])
const activeId = ref<AutoFetchScopeId>(GLOBAL_SCOPE)
async function load(): Promise<void> {
loading.value = true
loadFailed.value = false
try {
const [configRes, librariesRes] = await Promise.all([
api('/api/v1/book-metadata-fetch/config'),
api('/api/v1/book-metadata-fetch/config/libraries'),
])
if (!configRes.ok || !librariesRes.ok) throw new Error('load failed')
const config: BookMetadataFetchConfig = await configRes.json()
const entries: BookMetadataFetchLibraryConfigEntry[] = await librariesRes.json()
savedGlobal.value = config
draftGlobal.value = clone(config)
libraries.value = entries.map((entry) => ({
id: entry.libraryId,
name: entry.libraryName,
saved: entry.override,
draft: clone(entry.override),
lastRunAt: entry.lastRunAt,
lastQueuedCount: entry.lastQueuedCount,
}))
if (activeId.value !== GLOBAL_SCOPE && !libraries.value.some((library) => library.id === activeId.value)) activeId.value = GLOBAL_SCOPE
} catch {
loadFailed.value = true
} finally {
loading.value = false
}
}
/** Picks up the last-run lines after a run without touching anyone's drafts. */
async function refreshRuns(): Promise<void> {
const res = await api('/api/v1/book-metadata-fetch/config/libraries')
if (!res.ok) return
const entries: BookMetadataFetchLibraryConfigEntry[] = await res.json()
for (const entry of entries) {
const library = libraries.value.find((item) => item.id === entry.libraryId)
if (!library) continue
library.lastRunAt = entry.lastRunAt
library.lastQueuedCount = entry.lastQueuedCount
}
}
const activeLibrary = computed(() =>
activeId.value === GLOBAL_SCOPE ? null : (libraries.value.find((library) => library.id === activeId.value) ?? null),
)
const activeConfig = computed<BookMetadataFetchConfig | null>(() => {
if (!draftGlobal.value) return null
return activeLibrary.value ? mergeConfig(draftGlobal.value, activeLibrary.value.draft) : draftGlobal.value
})
const followerIds = computed(() => libraries.value.filter((library) => !library.draft).map((library) => library.id))
const ownRuleLibraries = computed(() => libraries.value.filter((library) => library.draft))
/** The libraries the active scope's counts and preview cover. */
const activeLibraryIds = computed(() => (activeLibrary.value ? [activeLibrary.value.id] : followerIds.value))
const activeOwnKeys = computed<OverrideKey[]>(() => (activeLibrary.value ? overriddenKeys(activeLibrary.value.draft) : []))
const globalUnsaved = computed(() => (savedGlobal.value && draftGlobal.value ? globalChangeCount(savedGlobal.value, draftGlobal.value) : 0))
function libraryUnsaved(library: AutoFetchLibraryScope): number {
return overrideChangeCount(library.saved, library.draft)
}
const activeUnsaved = computed(() => (activeLibrary.value ? libraryUnsaved(activeLibrary.value) : globalUnsaved.value))
const anyUnsaved = computed(() => globalUnsaved.value > 0 || libraries.value.some((library) => libraryUnsaved(library) > 0))
/** A library's rule is built on the global one, so either having unsaved edits makes a run disagree with the page. */
const runBlocked = computed(() => (activeLibrary.value ? globalUnsaved.value > 0 || libraryUnsaved(activeLibrary.value) > 0 : anyUnsaved.value))
const scopes = computed<FieldRuleScope[]>(() => [
{
id: GLOBAL_SCOPE,
name: t('settings.metadata.fieldRules.scope.global'),
isGlobal: true,
overrideCount: 0,
unsavedCount: globalUnsaved.value,
loaded: true,
},
...libraries.value.map((library) => ({
id: library.id,
name: library.name,
isGlobal: false,
overrideCount: overriddenKeys(library.draft).length,
unsavedCount: libraryUnsaved(library),
loaded: true,
})),
])
const activeName = computed(() => activeLibrary.value?.name ?? t('settings.metadata.fieldRules.scope.global'))
function select(id: AutoFetchScopeId): void {
activeId.value = id
}
function setCondition<K extends ConditionKey>(key: K, value: BookMetadataFetchConditions[K]): void {
if (!draftGlobal.value) return
const library = activeLibrary.value
if (library) library.draft = setOverrideCondition(draftGlobal.value, library.draft, key, value)
else draftGlobal.value = { ...draftGlobal.value, conditions: { ...draftGlobal.value.conditions, [key]: clone(value) } }
}
function setOnImport(choice: OnImportChoice): void {
if (!draftGlobal.value) return
const library = activeLibrary.value
if (library) library.draft = setOverrideOnImport(library.draft, choice)
else draftGlobal.value = withOnImport(draftGlobal.value, choice === 'on')
}
function followGlobal(key: OverrideKey | 'all'): void {
const library = activeLibrary.value
if (library) library.draft = clearOverrideKey(library.draft, key)
}
function discard(): void {
const library = activeLibrary.value
if (library) library.draft = clone(library.saved)
else if (savedGlobal.value) draftGlobal.value = clone(savedGlobal.value)
}
/** Saves the active scope only. Returns whether the server took it. */
async function save(): Promise<boolean> {
if (saving.value || !draftGlobal.value) return false
saving.value = true
try {
const library = activeLibrary.value
if (library) {
const res = await api(`/api/v1/book-metadata-fetch/config/libraries/${library.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(overrideRequestBody(library.draft)),
})
if (!res.ok) return false
const data: BookMetadataFetchLibraryConfig = await res.json()
library.saved = data.override
library.draft = clone(data.override)
return true
}
const res = await api('/api/v1/book-metadata-fetch/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(draftGlobal.value),
})
if (!res.ok) return false
const data: BookMetadataFetchConfig = await res.json()
savedGlobal.value = data
draftGlobal.value = clone(data)
return true
} catch {
return false
} finally {
saving.value = false
}
}
return {
loading,
loadFailed,
saving,
savedGlobal,
draftGlobal,
libraries,
activeId,
activeLibrary,
activeConfig,
activeLibraryIds,
activeOwnKeys,
activeUnsaved,
activeName,
followerIds,
ownRuleLibraries,
anyUnsaved,
runBlocked,
scopes,
load,
refreshRuns,
select,
setCondition,
setOnImport,
followGlobal,
discard,
save,
}
}
@@ -0,0 +1,90 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import type { BookMetadataFetchConfig } from '@bookorbit/types'
import {
clearOverrideKey,
fetchesOnImport,
globalChangeCount,
mergeConfig,
onImportChoice,
overriddenKeys,
overrideChangeCount,
overrideRequestBody,
setOverrideCondition,
setOverrideOnImport,
withOnImport,
} from '../auto-fetch-rules'
const global: BookMetadataFetchConfig = {
enabled: true,
triggerOnImport: false,
conditions: {
neverFetched: { enabled: true },
scoreThreshold: { enabled: true, threshold: 60 },
missingFields: { enabled: true, fields: ['description', 'cover'] },
},
}
describe('auto-fetch rules', () => {
it('treats imports as on only when both flags are on, and writes both together', () => {
expect(fetchesOnImport(global)).toBe(false)
const on = withOnImport(global, true)
expect(on).toMatchObject({ enabled: true, triggerOnImport: true })
expect(fetchesOnImport(on)).toBe(true)
expect(withOnImport(on, false)).toMatchObject({ enabled: false, triggerOnImport: false })
})
it('stores only the condition a library changes, so the rest keeps following global', () => {
const override = setOverrideCondition(global, null, 'scoreThreshold', { enabled: true, threshold: 75 })
expect(override).toEqual({ conditions: { scoreThreshold: { enabled: true, threshold: 75 } } })
expect(overriddenKeys(override)).toEqual(['scoreThreshold'])
const later = { ...global, conditions: { ...global.conditions, missingFields: { enabled: true, fields: ['genres' as const] } } }
expect(mergeConfig(later, override).conditions.missingFields.fields).toEqual(['genres'])
expect(mergeConfig(later, override).enabled).toBe(true)
})
it('drops a condition set back to the global value, and the override with it', () => {
const own = setOverrideCondition(global, null, 'missingFields', { enabled: true, fields: ['cover'] })
expect(own).not.toBeNull()
expect(setOverrideCondition(global, own, 'missingFields', { enabled: true, fields: ['cover', 'description'] })).toBeNull()
})
it('maps the on-import choice to the two flags and back', () => {
const on = setOverrideOnImport(null, 'on')
expect(on).toEqual({ enabled: true, triggerOnImport: true })
expect(onImportChoice(on)).toBe('on')
expect(onImportChoice(setOverrideOnImport(on, 'off'))).toBe('off')
expect(setOverrideOnImport(on, 'follow')).toBeNull()
expect(onImportChoice(null)).toBe('follow')
expect(overriddenKeys(on)).toEqual(['onImport'])
})
it('reads a legacy full copy as every setting overridden', () => {
const legacy = { ...global, conditions: { ...global.conditions } }
expect(overriddenKeys(legacy)).toEqual(['neverFetched', 'scoreThreshold', 'missingFields', 'onImport'])
expect(clearOverrideKey(legacy, 'onImport')).toEqual({ conditions: legacy.conditions })
expect(clearOverrideKey(legacy, 'all')).toBeNull()
})
it('counts unsaved changes per setting', () => {
const draft = withOnImport({ ...global, conditions: { ...global.conditions, scoreThreshold: { enabled: true, threshold: 70 } } }, true)
expect(globalChangeCount(global, draft)).toBe(2)
expect(
globalChangeCount(global, {
...global,
conditions: { ...global.conditions, missingFields: { enabled: true, fields: ['cover', 'description'] } },
}),
).toBe(0)
const saved = setOverrideCondition(global, null, 'scoreThreshold', { enabled: true, threshold: 75 })
const edited = setOverrideOnImport(setOverrideCondition(global, saved, 'scoreThreshold', { enabled: true, threshold: 80 }), 'on')
expect(overrideChangeCount(saved, edited)).toBe(2)
expect(overrideChangeCount(saved, saved)).toBe(0)
})
it('sends an empty object to clear an override', () => {
expect(overrideRequestBody(null)).toEqual({})
expect(overrideRequestBody({ enabled: false })).toEqual({ enabled: false })
})
})
@@ -0,0 +1,137 @@
import type { BookMetadataFetchConditions, BookMetadataFetchConfig, BookMetadataFetchConfigOverride, MetadataField } from '@bookorbit/types'
import { ALL_METADATA_FIELDS } from '@bookorbit/types'
export type ConditionKey = keyof BookMetadataFetchConditions
export type OverrideKey = ConditionKey | 'onImport'
export type OnImportChoice = 'follow' | 'on' | 'off'
export const CONDITION_KEYS: ConditionKey[] = ['neverFetched', 'scoreThreshold', 'missingFields']
/** Only a book with audio can lack these; the server skips them for every other book. */
export const AUDIO_ONLY_FIELDS: MetadataField[] = ['audioCover', 'narrators', 'duration', 'abridged']
export const EVERY_BOOK_FIELDS: MetadataField[] = ALL_METADATA_FIELDS.filter((field) => !AUDIO_ONLY_FIELDS.includes(field))
type Override = NonNullable<BookMetadataFetchConfigOverride>
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
}
/** Mirrors the server's merge: top-level keys first, then each condition on its own. */
export function mergeConfig(global: BookMetadataFetchConfig, override: BookMetadataFetchConfigOverride): BookMetadataFetchConfig {
if (!override) return clone(global)
const conditions = override.conditions ?? {}
return {
enabled: override.enabled ?? global.enabled,
triggerOnImport: override.triggerOnImport ?? global.triggerOnImport,
conditions: {
neverFetched: { ...global.conditions.neverFetched, ...conditions.neverFetched },
scoreThreshold: { ...global.conditions.scoreThreshold, ...conditions.scoreThreshold },
missingFields: { ...global.conditions.missingFields, ...conditions.missingFields },
},
}
}
/**
* Imports are queued only when both flags are on, and nothing else reads `enabled`, so the page shows them
* as one switch and writes them together.
*/
export function fetchesOnImport(config: Pick<BookMetadataFetchConfig, 'enabled' | 'triggerOnImport'>): boolean {
return config.enabled && config.triggerOnImport
}
export function withOnImport(config: BookMetadataFetchConfig, on: boolean): BookMetadataFetchConfig {
return { ...clone(config), enabled: on, triggerOnImport: on }
}
export function conditionsEqual<K extends ConditionKey>(key: K, a: BookMetadataFetchConditions[K], b: BookMetadataFetchConditions[K]): boolean {
if (key === 'missingFields') {
const left = a as BookMetadataFetchConditions['missingFields']
const right = b as BookMetadataFetchConditions['missingFields']
return left.enabled === right.enabled && [...left.fields].sort().join('|') === [...right.fields].sort().join('|')
}
return JSON.stringify(a) === JSON.stringify(b)
}
export function overriddenKeys(override: BookMetadataFetchConfigOverride): OverrideKey[] {
if (!override) return []
const keys: OverrideKey[] = CONDITION_KEYS.filter((key) => override.conditions?.[key] !== undefined)
if (override.enabled !== undefined || override.triggerOnImport !== undefined) keys.push('onImport')
return keys
}
export function onImportChoice(override: BookMetadataFetchConfigOverride): OnImportChoice {
if (!override || (override.enabled === undefined && override.triggerOnImport === undefined)) return 'follow'
return override.enabled !== false && override.triggerOnImport !== false ? 'on' : 'off'
}
/** Drops empty pieces so a library with nothing of its own is stored as `null` and follows the global rule. */
export function tidyOverride(override: Override | null): BookMetadataFetchConfigOverride {
if (!override) return null
const next = clone(override)
if (next.conditions && Object.keys(next.conditions).length === 0) delete next.conditions
return Object.keys(next).length > 0 ? next : null
}
/**
* Sets one condition for a library. A value equal to the global one is not stored, so the library goes back
* to following that condition and picks up later global changes.
*/
export function setOverrideCondition<K extends ConditionKey>(
global: BookMetadataFetchConfig,
override: BookMetadataFetchConfigOverride,
key: K,
value: BookMetadataFetchConditions[K],
): BookMetadataFetchConfigOverride {
const next: Override = override ? clone(override) : {}
const conditions = { ...next.conditions }
if (conditionsEqual(key, value, global.conditions[key])) delete conditions[key]
else conditions[key] = clone(value)
next.conditions = conditions
return tidyOverride(next)
}
export function clearOverrideKey(override: BookMetadataFetchConfigOverride, key: OverrideKey | 'all'): BookMetadataFetchConfigOverride {
if (!override || key === 'all') return null
const next = clone(override)
if (key === 'onImport') {
delete next.enabled
delete next.triggerOnImport
} else if (next.conditions) {
delete next.conditions[key]
}
return tidyOverride(next)
}
export function setOverrideOnImport(override: BookMetadataFetchConfigOverride, choice: OnImportChoice): BookMetadataFetchConfigOverride {
const next: Override = override ? clone(override) : {}
delete next.enabled
delete next.triggerOnImport
if (choice !== 'follow') {
next.enabled = choice === 'on'
next.triggerOnImport = choice === 'on'
}
return tidyOverride(next)
}
/** What the server stores for a library: `{}` is how the API clears an override back to `null`. */
export function overrideRequestBody(override: BookMetadataFetchConfigOverride): Override {
return override ? clone(override) : {}
}
export function globalChangeCount(saved: BookMetadataFetchConfig, draft: BookMetadataFetchConfig): number {
let changes = fetchesOnImport(saved) === fetchesOnImport(draft) ? 0 : 1
for (const key of CONDITION_KEYS) if (!conditionsEqual(key, saved.conditions[key], draft.conditions[key])) changes += 1
return changes
}
export function overrideChangeCount(saved: BookMetadataFetchConfigOverride, draft: BookMetadataFetchConfigOverride): number {
let changes = onImportChoice(saved) === onImportChoice(draft) ? 0 : 1
for (const key of CONDITION_KEYS) {
const before = saved?.conditions?.[key]
const after = draft?.conditions?.[key]
if (before === undefined && after === undefined) continue
if (before === undefined || after === undefined || !conditionsEqual(key, before as never, after as never)) changes += 1
}
return changes
}
@@ -5,7 +5,15 @@ import type { FieldRuleScope, FieldRuleScopeId } from '../composables/useFieldRu
const { t } = useI18n()
defineProps<{ scopes: FieldRuleScope[]; activeId: FieldRuleScopeId }>()
withDefaults(
defineProps<{
scopes: FieldRuleScope[]
activeId: FieldRuleScopeId
/** What the badge counts, for pages whose overrides are not fields. */
overrideCountKey?: string
}>(),
{ overrideCountKey: 'settings.metadata.fieldRules.scope.overrideCount' },
)
const emit = defineEmits<{ select: [id: FieldRuleScopeId] }>()
function select(id: FieldRuleScopeId) {
@@ -45,7 +53,7 @@ function select(id: FieldRuleScopeId) {
<span
v-else-if="scope.overrideCount > 0"
class="rounded-xs bg-primary/15 px-1.5 py-px text-[10px] font-bold tabular-nums text-primary"
:aria-label="t('settings.metadata.fieldRules.scope.overrideCount', { count: scope.overrideCount })"
:aria-label="t(overrideCountKey, { count: scope.overrideCount })"
>
{{ scope.overrideCount }}
</span>
+86
View File
@@ -1866,6 +1866,92 @@
"inheritFromGlobal": "Inherit from global",
"usingGlobalDefaults": "Using global defaults.",
"saveOverride": "Save override"
},
"scope": {
"ownSettings": "{count, plural, one {# own setting} other {# own settings}}",
"globalIntro": "Every library follows this rule unless it has its own.",
"globalCoverage": "{libraryCount, plural, one {Applies to # library} other {Applies to # libraries}}, {bookCount, plural, one {# book} other {# books}}.",
"ownLibraries": "{names} {count, plural, one {has its own rule.} other {have their own rules.}}",
"libraryFollows": "{library} follows the global rule. Change anything here to give it its own.",
"libraryOwn": "{library} has its own rule for {count, plural, one {# setting} other {# settings}}. Everything else follows the global rule.",
"lastRun": "Last run {when}, queued {count, plural, one {# book} other {# books}}.",
"neverRun": "Not run yet.",
"useGlobalRule": "Use the global rule",
"loadFailed": "Auto-fetch settings could not be loaded. Reload the page to try again.",
"saved": "Saved {scope}",
"saveFailed": "Could not save {scope}. Try again."
},
"run": {
"all": "Run for every library",
"library": "Run for {library}",
"saveFirst": "Runs use the saved rule. Save first.",
"failed": "The run could not be started. Try again."
},
"onImport": {
"label": "Fetch metadata for new books",
"hint": "Scans and uploads queue matching books right away. A fetch on import only fills empty fields; locked fields are never changed.",
"follow": "Follow global",
"on": "On",
"off": "Off"
},
"columns": {
"books": "{count, plural, one {book} other {books}}",
"ofTotal": "of {total}",
"off": "Off",
"own": "Own",
"global": "Global",
"useGlobal": "Use global",
"globalValue": "Global rule: {value}",
"on": "On",
"scoreBelowValue": "Score below {threshold}",
"neverFetchedSplit": "{count, plural, one {# never fetched} other {# never fetched}}",
"fetchedBeforeSplit": "{count, plural, one {# fetched before} other {# fetched before}}",
"lowScoreLabel": "Low confidence score",
"lowScoreHint": "Fetch books whose confidence score is under the line.",
"outOf": "/ 100",
"lowerThreshold": "Lower the threshold",
"raiseThreshold": "Raise the threshold",
"missingHint": "Fetch if any selected field is empty. Each field shows how many books lack it.",
"audioGroup": "Books with audio",
"audioHint": "{count, plural, =0 {None here have audio.} one {Only the # book with audio can lack these.} other {Only the # with audio can lack these.}}",
"fieldCount": "{field}: {count, plural, one {# book lacks it} other {# books lack it}}"
},
"histogram": {
"label": "Confidence score threshold",
"valueText": "Score below {threshold}"
},
"preview": {
"title": "What a run would fetch",
"matching": "{matching, plural, one {# of {total} books matches} other {# of {total} books match}}",
"showing": "{count, plural, one {showing the least complete} other {showing the # least complete}}",
"empty": "No book matches this rule, so a run would queue nothing.",
"unknownAuthor": "Unknown author",
"score": "Score {score}",
"more": "+{count}",
"missing": {
"title": "No title",
"subtitle": "No subtitle",
"description": "No description",
"cover": "No cover",
"audioCover": "No audiobook cover",
"authors": "No authors",
"publisher": "No publisher",
"isbn": "No ISBN",
"publishedYear": "No published year",
"language": "No language",
"pageCount": "No page count",
"communityRating": "No community rating",
"seriesName": "No series name",
"seriesIndex": "No series index",
"genres": "No genres",
"narrators": "No narrators",
"duration": "No duration",
"abridged": "Abridged not set"
}
},
"queue": {
"progress": "{done} of {total}",
"failed": "{count, plural, one {# book could not be fetched} other {# books could not be fetched}}"
}
},
"providers": {
+1 -1
View File
@@ -388,7 +388,7 @@ export const routes: RouteRecordRaw[] = [
path: 'metadata/auto-fetch',
name: 'settings-metadata-auto-fetch',
component: () => import('@/features/settings/metadata-auto-fetch/BookMetadataFetchSettings.vue'),
meta: { maxWidth: 'max-w-3xl', title: () => t('titles.metadata.auto-fetch') },
meta: { maxWidth: 'max-w-[100rem]', title: () => t('titles.metadata.auto-fetch') },
},
{
path: 'metadata/authors',
+1 -1
View File
@@ -13,7 +13,7 @@ const contentScroll = ref<HTMLElement | null>(null)
const maxWidth = computed(() => (route.meta.maxWidth as string | undefined) ?? 'max-w-3xl')
const match = computed(() => findSettingsNavItem(typeof route.name === 'string' ? route.name : ''))
const pageTitle = computed(() => (match.value ? t(match.value.item.labelKey) : t('settings.nav.title')))
const pageTitle = computed(() => (match.value ? t(match.value.item.titleKey ?? match.value.item.labelKey) : t('settings.nav.title')))
const pageIcon = computed(() => match.value?.item.icon ?? Settings)
/**
* The header wants a sentence; the nav search keeps the terse one. A locale without the longer