diff --git a/client/src/features/library/components/LibraryCreatorModal.vue b/client/src/features/library/components/LibraryCreatorModal.vue index 670226ed8..2df6e3507 100644 --- a/client/src/features/library/components/LibraryCreatorModal.vue +++ b/client/src/features/library/components/LibraryCreatorModal.vue @@ -34,6 +34,7 @@ import { api } from '@/lib/api' import { useLibraryAddedAt } from '../composables/useLibraryAddedAt' import { LIBRARY_ACCESS_KEY, useLibraryAccess } from '../composables/useLibraryAccess' import { useLibraryCreator, type LibraryCreatorSectionId } from '../composables/useLibraryCreator' +import { writtenKindCount } from '../utils/library-summary' import LibraryCreatorAccess from './LibraryCreatorAccess.vue' import LibraryCreatorDetails from './LibraryCreatorDetails.vue' import LibraryCreatorFileWrite from './LibraryCreatorFileWrite.vue' @@ -49,6 +50,10 @@ const props = defineProps<{ library?: Library | null /** Create mode only: pre-selects the library type (e.g. from the podcast sidebar section). */ initialType?: LibraryType + /** The section to open on, e.g. from an Edit button beside that part of the library. */ + initialSection?: LibraryCreatorSectionId + /** Create mode only: a library whose settings seed the new one. Its folders and access are not copied. */ + template?: Library | null }>() const emit = defineEmits<{ @@ -81,13 +86,6 @@ const SECTION_COMPONENTS: Record = { access: LibraryCreatorAccess, } const BOOK_SECTIONS: LibraryCreatorSectionId[] = ['details', 'folders', 'scanner', 'metadata', 'reading', 'schedule', 'fileWrite', 'access'] -const WRITE_FAMILY_FLAGS = [ - 'fileWriteEpubEnabled', - 'fileWriteFb2Enabled', - 'fileWritePdfEnabled', - 'fileWriteCbxEnabled', - 'fileWriteKindleEnabled', -] as const const creator = useLibraryCreator() const { form, mode, editingLibraryId, loading, folderChecks, stats, error, validationErrors } = creator @@ -150,9 +148,7 @@ const leadFormat = computed(() => { return lead ? lead.toUpperCase() : '' }) -const writtenKinds = computed( - () => WRITE_FAMILY_FLAGS.filter((flag) => form[flag]).length + (form.fileWriteAudioEnabled && form.fileWriteWriteCover ? 1 : 0), -) +const writtenKinds = computed(() => writtenKindCount(form)) const scheduleLabel = computed(() => { const cron = form.autoScanCronExpression @@ -545,15 +541,25 @@ async function initialize() { void access.load() return } + if (props.template) { + creator.initFromTemplate(props.template, t('library.creator.copyName', { name: props.template.name })) + return + } creator.initCreate() if (props.initialType && props.initialType !== form.type) handleTypeUpdate(props.initialType) } +function startingSection(): LibraryCreatorSectionId | null { + const requested = props.initialSection && sectionIds.value.includes(props.initialSection) ? props.initialSection : null + if (requested) return requested + return isWide.value ? 'details' : null +} + onMounted(async () => { try { await initialize() } finally { - activeSection.value = isWide.value ? 'details' : null + activeSection.value = startingSection() initialFormSnapshot.value = JSON.stringify(form) initializing.value = false if (creating.value && isWide.value) { diff --git a/client/src/features/library/components/__tests__/LibraryCreatorModal.spec.ts b/client/src/features/library/components/__tests__/LibraryCreatorModal.spec.ts index 76cfa3465..ea711b46e 100644 --- a/client/src/features/library/components/__tests__/LibraryCreatorModal.spec.ts +++ b/client/src/features/library/components/__tests__/LibraryCreatorModal.spec.ts @@ -151,6 +151,29 @@ describe('LibraryCreatorModal', () => { wrapper.unmount() }) + it('opens on the section it was asked for', async () => { + const library = makeLibrary() + apiMock.mockImplementation(async (url) => jsonResponse(String(url).endsWith('/libraries/9') ? library : null)) + const wrapper = await mountCreator({ library, initialSection: 'access' }) + + expect(navButton(wrapper, 'Access').attributes('aria-current')).toBe('true') + wrapper.unmount() + }) + + it("starts a new library from another one's settings, without its folders", async () => { + const template = makeLibrary({ name: 'Novels', watch: true, autoScanCronExpression: '0 0 * * 1', fileRenameEnabled: true }) + const wrapper = await mountCreator({ template }) + + expect(wrapper.get('#library-creator-title').text()).toBe('New library') + expect(wrapper.getComponent(LibraryCreatorDetails).props('name')).toBe('Novels copy') + expect(wrapper.getComponent(LibraryCreatorDetails).props('icon')).toBe('Swords') + expect(navButton(wrapper, 'Folders').text()).toContain('Needs a folder') + expect(navButton(wrapper, 'Automation').text()).toContain('Watching · Weekly') + expect(navButton(wrapper, 'File updates').text()).toContain('Renames') + expect(apiMock.mock.calls.some((call) => String(call[0]).endsWith('/libraries/9'))).toBe(false) + wrapper.unmount() + }) + it('keeps save disabled until something changes while editing', async () => { const library = makeLibrary() apiMock.mockImplementation(async (url) => jsonResponse(String(url).endsWith('/libraries/9') ? library : null)) diff --git a/client/src/features/library/composables/useLibraryCreator.ts b/client/src/features/library/composables/useLibraryCreator.ts index b93882aeb..ba7ebe461 100644 --- a/client/src/features/library/composables/useLibraryCreator.ts +++ b/client/src/features/library/composables/useLibraryCreator.ts @@ -124,6 +124,28 @@ export function useLibraryCreator() { } function initEdit(library: Library) { + applyLibrary(library) + mode.value = 'edit' + editingLibraryId.value = library.id + folderChecks.value = {} + error.value = null + } + + /** + * Seeds a new library from an existing one's settings. Folders and access belong to the original, so + * they start empty, and nothing reaches the server until the new library is created. + */ + function initFromTemplate(template: Library, name: string) { + initCreate() + applyLibrary(template) + form.name = name + form.displayOrder = blankForm().displayOrder + form.folders = [] + form.localFolders = [] + storedAddedAtSource.value = null + } + + function applyLibrary(library: Library) { form.type = library.type form.name = library.name form.icon = library.icon ?? null @@ -159,10 +181,6 @@ export function useLibraryCreator() { form.fileWriteAudioEnabled = library.fileWriteAudioEnabled form.fileWriteAudioMaxFileSizeMb = library.fileWriteAudioMaxFileSizeMb form.fileRenameEnabled = library.fileRenameEnabled - mode.value = 'edit' - editingLibraryId.value = library.id - folderChecks.value = {} - error.value = null } /** @@ -285,6 +303,7 @@ export function useLibraryCreator() { validationErrors, initCreate, initEdit, + initFromTemplate, checkFolders, loadStats, save, diff --git a/client/src/features/library/utils/__tests__/library-summary.spec.ts b/client/src/features/library/utils/__tests__/library-summary.spec.ts new file mode 100644 index 000000000..087db778c --- /dev/null +++ b/client/src/features/library/utils/__tests__/library-summary.spec.ts @@ -0,0 +1,53 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest' +import { describeSchedule, SCHEDULE_PRESET_CRONS, writtenKindCount, type FileWriteFlags } from '../library-summary' + +const noWrites: FileWriteFlags = { + fileWriteEpubEnabled: false, + fileWriteFb2Enabled: false, + fileWritePdfEnabled: false, + fileWriteCbxEnabled: false, + fileWriteKindleEnabled: false, + fileWriteAudioEnabled: false, + fileWriteWriteCover: false, +} + +describe('describeSchedule', () => { + it('names each preset the editor offers', () => { + expect(SCHEDULE_PRESET_CRONS.map(({ cron }) => describeSchedule(cron)?.preset)).toEqual([ + 'hourly', + 'every6Hours', + 'every12Hours', + 'daily', + 'weekly', + ]) + }) + + it('keeps the time of a daily or weekly schedule that is not at midnight', () => { + expect(describeSchedule('30 2 * * *')).toEqual({ preset: 'daily', hour: 2, minute: 30 }) + expect(describeSchedule('0 6 * * 5')).toEqual({ preset: 'weekly', hour: 6, minute: 0, weekday: 5 }) + }) + + it('reads a weekday of 7 as Sunday', () => { + expect(describeSchedule('0 0 * * 7')).toMatchObject({ preset: 'weekly', weekday: 0 }) + }) + + it('leaves anything the presets cannot name to the full description', () => { + for (const cron of ['15 3 1 * *', '0 */4 * * *', '5 * * * *', '0 0 * * 1-5', '0 25 * * *', 'not a cron', '']) { + expect(describeSchedule(cron)).toBeNull() + } + expect(describeSchedule(null)).toBeNull() + }) +}) + +describe('writtenKindCount', () => { + it('counts each file family that is written', () => { + expect(writtenKindCount(noWrites)).toBe(0) + expect(writtenKindCount({ ...noWrites, fileWriteEpubEnabled: true, fileWritePdfEnabled: true })).toBe(2) + }) + + it('counts audio only when the cover is written into it too', () => { + expect(writtenKindCount({ ...noWrites, fileWriteAudioEnabled: true })).toBe(0) + expect(writtenKindCount({ ...noWrites, fileWriteAudioEnabled: true, fileWriteWriteCover: true })).toBe(1) + }) +}) diff --git a/client/src/features/library/utils/library-summary.ts b/client/src/features/library/utils/library-summary.ts new file mode 100644 index 000000000..a3ff95422 --- /dev/null +++ b/client/src/features/library/utils/library-summary.ts @@ -0,0 +1,59 @@ +import type { Library } from '@bookorbit/types' + +/** File kinds BookOrbit can write metadata into; audio counts only when it may also embed the cover. */ +export const WRITE_FAMILY_FLAGS = [ + 'fileWriteEpubEnabled', + 'fileWriteFb2Enabled', + 'fileWritePdfEnabled', + 'fileWriteCbxEnabled', + 'fileWriteKindleEnabled', +] as const + +export type FileWriteFlags = Pick + +export function writtenKindCount(flags: FileWriteFlags): number { + return WRITE_FAMILY_FLAGS.filter((flag) => flags[flag]).length + (flags.fileWriteAudioEnabled && flags.fileWriteWriteCover ? 1 : 0) +} + +export type SchedulePreset = 'hourly' | 'every6Hours' | 'every12Hours' | 'daily' | 'weekly' + +/** A schedule the editor's presets can name, with the time it fires for the daily and weekly ones. */ +export type ScheduleSummary = + | { preset: 'hourly' | 'every6Hours' | 'every12Hours' } + | { preset: 'daily'; hour: number; minute: number } + | { preset: 'weekly'; hour: number; minute: number; weekday: number } + +const INTEGER = /^\d{1,2}$/ + +/** + * Names a cron expression by the editor's presets when one fits. Daily and weekly keep their exact time, so + * `0 2 * * *` is still "Daily" rather than falling back to a sentence. Anything else is left to the caller, + * which shows the full cron description instead. + */ +export function describeSchedule(cron: string | null | undefined): ScheduleSummary | null { + const fields = cron?.trim().split(/\s+/) + if (!fields || fields.length !== 5) return null + const [minute, hour, dayOfMonth, month, dayOfWeek] = fields as [string, string, string, string, string] + if (dayOfMonth !== '*' || month !== '*') return null + if (minute === '0' && dayOfWeek === '*') { + if (hour === '*') return { preset: 'hourly' } + if (hour === '*/6') return { preset: 'every6Hours' } + if (hour === '*/12') return { preset: 'every12Hours' } + } + if (!INTEGER.test(minute) || !INTEGER.test(hour)) return null + const m = Number(minute) + const h = Number(hour) + if (m > 59 || h > 23) return null + if (dayOfWeek === '*') return { preset: 'daily', hour: h, minute: m } + if (!/^[0-7]$/.test(dayOfWeek)) return null + return { preset: 'weekly', hour: h, minute: m, weekday: Number(dayOfWeek) % 7 } +} + +/** The editor's schedule presets, in the order its picker lists them. */ +export const SCHEDULE_PRESET_CRONS: { preset: SchedulePreset; cron: string }[] = [ + { preset: 'hourly', cron: '0 * * * *' }, + { preset: 'every6Hours', cron: '0 */6 * * *' }, + { preset: 'every12Hours', cron: '0 */12 * * *' }, + { preset: 'daily', cron: '0 0 * * *' }, + { preset: 'weekly', cron: '0 0 * * 1' }, +] diff --git a/client/src/features/scanner/composables/useScanProgress.ts b/client/src/features/scanner/composables/useScanProgress.ts index 81aa22b71..f0a6f5bff 100644 --- a/client/src/features/scanner/composables/useScanProgress.ts +++ b/client/src/features/scanner/composables/useScanProgress.ts @@ -6,6 +6,8 @@ import { createAuthenticatedSocket } from '@/lib/socket' import { useCoverVersions } from '@/features/book/composables/useCoverVersions' let socket: Socket | null = null +/** While a page announces finished scans itself (the Libraries settings page), the generic toast stays quiet. */ +let completionToastClaims = 0 const progressMap = ref>(new Map()) const coverRefreshMap = ref>(new Map()) const subscribedLibraries = new Set() @@ -18,7 +20,7 @@ function getSocket(): Socket { progressMap.value.set(event.libraryId, event) progressMap.value = new Map(progressMap.value) if (event.status !== 'running') { - if (event.status === 'completed' && event.added > 0) { + if (event.status === 'completed' && event.added > 0 && completionToastClaims === 0) { toast.success(`${event.added} new ${event.added === 1 ? 'book' : 'books'} added`) } // Keep the final event for a short time so UI can show completion, then clear. @@ -61,6 +63,17 @@ function getSocket(): Socket { export { getSocket } +/** Takes over the "books added" toast until the returned release is called. */ +export function claimScanCompletionToasts(): () => void { + completionToastClaims += 1 + let released = false + return () => { + if (released) return + released = true + completionToastClaims -= 1 + } +} + export function useScanProgress() { function subscribeLibrary(libraryId: number): void { if (subscribedLibraries.has(libraryId)) return diff --git a/client/src/features/settings/LibrariesSettings.vue b/client/src/features/settings/LibrariesSettings.vue index ea2742c5c..f4c76805f 100644 --- a/client/src/features/settings/LibrariesSettings.vue +++ b/client/src/features/settings/LibrariesSettings.vue @@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import { FolderOpen, Plus, RefreshCw } from '@lucide/vue' import { toast } from 'vue-sonner' -import type { Library as LibraryType } from '@bookorbit/types' +import type { Library as LibraryType, ScanProgressEvent } from '@bookorbit/types' import { Button } from '@/components/ui/button' import ConfirmDialog from '@/components/ui/ConfirmDialog.vue' @@ -14,14 +14,17 @@ import { api } from '@/lib/api' import { jsonBody } from '@/lib/api-json' import { usePermissions } from '@/features/auth/composables/usePermissions' import LibraryCreatorModal from '@/features/library/components/LibraryCreatorModal.vue' +import type { LibraryCreatorSectionId } from '@/features/library/composables/useLibraryCreator' import { useLibraries } from '@/features/library/composables/useLibraries' import { useLibraryCreationRedirect } from '@/features/library/composables/useLibraryCreationRedirect' import { useLibraryFileSync } from '@/features/library/composables/useLibraryFileSync' -import { getSocket, useScanProgress } from '@/features/scanner/composables/useScanProgress' +import { claimScanCompletionToasts, getSocket, useScanProgress } from '@/features/scanner/composables/useScanProgress' import LibrariesToolbar from './libraries/components/LibrariesToolbar.vue' import LibraryLedgerCards from './libraries/components/LibraryLedgerCards.vue' import LibraryLedgerList from './libraries/components/LibraryLedgerList.vue' import { useLibraryDetail } from './libraries/composables/useLibraryDetail' +import { formatSchedule } from './libraries/lib/library-schedule' +import type { AutomationToggle } from './libraries/components/LibraryAutomationList.vue' import { useLibraryOverview } from './libraries/composables/useLibraryOverview' import { matchesLibraryQuery, sortLibraries, type LibrarySortField } from './libraries/lib/library-sort' @@ -49,6 +52,10 @@ const sortBy = ref('default') const scanningAll = ref(false) const creatorOpen = ref(false) const editingLibrary = ref(null) +const editingSection = ref(undefined) +const templateLibrary = ref(null) +const savingAutomation = ref>(new Set()) +const justFinished = ref>(new Set()) const deletingLibrary = ref(null) const deleteConfirmName = ref('') const deleting = ref(false) @@ -80,6 +87,9 @@ function subscribeAll() { } } +/** This page announces finished scans with their outcome, so the generic "books added" toast stands down. */ +const releaseScanToasts = claimScanCompletionToasts() + onMounted(async () => { headerSlotAvailable.value = document.getElementById(HEADER_ACTIONS_TARGET.slice(1)) !== null getSocket() @@ -90,6 +100,8 @@ onMounted(async () => { onUnmounted(() => { overview.dispose() + releaseScanToasts() + for (const timer of finishTimers.values()) clearTimeout(timer) }) /** @@ -98,6 +110,44 @@ onUnmounted(() => { * leave the map, which keeps the set bounded to the scans currently in flight. */ const handledScanJobs = new Set() +const finishTimers = new Map>() +const FINISH_HIGHLIGHT_MS = 2200 + +function scanOutcomeText(event: ScanProgressEvent): string { + const parts: string[] = [] + if (event.added > 0) parts.push(t('settings.admin.libraries.scanAdded', { count: event.added })) + if (event.updated > 0) parts.push(t('settings.admin.libraries.scanUpdated', { count: event.updated })) + if (event.missing > 0) parts.push(t('settings.admin.libraries.scanMissing', { count: event.missing })) + return parts.length > 0 ? parts.join(t('settings.admin.libraries.outcomeSeparator')) : t('settings.admin.libraries.scanNoChange') +} + +/** Tints the row once so the library that just changed is findable, then lets it go. */ +function highlightFinished(libraryId: number) { + justFinished.value = new Set(justFinished.value).add(libraryId) + clearTimeout(finishTimers.get(libraryId)) + finishTimers.set( + libraryId, + setTimeout(() => { + const next = new Set(justFinished.value) + next.delete(libraryId) + justFinished.value = next + finishTimers.delete(libraryId) + }, FINISH_HIGHLIGHT_MS), + ) +} + +function announceFinished(event: ScanProgressEvent) { + const lib = libraries.value.find((library) => library.id === event.libraryId) + if (!lib) return + if (event.status === 'failed') { + toast.error(t('settings.admin.libraries.scanFinishedFailed', { name: lib.name }), { description: event.errorMessage }) + return + } + highlightFinished(lib.id) + toast.success(t('settings.admin.libraries.scanFinished', { name: lib.name, outcome: scanOutcomeText(event) }), { + action: { label: t('settings.admin.libraries.viewChanges'), onClick: () => openDetail(lib) }, + }) +} watch(progressMap, (map) => { const liveJobIds = new Set() @@ -107,6 +157,7 @@ watch(progressMap, (map) => { if (event.status === 'running' || handledScanJobs.has(event.jobId)) continue handledScanJobs.add(event.jobId) finished = true + announceFinished(event) } for (const jobId of handledScanJobs) { if (!liveJobIds.has(jobId)) handledScanJobs.delete(jobId) @@ -133,12 +184,75 @@ function toggleDetail(lib: LibraryType) { void detail.load(lib.id) } +/** Opens a row's details without closing it if it is already open, for "View changes". */ +function openDetail(lib: LibraryType) { + expandedId.value = lib.id + void detail.load(lib.id) +} + function historyFor(libraryId: number) { return detail.get(libraryId)?.history ?? null } -function accessCountFor(libraryId: number): number | null { - return detail.get(libraryId)?.accessCount ?? null +function accessFor(libraryId: number) { + return detail.get(libraryId)?.access ?? null +} + +function isSavingAutomation(libraryId: number): boolean { + return savingAutomation.value.has(libraryId) +} + +function isJustFinished(libraryId: number): boolean { + return justFinished.value.has(libraryId) +} + +type AutomationPatch = { watch: boolean } | { fileWriteEnabled: boolean } | { fileRenameEnabled: boolean } | { autoScanCronExpression: string | null } + +/** + * Saves one automation setting straight from the row. Both fields are optional on PATCH, so nothing else + * about the library is sent or changed. The toast's Undo sends the previous value back the same way. + */ +async function saveAutomation(lib: LibraryType, patch: AutomationPatch, undo: AutomationPatch | null, message: string): Promise { + savingAutomation.value = new Set(savingAutomation.value).add(lib.id) + try { + const res = await api(`/api/v1/libraries/${lib.id}`, jsonBody('PATCH', patch)) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + await refreshLibraries() + if (undo) { + toast.success(message, { action: { label: t('common.undo'), onClick: () => void saveAutomation(lib, undo, null, message) } }) + } + return true + } catch { + toast.error(t('settings.admin.libraries.quick.saveFailed', { name: lib.name })) + return false + } finally { + const next = new Set(savingAutomation.value) + next.delete(lib.id) + savingAutomation.value = next + } +} + +const TOGGLE_MESSAGES: Record = { + watch: { on: 'settings.admin.libraries.quick.watchOn', off: 'settings.admin.libraries.quick.watchOff' }, + fileWriteEnabled: { on: 'settings.admin.libraries.quick.fileWriteOn', off: 'settings.admin.libraries.quick.fileWriteOff' }, + fileRenameEnabled: { on: 'settings.admin.libraries.quick.renameOn', off: 'settings.admin.libraries.quick.renameOff' }, +} + +function toggleSetting(lib: LibraryType, setting: AutomationToggle) { + const next = !lib[setting] + const message = t(next ? TOGGLE_MESSAGES[setting].on : TOGGLE_MESSAGES[setting].off, { name: lib.name }) + const patch = { [setting]: next } as AutomationPatch + const undo = { [setting]: lib[setting] } as AutomationPatch + void saveAutomation(lib, patch, undo, message) +} + +function setSchedule(lib: LibraryType, cron: string | null) { + const previous = lib.autoScanCronExpression ?? null + const label = cron ? (formatSchedule(cron, t, locale.value)?.label ?? cron) : '' + const message = cron + ? t('settings.admin.libraries.quick.scheduleSet', { name: lib.name, value: label }) + : t('settings.admin.libraries.quick.scheduleOff', { name: lib.name }) + void saveAutomation(lib, { autoScanCronExpression: cron }, { autoScanCronExpression: previous }, message) } function isDetailLoading(libraryId: number): boolean { @@ -239,23 +353,36 @@ async function confirmSyncFiles() { function openCreate() { editingLibrary.value = null + editingSection.value = undefined + templateLibrary.value = null creatorOpen.value = true } -function openEdit(lib: LibraryType) { +function openEdit(lib: LibraryType, section?: LibraryCreatorSectionId) { editingLibrary.value = lib + editingSection.value = section + templateLibrary.value = null + creatorOpen.value = true +} + +/** A new library seeded from this one's settings; its folders and access are left for the new one. */ +function openDuplicate(lib: LibraryType) { + editingLibrary.value = null + editingSection.value = undefined + templateLibrary.value = lib creatorOpen.value = true } function closeCreator() { creatorOpen.value = false editingLibrary.value = null + editingSection.value = undefined + templateLibrary.value = null } async function onSaved(library: LibraryType) { const isNew = !editingLibrary.value - creatorOpen.value = false - editingLibrary.value = null + closeCreator() subscribeLibrary(library.id) if (isNew) { toast.success(t('settings.admin.libraries.libraryCreated', { name: library.name })) @@ -320,7 +447,8 @@ async function confirmDelete() {