From de0954e77f73901b32b678b140ed9e89fae12a82 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Wed, 30 Sep 2026 06:38:54 +0000 Subject: [PATCH] feat(settings): polish the libraries page and add in-place controls Cut long paths from the start, name layouts and schedules the way the editor does, and switch to cards when the ledger would scroll sideways. Rows now toggle automation in place with undo, open recent scan history, show live scan counts, flag failed or never-scanned libraries, and can seed a new library from another's settings. --- .../components/LibraryCreatorModal.vue | 28 +- .../__tests__/LibraryCreatorModal.spec.ts | 23 ++ .../library/composables/useLibraryCreator.ts | 27 +- .../utils/__tests__/library-summary.spec.ts | 53 +++ .../features/library/utils/library-summary.ts | 59 ++++ .../scanner/composables/useScanProgress.ts | 15 +- .../features/settings/LibrariesSettings.vue | 156 ++++++++- .../__tests__/LibrariesSettings.spec.ts | 309 ++++++++++++++++-- .../__tests__/library-ledger-helpers.spec.ts | 34 ++ .../libraries/components/LibrariesToolbar.vue | 36 +- .../components/LibraryAutomationList.vue | 128 +++++++- .../components/LibraryCapabilityStrip.vue | 51 --- .../components/LibraryDetailPanel.vue | 100 +++++- .../libraries/components/LibraryIdentity.vue | 46 ++- .../components/LibraryLedgerCards.vue | 64 +++- .../components/LibraryLedgerList.vue | 118 ++++--- .../libraries/components/LibraryLedgerRow.vue | 167 ++++++++-- .../components/LibraryProblemStrip.vue | 61 ++++ .../components/LibraryRowActions.vue | 24 +- .../libraries/components/LibraryScanCell.vue | 127 ++++--- .../components/LibraryScanHistory.vue | 11 +- .../components/LibraryScanHistoryPopover.vue | 79 +++++ .../components/LibraryScanOutcome.vue | 69 ++++ .../libraries/composables/useLibraryDetail.ts | 4 +- .../libraries/lib/library-problems.ts | 15 + .../libraries/lib/library-schedule.ts | 34 ++ client/src/locales/en.json | 63 +++- 27 files changed, 1636 insertions(+), 265 deletions(-) create mode 100644 client/src/features/library/utils/__tests__/library-summary.spec.ts create mode 100644 client/src/features/library/utils/library-summary.ts delete mode 100644 client/src/features/settings/libraries/components/LibraryCapabilityStrip.vue create mode 100644 client/src/features/settings/libraries/components/LibraryProblemStrip.vue create mode 100644 client/src/features/settings/libraries/components/LibraryScanHistoryPopover.vue create mode 100644 client/src/features/settings/libraries/components/LibraryScanOutcome.vue create mode 100644 client/src/features/settings/libraries/lib/library-problems.ts create mode 100644 client/src/features/settings/libraries/lib/library-schedule.ts 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() {