mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(metadata): limit fetched genres per book
Keep provider metadata from creating overly granular genre catalogs while preserving unlimited behavior by default. Closes #710
This commit is contained in:
@@ -62,6 +62,7 @@ function withBlocklist(prefs: MetadataFetchPreferences, blocklist: string[]): Me
|
||||
genres: {
|
||||
mode: prefs.options?.genres.mode ?? 'merge',
|
||||
blocklist,
|
||||
maxCount: prefs.options?.genres.maxCount ?? null,
|
||||
},
|
||||
saveProviderIds: prefs.options?.saveProviderIds ?? true,
|
||||
},
|
||||
|
||||
+2
@@ -47,6 +47,7 @@ function makePrefs(blocklist: string[], marker = 'initial'): MetadataFetchPrefer
|
||||
genres: {
|
||||
mode: 'merge',
|
||||
blocklist,
|
||||
maxCount: 3,
|
||||
},
|
||||
saveProviderIds: true,
|
||||
},
|
||||
@@ -92,6 +93,7 @@ describe('MetadataGenreBlocklistSettings', () => {
|
||||
const body = putBodyAt(2)
|
||||
expect(body.fields.title.providers).toEqual(['latest'])
|
||||
expect(body.options?.genres.blocklist).toEqual(['Audiobook', 'Adult'])
|
||||
expect(body.options?.genres.maxCount).toBe(3)
|
||||
})
|
||||
|
||||
it('does not submit duplicate genre values', async () => {
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ALL_METADATA_FIELDS } from '@bookorbit/types'
|
||||
import type { FieldPreference, MetadataFetchPreferences, MetadataField } from '@bookorbit/types'
|
||||
import GlobalPreferencePanel from './GlobalPreferencePanel.vue'
|
||||
|
||||
function makePreferences(maxCount: number | null = null): MetadataFetchPreferences {
|
||||
const fields = ALL_METADATA_FIELDS.reduce<Record<MetadataField, FieldPreference>>(
|
||||
(result, field) => {
|
||||
result[field] = {
|
||||
enabled: true,
|
||||
providers: [],
|
||||
mergeStrategy: 'overwriteIfProvided',
|
||||
}
|
||||
return result
|
||||
},
|
||||
{} as Record<MetadataField, FieldPreference>,
|
||||
)
|
||||
|
||||
return {
|
||||
fields,
|
||||
options: {
|
||||
genres: {
|
||||
mode: 'merge',
|
||||
blocklist: [],
|
||||
maxCount,
|
||||
},
|
||||
saveProviderIds: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function mountPanel(preferences = makePreferences()) {
|
||||
return mount(GlobalPreferencePanel, {
|
||||
props: {
|
||||
preferences,
|
||||
statuses: [],
|
||||
saving: false,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
FieldPreferenceTable: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('GlobalPreferencePanel', () => {
|
||||
it('saves a maximum genre count', async () => {
|
||||
const wrapper = mountPanel()
|
||||
const input = wrapper.get('#genre-max-count')
|
||||
|
||||
expect(input.classes()).toContain('w-24')
|
||||
expect(input.classes()).not.toContain('w-full')
|
||||
await input.setValue('3')
|
||||
await wrapper.findAll('button.settings-btn-primary')[0]!.trigger('click')
|
||||
|
||||
const saved = wrapper.emitted('save')?.[0]?.[0] as MetadataFetchPreferences
|
||||
expect(saved.options?.genres.maxCount).toBe(3)
|
||||
})
|
||||
|
||||
it('uses an empty maximum as unlimited', async () => {
|
||||
const wrapper = mountPanel(makePreferences(3))
|
||||
|
||||
await wrapper.get('#genre-max-count').setValue('')
|
||||
await wrapper.findAll('button.settings-btn-primary')[0]!.trigger('click')
|
||||
|
||||
const saved = wrapper.emitted('save')?.[0]?.[0] as MetadataFetchPreferences
|
||||
expect(saved.options?.genres.maxCount).toBeNull()
|
||||
})
|
||||
|
||||
it('prevents saving an invalid maximum', async () => {
|
||||
const wrapper = mountPanel()
|
||||
|
||||
await wrapper.get('#genre-max-count').setValue('0')
|
||||
|
||||
expect(wrapper.get('#genre-max-count').attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.get('#genre-max-count-error').text()).toContain('1 to 50')
|
||||
expect(wrapper.findAll('button.settings-btn-primary').every((button) => button.attributes('disabled') !== undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes genre and provider ID toggles as switches', () => {
|
||||
const wrapper = mountPanel()
|
||||
const switches = wrapper.findAll('[role="switch"]')
|
||||
|
||||
expect(switches).toHaveLength(2)
|
||||
expect(switches[0]!.attributes('aria-checked')).toBe('true')
|
||||
expect(switches[1]!.attributes('aria-checked')).toBe('true')
|
||||
})
|
||||
})
|
||||
+88
-35
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Loader2, RotateCcw, Save, Settings, Trash2 } from '@lucide/vue'
|
||||
import { MAX_METADATA_GENRE_COUNT } from '@bookorbit/types'
|
||||
import type { FieldPreference, MetadataFetchPreferences, MetadataField, ProviderStatus } from '@bookorbit/types'
|
||||
import FieldPreferenceTable from './FieldPreferenceTable.vue'
|
||||
|
||||
@@ -28,6 +29,7 @@ function withDefaultOptions(prefs: MetadataFetchPreferences): MetadataFetchPrefe
|
||||
genres: {
|
||||
mode: prefs.options?.genres.mode ?? 'merge',
|
||||
blocklist: prefs.options?.genres.blocklist ?? [],
|
||||
maxCount: prefs.options?.genres.maxCount ?? null,
|
||||
},
|
||||
saveProviderIds: prefs.options?.saveProviderIds ?? true,
|
||||
},
|
||||
@@ -48,13 +50,13 @@ function onFieldChange(field: MetadataField, pref: FieldPreference) {
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!draft.value) return
|
||||
if (!draft.value || !isGenreMaxCountValid.value) return
|
||||
emit('save', draft.value)
|
||||
}
|
||||
|
||||
function setGenreMerge(enabled: boolean) {
|
||||
function toggleGenreMerge() {
|
||||
if (!draft.value?.options) return
|
||||
draft.value.options.genres.mode = enabled ? 'merge' : 'firstProvider'
|
||||
draft.value.options.genres.mode = draft.value.options.genres.mode === 'merge' ? 'firstProvider' : 'merge'
|
||||
}
|
||||
|
||||
function toggleSaveProviderIds() {
|
||||
@@ -62,6 +64,22 @@ function toggleSaveProviderIds() {
|
||||
draft.value.options.saveProviderIds = !draft.value.options.saveProviderIds
|
||||
}
|
||||
|
||||
const genreMaxCount = computed(() => draft.value?.options?.genres.maxCount ?? null)
|
||||
const isGenreMaxCountValid = computed(
|
||||
() =>
|
||||
genreMaxCount.value === null ||
|
||||
(Number.isInteger(genreMaxCount.value) && genreMaxCount.value >= 1 && genreMaxCount.value <= MAX_METADATA_GENRE_COUNT),
|
||||
)
|
||||
const genreMaxCountDescriptionIds = computed(() =>
|
||||
isGenreMaxCountValid.value ? 'genre-max-count-hint' : 'genre-max-count-hint genre-max-count-error',
|
||||
)
|
||||
|
||||
function setGenreMaxCount(event: Event) {
|
||||
if (!draft.value?.options) return
|
||||
const value = (event.target as HTMLInputElement).valueAsNumber
|
||||
draft.value.options.genres.maxCount = Number.isNaN(value) ? null : value
|
||||
}
|
||||
|
||||
function handleClearAll() {
|
||||
if (!draft.value) return
|
||||
if (!confirm(t('settings.metadata.fieldRules.global.clearAllConfirm'))) return
|
||||
@@ -98,7 +116,7 @@ function handleResetToDefault() {
|
||||
<RotateCcw :size="13" />
|
||||
<span>{{ t('settings.metadata.fieldRules.resetToDefault') }}</span>
|
||||
</button>
|
||||
<button class="settings-btn-primary h-8 px-3" :disabled="saving || !draft" @click="save">
|
||||
<button class="settings-btn-primary h-8 px-3" :disabled="saving || !draft || !isGenreMaxCountValid" @click="save">
|
||||
<Loader2 v-if="saving" :size="14" class="animate-spin" />
|
||||
<Save v-else :size="14" />
|
||||
<span>{{ t('settings.metadata.fieldRules.global.saveDefaults') }}</span>
|
||||
@@ -123,7 +141,7 @@ function handleResetToDefault() {
|
||||
<RotateCcw :size="13" />
|
||||
<span>{{ t('settings.metadata.fieldRules.reset') }}</span>
|
||||
</button>
|
||||
<button class="settings-btn-primary h-8 px-3 justify-center ml-auto" :disabled="saving || !draft" @click="save">
|
||||
<button class="settings-btn-primary h-8 px-3 justify-center ml-auto" :disabled="saving || !draft || !isGenreMaxCountValid" @click="save">
|
||||
<Loader2 v-if="saving" :size="14" class="animate-spin" />
|
||||
<Save v-else :size="14" />
|
||||
<span>{{ t('settings.metadata.fieldRules.global.saveDefaults') }}</span>
|
||||
@@ -141,49 +159,84 @@ function handleResetToDefault() {
|
||||
<h4 class="settings-group-label !mb-0">{{ t('settings.metadata.fieldRules.advanced.title') }}</h4>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<!-- Genre Behavior -->
|
||||
<div class="space-y-4">
|
||||
<label class="flex items-start gap-3 group cursor-pointer">
|
||||
<div
|
||||
class="relative flex h-5 w-9 shrink-0 items-center rounded-full transition-colors mt-0.5"
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="h-full rounded-lg border border-border bg-background/60 p-4 shadow-xs">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<span id="combine-genres-label" class="text-sm font-medium leading-5 text-foreground">
|
||||
{{ t('settings.metadata.fieldRules.advanced.combineGenres.label') }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="draft.options?.genres.mode === 'merge'"
|
||||
aria-labelledby="combine-genres-label"
|
||||
aria-describedby="combine-genres-hint"
|
||||
class="relative flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
:class="draft.options?.genres.mode === 'merge' ? 'bg-primary' : 'bg-muted border border-border'"
|
||||
@click.prevent="setGenreMerge(draft.options?.genres.mode !== 'merge')"
|
||||
@click="toggleGenreMerge"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow-xs transition-transform"
|
||||
:class="draft.options?.genres.mode === 'merge' ? 'translate-x-4.5' : 'translate-x-0.5'"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<span class="text-sm font-medium text-foreground">{{ t('settings.metadata.fieldRules.advanced.combineGenres.label') }}</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('settings.metadata.fieldRules.advanced.combineGenres.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
<p id="combine-genres-hint" class="mt-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{{ t('settings.metadata.fieldRules.advanced.combineGenres.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- IDs Behavior -->
|
||||
<div class="space-y-4">
|
||||
<label class="flex items-start gap-3 group cursor-pointer">
|
||||
<div
|
||||
class="relative flex h-5 w-9 shrink-0 items-center rounded-full transition-colors mt-0.5"
|
||||
<div class="h-full rounded-lg border border-border bg-background/60 p-4 shadow-xs">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<label for="genre-max-count" class="text-sm font-medium leading-5 text-foreground">
|
||||
{{ t('settings.metadata.fieldRules.advanced.maxGenres.label') }}
|
||||
</label>
|
||||
<input
|
||||
id="genre-max-count"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="MAX_METADATA_GENRE_COUNT"
|
||||
step="1"
|
||||
:value="draft.options?.genres.maxCount ?? ''"
|
||||
:placeholder="t('settings.metadata.fieldRules.advanced.maxGenres.unlimited')"
|
||||
:aria-invalid="!isGenreMaxCountValid"
|
||||
:aria-describedby="genreMaxCountDescriptionIds"
|
||||
class="h-8 w-24 shrink-0 rounded-md border border-input bg-background px-2 text-center text-sm tabular-nums outline-none transition-shadow focus:ring-1 focus:ring-ring placeholder:text-muted-foreground"
|
||||
@input="setGenreMaxCount"
|
||||
/>
|
||||
</div>
|
||||
<p id="genre-max-count-hint" class="mt-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{{ t('settings.metadata.fieldRules.advanced.maxGenres.hint') }}
|
||||
</p>
|
||||
<p v-if="!isGenreMaxCountValid" id="genre-max-count-error" class="text-xs text-destructive">
|
||||
{{ t('settings.metadata.fieldRules.advanced.maxGenres.error', { max: MAX_METADATA_GENRE_COUNT }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="h-full rounded-lg border border-border bg-background/60 p-4 shadow-xs">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<span id="store-provider-ids-label" class="text-sm font-medium leading-5 text-foreground">
|
||||
{{ t('settings.metadata.fieldRules.advanced.storeProviderIds.label') }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="draft.options?.saveProviderIds"
|
||||
aria-labelledby="store-provider-ids-label"
|
||||
aria-describedby="store-provider-ids-hint"
|
||||
class="relative flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
:class="draft.options?.saveProviderIds ? 'bg-primary' : 'bg-muted border border-border'"
|
||||
@click.prevent="toggleSaveProviderIds"
|
||||
@click="toggleSaveProviderIds"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow-xs transition-transform"
|
||||
:class="draft.options?.saveProviderIds ? 'translate-x-4.5' : 'translate-x-0.5'"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<span class="text-sm font-medium text-foreground">{{ t('settings.metadata.fieldRules.advanced.storeProviderIds.label') }}</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('settings.metadata.fieldRules.advanced.storeProviderIds.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
<p id="store-provider-ids-hint" class="mt-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{{ t('settings.metadata.fieldRules.advanced.storeProviderIds.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1360,6 +1360,12 @@
|
||||
"label": "Combine genres from all selected providers",
|
||||
"hint": "Collect and deduplicate genres from every provider assigned to the Genres field, rather than stopping at the first result."
|
||||
},
|
||||
"maxGenres": {
|
||||
"label": "Maximum genres per book",
|
||||
"hint": "Applied after exclusions and duplicate removal. Leave empty for unlimited. This affects future metadata fetches.",
|
||||
"unlimited": "Unlimited",
|
||||
"error": "Enter a whole number from 1 to {max}, or leave the field empty."
|
||||
},
|
||||
"storeProviderIds": {
|
||||
"label": "Store provider IDs on books",
|
||||
"hint": "When fetching metadata, save the returned provider IDs (ISBN, ASIN, Goodreads ID, etc.) on the book record for more accurate future lookups."
|
||||
|
||||
Reference in New Issue
Block a user