feat(metadata): add configurable missing-field reminders

Add account-scoped reminders for the metadata fields users care about.
Treat either ISBN as complete, accept a publication year or date, and
skip page-count reminders for audio-only books.

Place reminders after the genre blocklist and allow customization from
the editor without discarding unsaved changes.
This commit is contained in:
neon
2026-10-07 20:06:50 +00:00
parent 8fef9d1633
commit 6c7d91eaac
21 changed files with 636 additions and 51 deletions
@@ -12,7 +12,7 @@ import {
RefreshCw,
Sparkles,
Star,
TriangleAlert,
ListChecks,
X,
} from '@lucide/vue'
import { toast } from 'vue-sonner'
@@ -54,7 +54,10 @@ import { metadataRefreshAppliedMessage, metadataRefreshEmptyMessage } from '@/fe
import { filterProviderIdFields, isProviderIdFieldAvailable, isProviderIdFormField } from '@/features/book/lib/provider-id-fields'
import { formatCommunityRatingLine } from '@/features/book/lib/community-rating'
import { coverFieldMedium, coverLockField } from '@/features/book/lib/cover-slots'
import { formatList } from '@/i18n/formatters'
import { formatList, formatNumber } from '@/i18n/formatters'
import { useMetadataReminders } from '@/features/metadata-reminders/composables/useMetadataReminders'
import { missingMetadataFields } from '@/features/metadata-reminders/lib/missing-metadata'
import MetadataReminderSheet from '@/features/metadata-reminders/components/MetadataReminderSheet.vue'
import MetadataSourceCard from './MetadataSourceCard.vue'
const AUTO_FILL_EMPTY_TOAST_DURATION_MS = 10_000
@@ -286,22 +289,23 @@ const saveErrorMessage = computed(() => {
const combinedError = computed(() => lockError.value ?? saveErrorMessage.value)
// Fields whose emptiness the toolbar counts. Provider ids are deliberately excluded: most books
// legitimately have none, so counting them would report a gap on every healthy record.
const emptyFields = computed(() => {
const checks: { label: string; filled: boolean }[] = [
{ label: t('book.detail.editMetadata.publisherLabel'), filled: Boolean(form.publisher?.trim()) },
{ label: t('book.detail.editMetadata.languageLabel'), filled: Boolean(form.language?.trim()) },
{ label: t('book.detail.editMetadata.yearLabel'), filled: form.publishedYear != null },
{ label: t('book.detail.editMetadata.pageCountLabel'), filled: form.pageCount != null },
{ label: t('book.detail.editMetadata.isbn13Label'), filled: Boolean(form.isbn13?.trim()) },
{ label: t('book.detail.editMetadata.isbn10Label'), filled: Boolean(form.isbn10?.trim()) },
{ label: t('book.detail.editMetadata.genresLabel'), filled: form.genres.length > 0 },
{ label: t('book.detail.editMetadata.tagsLabel'), filled: form.tags.length > 0 },
{ label: t('book.detail.editMetadata.descriptionLabel'), filled: Boolean(form.description?.trim()) },
]
return checks.filter((check) => !check.filled).map((check) => check.label)
})
const { fields: reminderFields } = useMetadataReminders()
const remindersOpen = ref(false)
const missingFieldsOpen = ref(false)
const missingFieldsTrigger = ref<HTMLButtonElement | null>(null)
const metadataToolbar = ref<HTMLElement | null>(null)
const emptyFields = computed(() => missingMetadataFields(form, props.book.files, reminderFields.value))
function customizeReminders() {
missingFieldsOpen.value = false
remindersOpen.value = true
}
async function restoreReminderFocus() {
await nextTick()
const target = missingFieldsTrigger.value ?? metadataToolbar.value
target?.focus()
}
const metadataScore = computed(() => props.book.metadataScore)
const metadataScoreColour = computed(() => (metadataScore.value == null ? null : metadataScoreColor(metadataScore.value)))
@@ -857,7 +861,11 @@ function handleCoverChanged(medium: CoverMedium | null) {
<div class="@container/edit flex min-h-full min-w-0 flex-col">
<div class="flex flex-1 flex-col gap-3">
<!-- Command strip -->
<div class="sticky top-0 z-30 -mx-4 flex flex-none items-center gap-2 bg-card/95 px-4 py-0.5 backdrop-blur-sm sm:mx-0 sm:px-0">
<div
ref="metadataToolbar"
tabindex="-1"
class="sticky top-0 z-30 -mx-4 flex flex-none items-center gap-2 bg-card/95 px-4 py-0.5 backdrop-blur-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:mx-0 sm:px-0"
>
<div class="no-scrollbar flex min-w-0 flex-1 items-center gap-2 overflow-x-auto">
<div
v-if="metadataScore !== null"
@@ -871,23 +879,32 @@ function handleCoverChanged(medium: CoverMedium | null) {
</span>
</div>
<Popover v-if="emptyFields.length > 0">
<Popover v-if="emptyFields.length > 0" v-model:open="missingFieldsOpen">
<PopoverTrigger as-child>
<button
ref="missingFieldsTrigger"
type="button"
class="flex h-9 flex-none items-center gap-1.5 rounded-lg border border-dashed border-warning/40 bg-warning/10 px-2.5 text-xs font-semibold text-foreground transition-colors hover:bg-warning/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:h-8"
:aria-label="t('book.detail.editMetadata.emptyFieldsCount', { count: emptyFields.length })"
class="flex h-9 flex-none items-center gap-1.5 rounded-lg border border-border bg-card px-2.5 text-xs font-semibold text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:h-8"
:aria-label="t('metadataReminders.missingCount', { count: emptyFields.length })"
>
<TriangleAlert class="size-3.5 shrink-0 text-warning" aria-hidden="true" />
<span>{{ emptyFields.length }}</span>
<span class="hidden @3xl/edit:inline">{{ t('book.detail.editMetadata.emptyFields', { count: emptyFields.length }) }}</span>
<ListChecks class="size-3.5 shrink-0" aria-hidden="true" />
<span>{{ formatNumber(emptyFields.length) }}</span>
<span class="hidden @3xl/edit:inline">{{ t('metadataReminders.missingFields', { count: emptyFields.length }) }}</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" class="w-64 max-w-[calc(100vw-2rem)] p-3" :aria-label="t('book.detail.editMetadata.emptyFieldsHeading')">
<p class="mb-2 text-sm font-semibold">{{ t('book.detail.editMetadata.emptyFieldsHeading') }}</p>
<PopoverContent align="start" class="w-72 max-w-[calc(100vw-2rem)] p-3" :aria-label="t('metadataReminders.missingHeading')">
<p class="text-sm font-semibold">{{ t('metadataReminders.missingHeading') }}</p>
<p class="mb-3 mt-1 text-xs text-muted-foreground">{{ t('metadataReminders.missingDescription') }}</p>
<ul class="grid gap-1 text-sm text-muted-foreground">
<li v-for="field in emptyFields" :key="field">{{ field }}</li>
<li v-for="field in emptyFields" :key="field">{{ t(`metadataReminders.fields.${field}.label`) }}</li>
</ul>
<button
type="button"
class="mt-3 w-full rounded-md border-t border-border pt-3 text-start text-sm text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click="customizeReminders"
>
{{ t('metadataReminders.customize') }}
</button>
</PopoverContent>
</Popover>
@@ -1590,6 +1607,7 @@ function handleCoverChanged(medium: CoverMedium | null) {
</div>
<MetadataSearchDrawer v-if="searchOpen" :book="props.book" :locked-fields="lockedFields" @close="handleCloseSearch" @apply="handleApply" />
<MetadataReminderSheet v-if="remindersOpen" v-model:open="remindersOpen" @closed="restoreReminderFocus" />
</template>
<style scoped>
@@ -1,7 +1,7 @@
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BookDetail, CoverMedium } from '@bookorbit/types'
import type { AuthUser, BookDetail, CoverMedium } from '@bookorbit/types'
import { api } from '@/lib/api'
import EditMetadataTab from '../EditMetadataTab.vue'
@@ -22,6 +22,9 @@ vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({ hasPermission: () => true }),
}))
const reminderUser = ref<AuthUser>({ id: 7, settings: {} } as AuthUser)
vi.mock('@/features/auth/composables/useAuth', () => ({ useAuth: () => ({ user: reminderUser }) }))
const panel = {
hasPending: ref(false),
pendingMedia: ref<(CoverMedium | null)[]>([]),
@@ -115,7 +118,7 @@ function mountTab(book: BookDetail) {
CoverEditorPanel: CoverEditorPanelStub,
MetadataSearchDrawer: MetadataSearchDrawerStub,
MetadataSourceCard: true,
MetadataFieldLabel: true,
MetadataFieldLabel: { props: ['field'], template: '<div :data-field="field"><slot /></div>' },
RichDescriptionEditor: true,
SeriesMembershipEditor: true,
ChipInput: true,
@@ -137,6 +140,7 @@ describe('EditMetadataTab cover tiles', () => {
let savedBook: BookDetail
beforeEach(() => {
reminderUser.value = { id: 7, settings: {} } as AuthUser
panel.hasPending.value = false
panel.pendingMedia.value = []
panel.busy.value = false
@@ -158,20 +162,19 @@ describe('EditMetadataTab cover tiles', () => {
document.body.append(wrapper.element)
await flushPromises()
const badge = wrapper.get('[aria-label="book.detail.editMetadata.emptyFieldsCount"]')
const badge = wrapper.get('[aria-label="metadataReminders.missingCount"]')
await badge.trigger('click')
await flushPromises()
const popover = document.querySelector('[data-slot="popover-content"]')!
expect(popover).not.toBeNull()
expect(Array.from(popover.querySelectorAll('li'), (item) => item.textContent)).toEqual([
'book.detail.editMetadata.languageLabel',
'book.detail.editMetadata.pageCountLabel',
'book.detail.editMetadata.isbn13Label',
'book.detail.editMetadata.isbn10Label',
'book.detail.editMetadata.genresLabel',
'book.detail.editMetadata.tagsLabel',
'book.detail.editMetadata.descriptionLabel',
'metadataReminders.fields.language.label',
'metadataReminders.fields.pageCount.label',
'metadataReminders.fields.isbn.label',
'metadataReminders.fields.genres.label',
'metadataReminders.fields.tags.label',
'metadataReminders.fields.description.label',
])
expect(badge.attributes('aria-expanded')).toBe('true')
expect(wrapper.findComponent(MetadataSearchDrawerStub).exists()).toBe(false)
@@ -179,7 +182,7 @@ describe('EditMetadataTab cover tiles', () => {
await wrapper.setProps({ book: makeBook({ publisher: 'Ace', publishedYear: 1965, language: 'en' }) })
await flushPromises()
expect(popover.textContent).not.toContain('book.detail.editMetadata.languageLabel')
expect(popover.textContent).not.toContain('metadataReminders.fields.language.label')
wrapper.unmount()
})
@@ -195,6 +198,61 @@ describe('EditMetadataTab cover tiles', () => {
expect(saveButton(wrapper).attributes('disabled')).toBeUndefined()
})
it('opens reminder preferences from the popover without leaving the unsaved book form', async () => {
const wrapper = mountTab(makeBook())
document.body.append(wrapper.element)
await flushPromises()
await wrapper.get('[data-field="title"] input').setValue('Unsaved title')
await wrapper.get('[aria-label="metadataReminders.missingCount"]').trigger('click')
await flushPromises()
const customize = document.querySelector<HTMLButtonElement>('[data-slot="popover-content"] button')!
customize.click()
await flushPromises()
const sheet = document.querySelector('[data-slot="sheet-content"]')!
expect(sheet).not.toBeNull()
expect(sheet.textContent).toContain('metadataReminders.title')
expect(patchCalls()).toHaveLength(0)
expect((wrapper.get('[data-field="title"] input').element as HTMLInputElement).value).toBe('Unsaved title')
wrapper.unmount()
})
it('hides reminders when the only absent identifier is ISBN-10', async () => {
const wrapper = mountTab(
makeBook({
isbn13: '9780593419113',
publisher: 'Penguin',
publishedDate: '2023-04-25',
language: 'en',
pageCount: 272,
genres: ['Cooking'],
tags: ['Food'],
description: 'A cookbook',
}),
)
await flushPromises()
expect(wrapper.find('[aria-label="metadataReminders.missingCount"]').exists()).toBe(false)
wrapper.unmount()
})
it('returns focus to the editor when preferences remove the badge that opened the sheet', async () => {
reminderUser.value.settings.metadataReminderPreferences = { fields: ['isbn'] }
const wrapper = mountTab(makeBook())
document.body.append(wrapper.element)
await flushPromises()
await wrapper.get('[aria-label="metadataReminders.missingCount"]').trigger('click')
await flushPromises()
document.querySelector<HTMLButtonElement>('[data-slot="popover-content"] button')!.click()
await flushPromises()
document.querySelector<HTMLInputElement>('[data-slot="sheet-content"] input[value="isbn"]')!.click()
await flushPromises()
expect(wrapper.find('[aria-label="metadataReminders.missingCount"]').exists()).toBe(false)
document.querySelector<HTMLButtonElement>('[data-slot="sheet-content"] [data-slot="sheet-footer"] button')!.click()
await flushPromises()
expect(document.activeElement?.getAttribute('tabindex')).toBe('-1')
expect(wrapper.element.contains(document.activeElement)).toBe(true)
wrapper.unmount()
})
it('does not save the form when a cover fails to save', async () => {
const wrapper = mountTab(makeBook())
await flushPromises()
@@ -0,0 +1,45 @@
import { flushPromises, mount } from '@vue/test-utils'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AuthUser } from '@bookorbit/types'
import { i18n } from '@/i18n'
import { api } from '@/lib/api'
import MetadataReminderControls from './MetadataReminderControls.vue'
const user = ref<AuthUser | null>(null)
vi.mock('@/features/auth/composables/useAuth', () => ({ useAuth: () => ({ user }) }))
vi.mock('@/lib/api', () => ({ api: vi.fn<typeof api>() }))
beforeEach(() => {
i18n.global.locale.value = 'en'
user.value = { id: 7, settings: {} } as AuthUser
vi.mocked(api)
.mockReset()
.mockResolvedValue({ ok: true } as Response)
})
describe('metadata reminder controls', () => {
it('saves checkbox changes automatically and restores defaults', async () => {
const wrapper = mount(MetadataReminderControls)
await wrapper.get('input[value="tags"]').setValue(false)
await flushPromises()
expect(user.value!.settings.metadataReminderPreferences!.fields).not.toContain('tags')
expect(wrapper.get('[role="status"]').text()).toBe('Preferences saved.')
await wrapper.get('button').trigger('click')
await flushPromises()
expect(user.value!.settings.metadataReminderPreferences!.fields).toContain('tags')
})
it('reverts a failed checkbox change and retries the requested selection', async () => {
vi.mocked(api).mockResolvedValueOnce({ ok: false } as Response)
const wrapper = mount(MetadataReminderControls)
await wrapper.get('input[value="tags"]').setValue(false)
await flushPromises()
expect((wrapper.get('input[value="tags"]').element as HTMLInputElement).checked).toBe(true)
expect(wrapper.get('[role="alert"]').text()).toContain('Could not save')
await wrapper.get('[role="alert"] button').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect((wrapper.get('input[value="tags"]').element as HTMLInputElement).checked).toBe(false)
})
})
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed, ref, useId, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { METADATA_REMINDER_FIELDS, type MetadataReminderField } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import { useMetadataReminders } from '../composables/useMetadataReminders'
import { useAuth } from '@/features/auth/composables/useAuth'
const { t } = useI18n()
const { fields, saving, saveFields } = useMetadataReminders()
const { user } = useAuth()
const id = useId()
const failedFields = ref<MetadataReminderField[] | null>(null)
const saved = ref(false)
const isDefault = computed(() => fields.value.length === METADATA_REMINDER_FIELDS.length)
watch(
() => user.value?.id,
() => {
failedFields.value = null
saved.value = false
},
)
async function persist(next: MetadataReminderField[]) {
const userId = user.value?.id
failedFields.value = null
saved.value = false
const success = await saveFields(next)
if (user.value?.id !== userId) return
if (success) saved.value = true
else failedFields.value = next
}
function handleFieldChange(field: MetadataReminderField, event: Event) {
const checked = (event.target as HTMLInputElement).checked
const next = checked ? [...fields.value, field] : fields.value.filter((value) => value !== field)
void persist(next)
}
function resetDefaults() {
void persist([...METADATA_REMINDER_FIELDS])
}
function retrySave() {
if (failedFields.value) void persist([...failedFields.value])
}
</script>
<template>
<div class="space-y-4">
<div class="space-y-1">
<p class="text-xs font-medium text-primary">{{ t('metadataReminders.personal') }}</p>
<p class="text-sm text-muted-foreground">{{ t('metadataReminders.description') }}</p>
</div>
<fieldset class="settings-card" :disabled="saving" :aria-busy="saving">
<legend class="sr-only">{{ t('metadataReminders.fieldsHeading') }}</legend>
<label
v-for="field in METADATA_REMINDER_FIELDS"
:key="field"
class="flex cursor-pointer items-start gap-3 bg-card px-4 py-3.5 focus-within:bg-muted/30 hover:bg-muted/30 md:px-5"
:class="saving ? 'cursor-wait' : ''"
>
<input
type="checkbox"
:value="field"
:checked="fields.includes(field)"
:aria-describedby="`${id}-${field}-hint`"
class="mt-0.5 size-4 shrink-0 accent-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
@change="handleFieldChange(field, $event)"
/>
<span class="min-w-0">
<span class="settings-label block">{{ t(`metadataReminders.fields.${field}.label`) }}</span>
<span :id="`${id}-${field}-hint`" class="settings-hint block">{{ t(`metadataReminders.fields.${field}.hint`) }}</span>
</span>
</label>
</fieldset>
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-xs text-muted-foreground" role="status" aria-live="polite">
{{ saving ? t('metadataReminders.saving') : saved ? t('metadataReminders.saved') : t('metadataReminders.autoSave') }}
</p>
<Button type="button" variant="outline" size="sm" :disabled="saving || isDefault" @click="resetDefaults">
{{ t('metadataReminders.reset') }}
</Button>
</div>
<p v-if="fields.length === 0" class="text-sm text-muted-foreground">{{ t('metadataReminders.disabled') }}</p>
<div v-if="failedFields" class="flex flex-wrap items-center gap-3" role="alert">
<p class="text-sm text-destructive">{{ t('metadataReminders.saveFailed') }}</p>
<Button type="button" variant="outline" size="sm" :disabled="saving" @click="retrySave">{{ t('common.retry') }}</Button>
</div>
</div>
</template>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import MetadataReminderControls from './MetadataReminderControls.vue'
import { useMetadataReminders } from '../composables/useMetadataReminders'
defineProps<{ open: boolean }>()
const emit = defineEmits<{ 'update:open': [open: boolean]; closed: [] }>()
const { t } = useI18n()
const { saving } = useMetadataReminders()
function handleOpenChange(open: boolean) {
if (!saving.value) emit('update:open', open)
}
function closeSheet() {
handleOpenChange(false)
}
function restoreFocus(event: Event) {
event.preventDefault()
emit('closed')
}
</script>
<template>
<Sheet :open="open" @update:open="handleOpenChange">
<SheetContent side="right" hide-close class="w-full gap-0 sm:max-w-lg" @close-auto-focus="restoreFocus">
<SheetHeader class="border-b border-border">
<SheetTitle>{{ t('metadataReminders.title') }}</SheetTitle>
<SheetDescription>{{ t('metadataReminders.sheetDescription') }}</SheetDescription>
</SheetHeader>
<div class="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
<MetadataReminderControls />
</div>
<SheetFooter class="border-t border-border">
<Button type="button" :disabled="saving" @click="closeSheet">{{ t('common.close') }}</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</template>
@@ -0,0 +1,89 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AuthUser } from '@bookorbit/types'
import { METADATA_REMINDER_FIELDS } from '@bookorbit/types'
import { api } from '@/lib/api'
import { useMetadataReminders } from './useMetadataReminders'
const user = ref<AuthUser | null>(null)
vi.mock('@/features/auth/composables/useAuth', () => ({ useAuth: () => ({ user }) }))
vi.mock('@/lib/api', () => ({ api: vi.fn<typeof api>() }))
function account(id = 7): AuthUser {
return { id, settings: { timezone: 'UTC' } } as AuthUser
}
beforeEach(() => {
user.value = account()
vi.mocked(api)
.mockReset()
.mockResolvedValue({ ok: true } as Response)
})
describe('metadata reminder preferences', () => {
it('uses defaults for older accounts and after signing out', () => {
const reminders = useMetadataReminders()
expect(reminders.fields.value).toEqual(METADATA_REMINDER_FIELDS)
user.value = null
expect(reminders.fields.value).toEqual(METADATA_REMINDER_FIELDS)
})
it('patches only this preference and preserves unrelated settings', async () => {
const reminders = useMetadataReminders()
expect(await reminders.saveFields(['isbn'])).toBe(true)
expect(api).toHaveBeenCalledWith('/api/v1/users/me/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: { metadataReminderPreferences: { fields: ['isbn'] } } }),
})
expect(user.value!.settings.timezone).toBe('UTC')
expect(reminders.fields.value).toEqual(['isbn'])
})
it('restores saved preferences after an unsuccessful write', async () => {
vi.mocked(api).mockResolvedValue({ ok: false } as Response)
const reminders = useMetadataReminders()
expect(await reminders.saveFields([])).toBe(false)
expect(reminders.fields.value).toEqual(METADATA_REMINDER_FIELDS)
})
it('restores saved preferences after a network failure', async () => {
vi.mocked(api).mockRejectedValue(new Error('Offline'))
const reminders = useMetadataReminders()
expect(await reminders.saveFields([])).toBe(false)
expect(reminders.saving.value).toBe(false)
expect(reminders.fields.value).toEqual(METADATA_REMINDER_FIELDS)
})
it('shares pending choices across consumers and prevents overlapping writes', async () => {
let resolve!: (response: Response) => void
vi.mocked(api).mockReturnValue(
new Promise<Response>((done) => {
resolve = done
}),
)
const reminders = useMetadataReminders()
const saving = reminders.saveFields([])
expect(useMetadataReminders().fields.value).toEqual([])
expect(await reminders.saveFields(['isbn'])).toBe(false)
expect(api).toHaveBeenCalledTimes(1)
resolve({ ok: true } as Response)
expect(await saving).toBe(true)
})
it('does not expose pending choices or apply stale responses to another account', async () => {
let resolve!: (response: Response) => void
vi.mocked(api).mockReturnValue(
new Promise<Response>((done) => {
resolve = done
}),
)
const reminders = useMetadataReminders()
const saving = reminders.saveFields([])
user.value = account(8)
expect(reminders.fields.value).toEqual(METADATA_REMINDER_FIELDS)
resolve({ ok: true } as Response)
expect(await saving).toBe(false)
expect(user.value.settings.metadataReminderPreferences).toBeUndefined()
})
})
@@ -0,0 +1,42 @@
import { computed, ref } from 'vue'
import { normalizeMetadataReminderFields, type MetadataReminderField } from '@bookorbit/types'
import { useAuth } from '@/features/auth/composables/useAuth'
import { api } from '@/lib/api'
const pending = ref<{ userId: number; fields: MetadataReminderField[] } | null>(null)
export function useMetadataReminders() {
const { user } = useAuth()
const fields = computed(() => {
const pendingSave = pending.value
if (pendingSave && pendingSave.userId === user.value?.id) return pendingSave.fields
return normalizeMetadataReminderFields(user.value?.settings?.metadataReminderPreferences?.fields)
})
const saving = computed(() => pending.value !== null)
async function saveFields(next: readonly MetadataReminderField[]): Promise<boolean> {
if (!user.value || saving.value) return false
const userId = user.value.id
const normalized = normalizeMetadataReminderFields(next)
pending.value = { userId, fields: normalized }
try {
const response = await api('/api/v1/users/me/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: { metadataReminderPreferences: { fields: normalized } } }),
})
if (!response.ok || user.value?.id !== userId) return false
user.value = {
...user.value,
settings: { ...user.value.settings, metadataReminderPreferences: { fields: normalized } },
}
return true
} catch {
return false
} finally {
pending.value = null
}
}
return { fields, saving, saveFields }
}
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { METADATA_REMINDER_FIELDS, normalizeMetadataReminderFields } from '@bookorbit/types'
import { missingMetadataFields } from './missing-metadata'
const empty = {
publisher: null,
language: null,
publishedYear: null,
publishedDate: null,
pageCount: null,
isbn10: null,
isbn13: null,
genres: [],
tags: [],
description: null,
}
describe('missing metadata reminders', () => {
it('uses defaults for absent or malformed preferences, and preserves an explicit empty selection', () => {
expect(normalizeMetadataReminderFields(undefined)).toEqual(METADATA_REMINDER_FIELDS)
expect(normalizeMetadataReminderFields('isbn')).toEqual(METADATA_REMINDER_FIELDS)
expect(normalizeMetadataReminderFields([])).toEqual([])
expect(normalizeMetadataReminderFields(['isbn', 'future-field', 'isbn'])).toEqual(['isbn'])
})
it('counts ISBN once when both identifiers are missing, and accepts either identifier', () => {
expect(missingMetadataFields(empty, [], ['isbn'])).toEqual(['isbn'])
expect(missingMetadataFields({ ...empty, isbn10: '0593419111' }, [], ['isbn'])).toEqual([])
expect(missingMetadataFields({ ...empty, isbn13: '9780593419113' }, [], ['isbn'])).toEqual([])
})
it('accepts either a publication year or full date', () => {
expect(missingMetadataFields({ ...empty, publishedYear: 2023 }, [], ['publication'])).toEqual([])
expect(missingMetadataFields({ ...empty, publishedDate: '2023-04-25' }, [], ['publication'])).toEqual([])
})
it('skips page count for audio-only content, including a supplementary PDF', () => {
const files = [
{ format: 'm4b', role: 'primary' },
{ format: 'pdf', role: 'supplement' },
]
expect(missingMetadataFields(empty, files, ['pageCount'])).toEqual([])
})
it('checks page count for mixed media even when audio is primary, and for comics', () => {
const files = [
{ format: 'm4b', role: 'primary' },
{ format: 'epub', role: 'content' },
]
expect(missingMetadataFields(empty, files, ['pageCount'])).toEqual(['pageCount'])
expect(missingMetadataFields(empty, [{ format: 'cbz', role: 'content' }], ['pageCount'])).toEqual(['pageCount'])
})
it('ignores unchecked fields and treats whitespace-only text and lists as empty', () => {
const metadata = { ...empty, publisher: ' ', genres: [' '], tags: ['Cooking'], description: 'A cookbook' }
expect(missingMetadataFields(metadata, [], ['publisher', 'genres', 'tags', 'description'])).toEqual(['publisher', 'genres'])
expect(missingMetadataFields(metadata, [], [])).toEqual([])
})
it('does not turn a present zero into a missing-field reminder', () => {
expect(missingMetadataFields({ ...empty, pageCount: 0 }, [], ['pageCount'])).toEqual([])
})
})
@@ -0,0 +1,35 @@
import { getBookMediaProfile, type BookDetailFile, type MetadataReminderField } from '@bookorbit/types'
type ReminderMetadata = {
publisher: string | null
language: string | null
publishedYear: number | null
publishedDate: string | null
pageCount: number | null
isbn10: string | null
isbn13: string | null
genres: string[]
tags: string[]
description: string | null
}
export function missingMetadataFields(
metadata: ReminderMetadata,
files: readonly Pick<BookDetailFile, 'format' | 'role'>[],
enabledFields: readonly MetadataReminderField[],
): MetadataReminderField[] {
const contentFiles = files.filter((file) => file.role === 'content' || file.role === 'primary')
const media = getBookMediaProfile(contentFiles)
const audioOnly = media.hasAudio && !media.hasEbook && !media.hasComic
const filled: Record<MetadataReminderField, boolean> = {
publisher: Boolean(metadata.publisher?.trim()),
language: Boolean(metadata.language?.trim()),
publication: metadata.publishedYear != null || Boolean(metadata.publishedDate?.trim()),
pageCount: audioOnly || metadata.pageCount != null,
isbn: Boolean(metadata.isbn10?.trim() || metadata.isbn13?.trim()),
genres: metadata.genres.some((genre) => Boolean(genre.trim())),
tags: metadata.tags.some((tag) => Boolean(tag.trim())),
description: Boolean(metadata.description?.trim()),
}
return enabledFields.filter((field) => !filled[field])
}
@@ -0,0 +1,7 @@
<script setup lang="ts">
import MetadataReminderControls from '@/features/metadata-reminders/components/MetadataReminderControls.vue'
</script>
<template>
<MetadataReminderControls />
</template>
@@ -121,8 +121,8 @@ describe('SettingsNav', () => {
})
describe('group visibility', () => {
it('shows only the personal group to a user without permissions', () => {
expect(groupLabels(mountNav())).toEqual(['You'])
it('shows personal and library preferences to a user without permissions', () => {
expect(groupLabels(mountNav())).toEqual(['You', 'Library'])
})
it('shows every group to a superuser', () => {
@@ -161,8 +161,15 @@ describe('SettingsNav', () => {
expect(childLabels(mountNav({ perms: ['manage_libraries'], routeName: 'settings-metadata-custom-fields' }))).toContain('Custom Fields')
})
it('drops the metadata row entirely when no child is reachable', () => {
expect(itemLabels(mountNav({ perms: ['manage_app_settings'] }))).not.toContain('Metadata')
it('shows personal reminders without exposing administrative metadata pages', () => {
const wrapper = mountNav({ routeName: 'settings-metadata-reminders' })
expect(itemLabels(wrapper)).toContain('Metadata')
expect(childLabels(wrapper)).toEqual(['Missing-field reminders'])
})
it('places personal reminders last after the genre blocklist', () => {
const wrapper = mountNav({ su: true, routeName: 'settings-metadata-reminders' })
expect(childLabels(wrapper).slice(-2)).toEqual(['Genre Blocklist', 'Missing-field reminders'])
})
it('shows file naming and maintenance with manage_app_settings', () => {
@@ -86,7 +86,9 @@ describe('SettingsSidebar', () => {
})
it('only shows groups the user has access to', () => {
expect(mountSidebar({ rail: true }).findAll('[data-testid="rail-group"]')).toHaveLength(1)
const personal = mountSidebar({ rail: true })
expect(personal.findAll('[data-testid="rail-group"]')).toHaveLength(2)
expect(personal.findAll('[data-testid="settings-sidebar-rail-item"]').map((node) => node.text())).toContain('Missing-field reminders')
expect(mountSidebar({ rail: true, perms: ['manage_libraries'] }).findAll('[data-testid="rail-group"]')).toHaveLength(2)
})
@@ -50,7 +50,9 @@ describe('settings navigation model', () => {
describe('visibleSettingsNav', () => {
it('drops groups with no reachable destination', () => {
expect(visibleSettingsNav(NOBODY).map((group) => group.id)).toEqual(['you'])
expect(visibleSettingsNav(NOBODY).map((group) => group.id)).toEqual(['you', 'library'])
const library = visibleSettingsNav(NOBODY).find((group) => group.id === 'library')
expect(library?.items.flatMap((item) => item.children?.map((child) => child.id) ?? [item.id])).toEqual(['metadata-reminders'])
})
it('keeps every group for a superuser', () => {
@@ -100,9 +102,6 @@ describe('visibleSettingsNav', () => {
})
it('drops a grouping row once every child behind it is hidden', () => {
const library = visibleSettingsNav({ ...NOBODY, permissions: ['manage_app_settings'] }).find((group) => group.id === 'library')
expect(library?.items.map((item) => item.id)).not.toContain('metadata')
const server = visibleSettingsNav({ ...NOBODY, permissions: [Permission.ManageBookDock] }).find((group) => group.id === 'server')
expect(server?.items.map((item) => item.id)).not.toContain('users-access')
})
@@ -2,13 +2,14 @@ import { describe, expect, it } from 'vitest'
import { METADATA_TABS, normalizeMetadataTab } from '../metadata-tabs'
describe('metadata tabs', () => {
it('keeps genre blocklist as the final metadata tab', () => {
expect(METADATA_TABS).toEqual(['providers', 'field-rules', 'custom-fields', 'score', 'auto-fetch', 'authors', 'genre-blocklist'])
it('places reminders last after genre blocklist', () => {
expect(METADATA_TABS).toEqual(['providers', 'field-rules', 'custom-fields', 'score', 'auto-fetch', 'authors', 'genre-blocklist', 'reminders'])
})
it('normalizes supported metadata tabs and falls back to providers', () => {
expect(normalizeMetadataTab('custom-fields')).toBe('custom-fields')
expect(normalizeMetadataTab('genre-blocklist')).toBe('genre-blocklist')
expect(normalizeMetadataTab('reminders')).toBe('reminders')
expect(normalizeMetadataTab('unknown')).toBe('providers')
})
})
@@ -1,4 +1,4 @@
export const METADATA_TABS = ['providers', 'field-rules', 'custom-fields', 'score', 'auto-fetch', 'authors', 'genre-blocklist'] as const
export const METADATA_TABS = ['providers', 'field-rules', 'custom-fields', 'score', 'auto-fetch', 'authors', 'genre-blocklist', 'reminders'] as const
export type MetadataTab = (typeof METADATA_TABS)[number]
@@ -19,6 +19,7 @@ import {
Library,
LibraryBig,
Link2,
ListChecks,
Lock,
Mail,
Palette,
@@ -341,6 +342,14 @@ export const SETTINGS_NAV: readonly SettingsNavGroup[] = [
keywords: 'genre tag blocklist exclude ignore',
isVisible: anyPermission('manage_metadata_config'),
},
{
id: 'metadata-reminders',
routeName: 'settings-metadata-reminders',
labelKey: 'metadataReminders.title',
descriptionKey: 'metadataReminders.navDescription',
icon: ListChecks,
keywords: 'missing empty field reminder isbn tags personal preferences completeness',
},
],
},
{
+53
View File
@@ -9316,6 +9316,59 @@
}
}
},
"metadataReminders": {
"title": "Missing-field reminders",
"navDescription": "Choose which missing metadata fields you want to see",
"personal": "Your preferences",
"description": "Choose which missing fields appear in book reminders. These preferences apply across your library and do not change metadata scores.",
"sheetDescription": "Choose the fields you care about without leaving this book.",
"fieldsHeading": "Fields to check",
"autoSave": "Changes save automatically to your account.",
"saving": "Saving preferences...",
"saved": "Preferences saved.",
"reset": "Reset to defaults",
"disabled": "No fields selected. Missing-field reminders are hidden.",
"saveFailed": "Could not save your preferences. Your previous preferences have been restored.",
"customize": "Customize reminders...",
"missingHeading": "Missing metadata",
"missingDescription": "Missing fields you have chosen to track.",
"missingCount": "{count, plural, one {# missing field} other {# missing fields}}",
"missingFields": "{count, plural, one {missing field} other {missing fields}}",
"fields": {
"publisher": {
"label": "Publisher",
"hint": "The publisher is filled in."
},
"language": {
"label": "Language",
"hint": "The language is filled in."
},
"publication": {
"label": "Publication year",
"hint": "A publication year or full date is enough."
},
"pageCount": {
"label": "Page count",
"hint": "The page count is filled in. Audio-only books are skipped."
},
"isbn": {
"label": "ISBN",
"hint": "Either ISBN-10 or ISBN-13 is enough."
},
"genres": {
"label": "Genres",
"hint": "At least one genre is filled in."
},
"tags": {
"label": "Tags",
"hint": "At least one tag is filled in."
},
"description": {
"label": "Description",
"hint": "The description is filled in."
}
}
},
"metadataScore": {
"missingFields": "{count, plural, =0 {no fields missing - edit metadata} one {# field missing - edit metadata} other {# fields missing - edit metadata}}"
},
+7
View File
@@ -75,6 +75,7 @@ const READER_ROUTES: Record<ReaderTab, string> = {
}
const METADATA_ROUTES: Record<MetadataTab, string> = {
reminders: 'settings-metadata-reminders',
providers: 'settings-metadata-providers',
'field-rules': 'settings-metadata-field-rules',
'custom-fields': 'settings-metadata-custom-fields',
@@ -346,6 +347,12 @@ export const routes: RouteRecordRaw[] = [
name: 'settings-metadata',
redirect: tabRedirect(normalizeMetadataTab, METADATA_ROUTES),
},
{
path: 'metadata/reminders',
name: 'settings-metadata-reminders',
component: () => import('@/features/settings/MetadataRemindersSettings.vue'),
meta: { maxWidth: 'max-w-3xl', title: () => t('metadataReminders.title') },
},
{
path: 'metadata/providers',
name: 'settings-metadata-providers',