mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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}}"
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user