mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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>
|
||||
|
||||
+224
@@ -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')
|
||||
})
|
||||
})
|
||||
+214
@@ -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>
|
||||
+116
@@ -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>
|
||||
+73
@@ -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>
|
||||
-340
@@ -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')
|
||||
})
|
||||
})
|
||||
-191
@@ -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>
|
||||
-395
@@ -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)
|
||||
})
|
||||
})
|
||||
-281
@@ -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>
|
||||
+180
@@ -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,
|
||||
}
|
||||
}
|
||||
+90
@@ -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
|
||||
}
|
||||
+10
-2
@@ -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>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user