mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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.
This commit is contained in:
@@ -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<LibraryCreatorSectionId, Component> = {
|
||||
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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<Library, (typeof WRITE_FAMILY_FLAGS)[number] | 'fileWriteAudioEnabled' | 'fileWriteWriteCover'>
|
||||
|
||||
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' },
|
||||
]
|
||||
@@ -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<Map<number, ScanProgressEvent>>(new Map())
|
||||
const coverRefreshMap = ref<Map<number, CoverRefreshProgressEvent>>(new Map())
|
||||
const subscribedLibraries = new Set<number>()
|
||||
@@ -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
|
||||
|
||||
@@ -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<LibrarySortField>('default')
|
||||
const scanningAll = ref(false)
|
||||
const creatorOpen = ref(false)
|
||||
const editingLibrary = ref<LibraryType | null>(null)
|
||||
const editingSection = ref<LibraryCreatorSectionId | undefined>(undefined)
|
||||
const templateLibrary = ref<LibraryType | null>(null)
|
||||
const savingAutomation = ref<Set<number>>(new Set())
|
||||
const justFinished = ref<Set<number>>(new Set())
|
||||
const deletingLibrary = ref<LibraryType | null>(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<number>()
|
||||
const finishTimers = new Map<number, ReturnType<typeof setTimeout>>()
|
||||
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<number>()
|
||||
@@ -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<boolean> {
|
||||
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<AutomationToggle, { on: string; off: string }> = {
|
||||
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() {
|
||||
|
||||
<template>
|
||||
<TooltipProvider>
|
||||
<div class="space-y-4">
|
||||
<!-- A container, so the ledger swaps to cards by the width it actually has, sidebar open or collapsed. -->
|
||||
<div class="@container space-y-4">
|
||||
<Teleport :to="HEADER_ACTIONS_TARGET" defer :disabled="!headerSlotAvailable">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -360,7 +488,7 @@ async function confirmDelete() {
|
||||
</p>
|
||||
|
||||
<div v-if="showSkeleton" class="space-y-2.5" aria-hidden="true">
|
||||
<Skeleton v-for="index in libraries.length" :key="index" class="h-44 w-full rounded-xl md:h-[7.6875rem]" />
|
||||
<Skeleton v-for="index in libraries.length" :key="index" class="h-44 w-full rounded-xl @min-[75rem]:h-[7.6875rem]" />
|
||||
</div>
|
||||
|
||||
<template v-else-if="visibleLibraries.length > 0">
|
||||
@@ -373,8 +501,10 @@ async function confirmDelete() {
|
||||
:is-scanning="isScanning"
|
||||
:is-refreshing-covers="isRefreshingCovers"
|
||||
:is-syncing-files="isSyncingFiles"
|
||||
:is-saving-automation="isSavingAutomation"
|
||||
:is-just-finished="isJustFinished"
|
||||
:history-for="historyFor"
|
||||
:access-count-for="accessCountFor"
|
||||
:access-for="accessFor"
|
||||
:is-detail-loading="isDetailLoading"
|
||||
:is-detail-failed="isDetailFailed"
|
||||
@toggle="toggleDetail"
|
||||
@@ -383,6 +513,9 @@ async function confirmDelete() {
|
||||
@refresh-covers="refreshCovers"
|
||||
@sync-files="promptSyncFiles"
|
||||
@remove="openDelete"
|
||||
@duplicate="openDuplicate"
|
||||
@toggle-setting="toggleSetting"
|
||||
@set-schedule="setSchedule"
|
||||
/>
|
||||
<LibraryLedgerCards
|
||||
:libraries="visibleLibraries"
|
||||
@@ -397,6 +530,7 @@ async function confirmDelete() {
|
||||
@refresh-covers="refreshCovers"
|
||||
@sync-files="promptSyncFiles"
|
||||
@remove="openDelete"
|
||||
@duplicate="openDuplicate"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -425,6 +559,8 @@ async function confirmDelete() {
|
||||
<LibraryCreatorModal
|
||||
v-if="creatorOpen"
|
||||
:library="editingLibrary"
|
||||
:initial-section="editingSection"
|
||||
:template="templateLibrary"
|
||||
@close="closeCreator"
|
||||
@saved="onSaved"
|
||||
@scan="scan"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import type { Library, LibraryLastScan, LibraryOverviewEntry, LibraryScanHistoryEntry } from '@bookorbit/types'
|
||||
import type { Library, LibraryAccessEntry, LibraryLastScan, LibraryOverviewEntry, LibraryScanHistoryEntry } from '@bookorbit/types'
|
||||
|
||||
// --- Mocks (must be before imports that use them) ---
|
||||
|
||||
@@ -31,6 +31,7 @@ vi.mock('@/features/scanner/composables/useScanProgress', () => ({
|
||||
isRefreshingCovers: vi.fn<() => boolean>().mockReturnValue(false),
|
||||
}),
|
||||
getSocket: vi.fn<() => void>(),
|
||||
claimScanCompletionToasts: vi.fn<() => () => void>(() => vi.fn<() => void>()),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/composables/usePermissions', () => ({
|
||||
@@ -42,11 +43,15 @@ vi.mock('@/lib/api', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('vue-sonner', () => ({
|
||||
toast: { success: vi.fn<() => void>(), error: vi.fn<() => void>() },
|
||||
toast: { success: toastSuccess, error: vi.fn<() => void>() },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/clipboard', () => ({
|
||||
copyToClipboard: (text: string) => copyMock(text),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/library/components/LibraryCreatorModal.vue', () => ({
|
||||
default: { name: 'LibraryCreatorModal', props: ['library'], template: '<div data-testid="library-creator" />' },
|
||||
default: { name: 'LibraryCreatorModal', props: ['library', 'initialSection', 'template'], template: '<div data-testid="library-creator" />' },
|
||||
}))
|
||||
|
||||
// --- Module-level mutable state ---
|
||||
@@ -58,8 +63,11 @@ const librariesRef = ref<Library[]>([])
|
||||
const progressRef = ref<Map<number, ScanProgressEvent>>(new Map())
|
||||
const overviewRef = ref<LibraryOverviewEntry[]>([])
|
||||
const historyRef = ref<LibraryScanHistoryEntry[]>([])
|
||||
const accessRef = ref<{ userId: number }[]>([])
|
||||
const accessRef = ref<LibraryAccessEntry[]>([])
|
||||
const apiMock = vi.fn<(...args: unknown[]) => Promise<{ ok: boolean; json: () => Promise<unknown> }>>()
|
||||
type ToastOptions = { action?: { label: string; onClick: () => void }; description?: string }
|
||||
const toastSuccess = vi.hoisted(() => vi.fn<(message: string, options?: ToastOptions) => void>())
|
||||
const copyMock = vi.hoisted(() => vi.fn<(text: string) => Promise<boolean>>())
|
||||
|
||||
// --- Factory helpers ---
|
||||
|
||||
@@ -129,11 +137,14 @@ function makeRouter() {
|
||||
{ path: '/', component: { template: '<div />' } },
|
||||
{ path: '/library/:id', name: 'library', component: { template: '<div />' } },
|
||||
{ path: '/settings/appearance/theme', name: 'settings-appearance-theme', component: { template: '<div />' } },
|
||||
{ path: '/tools/missing-resources', name: 'tools-missing-resources', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
import LibrariesSettings from '../LibrariesSettings.vue'
|
||||
import LibraryAutomationList from '../libraries/components/LibraryAutomationList.vue'
|
||||
import LibraryDetailPanel from '../libraries/components/LibraryDetailPanel.vue'
|
||||
import LibraryRowActions from '../libraries/components/LibraryRowActions.vue'
|
||||
|
||||
/** Every mount is tracked so a leftover component's watchers cannot react to a later test's state. */
|
||||
@@ -220,11 +231,26 @@ describe('LibrariesSettings ledger', () => {
|
||||
})
|
||||
|
||||
describe('identity cell', () => {
|
||||
it('shows the organization mode', async () => {
|
||||
it('names the organization the way the editor does', async () => {
|
||||
librariesRef.value = [makeLibrary({ organizationMode: 'book_per_folder' })]
|
||||
const wrapper = await mountLoaded()
|
||||
expect(tableText(wrapper)).toContain('Folder mode')
|
||||
expect(tableText(wrapper)).not.toContain('File mode')
|
||||
expect(tableText(wrapper)).toContain('Folder as Book')
|
||||
expect(tableText(wrapper)).not.toContain('File as Book')
|
||||
expect(tableText(wrapper)).not.toContain('Folder mode')
|
||||
})
|
||||
|
||||
it('cuts a long path from the start so the folder that names the library stays visible', async () => {
|
||||
librariesRef.value = [
|
||||
makeLibrary({
|
||||
folders: [{ id: 1, path: '/mnt/user/media/bookorbit/books/Novels', role: 'downloads' as const, createdAt: '2024-01-01T00:00:00.000Z' }],
|
||||
}),
|
||||
]
|
||||
const wrapper = await mountLoaded()
|
||||
const path = wrapper.get('[data-testid="libraries-ledger-list"] [data-testid="library-path"]')
|
||||
expect(path.classes()).toContain('library-path')
|
||||
expect(path.get('bdi').attributes('dir')).toBe('ltr')
|
||||
expect(path.get('bdi span').text()).toBe('Novels')
|
||||
expect(path.text()).toBe('/mnt/user/media/bookorbit/books/Novels')
|
||||
})
|
||||
|
||||
it('does not show both mode badges at the same time', async () => {
|
||||
@@ -252,11 +278,10 @@ describe('LibrariesSettings ledger', () => {
|
||||
})
|
||||
|
||||
describe('folders badge', () => {
|
||||
it('shows "1 folder" for a single folder', async () => {
|
||||
it('leaves the count out for a single folder, which the path line already shows', async () => {
|
||||
librariesRef.value = [makeLibrary({ folders: [{ id: 1, path: '/books', role: 'downloads' as const, createdAt: '2024-01-01T00:00:00.000Z' }] })]
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('1 folder')
|
||||
const wrapper = await mountLoaded()
|
||||
expect(tableText(wrapper)).not.toContain('1 folder')
|
||||
})
|
||||
|
||||
it('shows "3 folders" for three folders', async () => {
|
||||
@@ -304,19 +329,81 @@ describe('LibrariesSettings ledger', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('distinguishes on from off by more than the word, so the state is not colour-only', async () => {
|
||||
librariesRef.value = [makeLibrary({ watch: true, fileWriteEnabled: false })]
|
||||
it('makes every setting except the schedule a switch, so its state is not colour-only', async () => {
|
||||
librariesRef.value = [makeLibrary({ watch: true, fileWriteEnabled: false, fileRenameEnabled: true })]
|
||||
const wrapper = await mountLoaded()
|
||||
const [watchRow, , writeRow] = wrapper.get('[data-testid="libraries-ledger-list"]').findAll('li')
|
||||
const chip = (row: typeof watchRow) => row.findAll('span').find((span) => span.classes().includes('rounded-full'))!
|
||||
expect(chip(watchRow!).classes().join(' ')).toContain('bg-primary/12')
|
||||
expect(chip(writeRow!).classes().join(' ')).toContain('border-dashed')
|
||||
const list = wrapper.get('[data-testid="libraries-ledger-list"]')
|
||||
const switchFor = (key: string) => list.get(`[data-testid="automation-${key}"] [role="switch"]`)
|
||||
expect(switchFor('watch').attributes('aria-checked')).toBe('true')
|
||||
expect(switchFor('fileWrite').attributes('aria-checked')).toBe('false')
|
||||
expect(switchFor('fileRename').attributes('aria-checked')).toBe('true')
|
||||
expect(switchFor('watch').attributes('aria-label')).toBe('Watch folders for Test Library')
|
||||
})
|
||||
|
||||
it('shows the real schedule instead of a bare "on"', async () => {
|
||||
it('names a schedule the way the editor does, with the full description on hover', async () => {
|
||||
librariesRef.value = [makeLibrary({ autoScanCronExpression: '0 2 * * *' })]
|
||||
const wrapper = await mountLoaded()
|
||||
expect(tableText(wrapper)).toContain('02:00 AM')
|
||||
const trigger = wrapper.get('[data-testid="libraries-ledger-list"] [data-testid="schedule-trigger"]')
|
||||
expect(trigger.text()).toBe('Daily · 2:00 AM')
|
||||
expect(trigger.attributes('title')).toContain('02:00 AM')
|
||||
})
|
||||
|
||||
it('names a weekly schedule with its day and time', async () => {
|
||||
librariesRef.value = [makeLibrary({ autoScanCronExpression: '0 0 * * 1' })]
|
||||
const wrapper = await mountLoaded()
|
||||
expect(wrapper.get('[data-testid="libraries-ledger-list"] [data-testid="schedule-trigger"]').text()).toBe('Weekly · Mon 12:00 AM')
|
||||
})
|
||||
|
||||
it('keeps the full description for a schedule the presets cannot name', async () => {
|
||||
librariesRef.value = [makeLibrary({ autoScanCronExpression: '15 3 1 * *' })]
|
||||
const wrapper = await mountLoaded()
|
||||
expect(wrapper.get('[data-testid="libraries-ledger-list"] [data-testid="schedule-trigger"]').text()).toContain('03:15 AM')
|
||||
})
|
||||
|
||||
it('saves a switch straight from the row and offers to undo it', async () => {
|
||||
librariesRef.value = [makeLibrary({ id: 3, name: 'Comics', watch: false })]
|
||||
const wrapper = await mountLoaded()
|
||||
await wrapper.get('[data-testid="libraries-ledger-list"] [data-testid="automation-watch"] [role="switch"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const patch = () => apiMock.mock.calls.filter((call) => (call[1] as RequestInit | undefined)?.method === 'PATCH')
|
||||
expect(patch()).toHaveLength(1)
|
||||
expect(patch()[0]![0]).toBe('/api/v1/libraries/3')
|
||||
expect(JSON.parse(String((patch()[0]![1] as RequestInit).body))).toEqual({ watch: true })
|
||||
|
||||
const [message, options] = toastSuccess.mock.calls.at(-1)!
|
||||
expect(message).toBe('Watching folders for "Comics"')
|
||||
expect(options?.action?.label).toBe('Undo')
|
||||
options!.action!.onClick()
|
||||
await flushPromises()
|
||||
expect(JSON.parse(String((patch()[1]![1] as RequestInit).body))).toEqual({ watch: false })
|
||||
})
|
||||
|
||||
it('sends only the setting that changed, so nothing else about the library is touched', async () => {
|
||||
librariesRef.value = [makeLibrary({ id: 3, fileRenameEnabled: false })]
|
||||
const wrapper = await mountLoaded()
|
||||
await wrapper.get('[data-testid="libraries-ledger-list"] [data-testid="automation-fileRename"] [role="switch"]').trigger('click')
|
||||
await flushPromises()
|
||||
const call = apiMock.mock.calls.find((c) => (c[1] as RequestInit | undefined)?.method === 'PATCH')!
|
||||
expect(JSON.parse(String((call[1] as RequestInit).body))).toEqual({ fileRenameEnabled: true })
|
||||
})
|
||||
|
||||
it('saves a schedule preset picked from the row', async () => {
|
||||
librariesRef.value = [makeLibrary({ id: 3, name: 'Comics', autoScanCronExpression: null })]
|
||||
const wrapper = await mountLoaded()
|
||||
wrapper.findComponent(LibraryAutomationList).vm.$emit('setSchedule', librariesRef.value[0]!, '0 0 * * 1')
|
||||
await flushPromises()
|
||||
const call = apiMock.mock.calls.find((c) => (c[1] as RequestInit | undefined)?.method === 'PATCH')!
|
||||
expect(JSON.parse(String((call[1] as RequestInit).body))).toEqual({ autoScanCronExpression: '0 0 * * 1' })
|
||||
expect(toastSuccess.mock.calls.at(-1)?.[0]).toBe('Scheduled scan for "Comics": Weekly · Mon 12:00 AM')
|
||||
})
|
||||
|
||||
it('opens the editor on Automation for a custom schedule', async () => {
|
||||
librariesRef.value = [makeLibrary({ id: 3 })]
|
||||
const wrapper = await mountLoaded()
|
||||
wrapper.findComponent(LibraryAutomationList).vm.$emit('editSchedule', librariesRef.value[0]!)
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'LibraryCreatorModal' }).props('initialSection')).toBe('schedule')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -360,6 +447,71 @@ describe('LibrariesSettings ledger', () => {
|
||||
expect(tableText(wrapper)).toContain('+3 added')
|
||||
})
|
||||
|
||||
it('reads the trigger as a description and colours what was added and what went missing', async () => {
|
||||
librariesRef.value = [makeLibrary({ id: 4 })]
|
||||
overviewRef.value = [makeEntry({ libraryId: 4, lastScan: makeScan({ triggeredBy: 'schedule', addedCount: 2, missingCount: 11 }) })]
|
||||
const wrapper = await mountLoaded()
|
||||
const status = wrapper.get('[data-testid="libraries-ledger-list"] [data-testid="scan-status"]')
|
||||
expect(status.text()).toContain('Scheduled · +2 added, 11 missing')
|
||||
expect(
|
||||
status
|
||||
.findAll('span')
|
||||
.find((span) => span.text() === '+2 added')!
|
||||
.classes(),
|
||||
).toContain('text-[var(--pill-success)]')
|
||||
expect(
|
||||
status
|
||||
.findAll('span')
|
||||
.find((span) => span.text() === '11 missing')!
|
||||
.classes(),
|
||||
).toContain('text-[var(--pill-warning)]')
|
||||
})
|
||||
|
||||
it('opens the last ten scans from the last-scan line', async () => {
|
||||
librariesRef.value = [makeLibrary({ id: 4 })]
|
||||
overviewRef.value = [makeEntry({ libraryId: 4, lastScan: makeScan() })]
|
||||
// The popover portals to the body, so this test opts into the real Teleport.
|
||||
const wrapper = await mountLoaded({ realTeleport: true })
|
||||
await wrapper.get('[data-testid="libraries-ledger-list"] [data-testid="scan-status"]').trigger('click')
|
||||
await flushPromises()
|
||||
const call = apiMock.mock.calls.find((c) => String(c[0]).includes('scan-history'))
|
||||
expect(String(call?.[0])).toBe('/api/v1/scanner/libraries/4/scan-history?limit=10')
|
||||
})
|
||||
|
||||
it('shows what a running scan has found so far', async () => {
|
||||
librariesRef.value = [makeLibrary({ id: 4 })]
|
||||
overviewRef.value = [makeEntry({ libraryId: 4, lastScan: makeScan() })]
|
||||
progressRef.value = new Map([[4, { jobId: 1, libraryId: 4, status: 'running', processed: 40, total: 100, added: 3, updated: 2, missing: 0 }]])
|
||||
const wrapper = await mountLoaded()
|
||||
expect(wrapper.get('[data-testid="libraries-ledger-list"] [data-testid="scan-live"]').text()).toContain('+3 added, 2 updated')
|
||||
})
|
||||
|
||||
it('offers the one repair a failed or never-scanned library needs, inside its row', async () => {
|
||||
librariesRef.value = [makeLibrary({ id: 4, name: 'Novels' }), makeLibrary({ id: 5, name: 'Manga' })]
|
||||
overviewRef.value = [
|
||||
makeEntry({ libraryId: 4, lastScan: makeScan({ status: 'failed', errorMessage: 'Server restarted during scan' }) }),
|
||||
makeEntry({ libraryId: 5 }),
|
||||
]
|
||||
const wrapper = await mountLoaded()
|
||||
const strips = wrapper.get('[data-testid="libraries-ledger-list"]').findAll('[data-testid="library-problem"]')
|
||||
expect(strips).toHaveLength(2)
|
||||
expect(strips[0]!.text()).toContain('The last scan failed')
|
||||
expect(strips[0]!.text()).toContain('Server restarted during scan')
|
||||
expect(strips[0]!.text()).toContain('Retry scan')
|
||||
expect(strips[1]!.text()).toContain('Never scanned.')
|
||||
|
||||
await strips[1]!.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(apiMock.mock.calls.some((c) => String(c[0]) === '/api/v1/scanner/libraries/5/scan')).toBe(true)
|
||||
})
|
||||
|
||||
it('adds no strip for a scan that only found missing books', async () => {
|
||||
librariesRef.value = [makeLibrary({ id: 4 })]
|
||||
overviewRef.value = [makeEntry({ libraryId: 4, lastScan: makeScan({ missingCount: 11 }) })]
|
||||
const wrapper = await mountLoaded()
|
||||
expect(wrapper.get('[data-testid="libraries-ledger-list"]').find('[data-testid="library-problem"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('says "no change" rather than going blank when a scan found nothing', async () => {
|
||||
librariesRef.value = [makeLibrary({ id: 4 })]
|
||||
overviewRef.value = [makeEntry({ libraryId: 4, lastScan: makeScan({ triggeredBy: 'schedule' }) })]
|
||||
@@ -517,7 +669,10 @@ describe('LibrariesSettings ledger', () => {
|
||||
markAsFinishedPercentComplete: 98,
|
||||
}),
|
||||
]
|
||||
accessRef.value = [{ userId: 1 }, { userId: 2 }]
|
||||
accessRef.value = [
|
||||
{ userId: 1, username: 'maya', name: 'Maya Ortiz', accessLevel: 'viewer' },
|
||||
{ userId: 2, username: 'sam', name: 'Sam Whitfield', accessLevel: 'viewer' },
|
||||
]
|
||||
const wrapper = await mountLoaded()
|
||||
await expandFirst(wrapper)
|
||||
const panel = wrapper.get('[id="library-detail-4"]').text()
|
||||
@@ -526,7 +681,8 @@ describe('LibrariesSettings ledger', () => {
|
||||
expect(panel).toContain('1 pattern')
|
||||
expect(panel).toContain('0.25%')
|
||||
expect(panel).toContain('98%')
|
||||
expect(panel).toContain('2 people')
|
||||
expect(panel).toContain('Maya Ortiz')
|
||||
expect(panel).toContain('Sam Whitfield')
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -592,6 +748,88 @@ describe('LibrariesSettings ledger', () => {
|
||||
expect(wrapper.get('[id="library-detail-4"]').text()).toContain('Could not load the scan history')
|
||||
})
|
||||
|
||||
it('opens the editor at the section beside each Edit button', async () => {
|
||||
const wrapper = await mountLoaded()
|
||||
await expandFirst(wrapper)
|
||||
const panel = wrapper.findComponent(LibraryDetailPanel)
|
||||
const editor = () => wrapper.findComponent({ name: 'LibraryCreatorModal' })
|
||||
|
||||
panel.vm.$emit('edit', librariesRef.value[0]!, 'folders')
|
||||
await flushPromises()
|
||||
expect(editor().props('initialSection')).toBe('folders')
|
||||
expect(editor().props('library')).toEqual(librariesRef.value[0])
|
||||
})
|
||||
|
||||
it('shows the whole path and copies it', async () => {
|
||||
librariesRef.value = [
|
||||
makeLibrary({
|
||||
id: 4,
|
||||
folders: [{ id: 9, path: '/mnt/user/media/bookorbit/books/Novels', role: 'downloads' as const, createdAt: '2024-01-01T00:00:00.000Z' }],
|
||||
}),
|
||||
]
|
||||
copyMock.mockResolvedValue(true)
|
||||
const wrapper = await mountLoaded()
|
||||
await expandFirst(wrapper)
|
||||
const panel = wrapper.get('[id="library-detail-4"]')
|
||||
expect(panel.get('[data-testid="library-full-path"]').classes()).toContain('break-all')
|
||||
await panel.get('button[aria-label="Copy path"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(copyMock).toHaveBeenCalledWith('/mnt/user/media/bookorbit/books/Novels')
|
||||
expect(toastSuccess).toHaveBeenCalledWith('Path copied')
|
||||
})
|
||||
|
||||
it('lists the first people with access and counts the rest', async () => {
|
||||
accessRef.value = ['Ana Beck', 'Jules Marchand', 'Maya Ortiz', 'Priya Raman', 'Sam Whitfield'].map((name, index) => ({
|
||||
userId: index + 1,
|
||||
username: name.toLowerCase(),
|
||||
name,
|
||||
accessLevel: 'viewer' as const,
|
||||
}))
|
||||
const wrapper = await mountLoaded()
|
||||
await expandFirst(wrapper)
|
||||
const people = wrapper.get('[data-testid="library-access-people"]').text()
|
||||
expect(people).toContain('Ana Beck')
|
||||
expect(people).toContain('Maya Ortiz')
|
||||
expect(people).not.toContain('Priya Raman')
|
||||
expect(people).toContain('+2 more')
|
||||
})
|
||||
|
||||
it('keeps completed scans plain so a failure stands out', async () => {
|
||||
historyRef.value = [
|
||||
{ id: 1, ...makeScan() },
|
||||
{ id: 2, ...makeScan({ status: 'failed', errorMessage: 'ENOENT: /books' }) },
|
||||
]
|
||||
const wrapper = await mountLoaded()
|
||||
await expandFirst(wrapper)
|
||||
const statusCells = wrapper.get('[id="library-detail-4"] table').findAll('tbody tr td:last-child span')
|
||||
expect(statusCells[0]!.classes()).not.toContain('rounded-full')
|
||||
expect(statusCells[1]!.classes()).toContain('rounded-full')
|
||||
})
|
||||
|
||||
it('opens the details from a click anywhere on the row, but not from its controls', async () => {
|
||||
const wrapper = await mountLoaded()
|
||||
await wrapper.get('[data-testid="library-row"] .grid').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[id="library-detail-4"]').exists()).toBe(true)
|
||||
|
||||
await wrapper.get('[data-testid="library-row"] a').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[id="library-detail-4"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('opens on a click on the chevron icon even when the icon is replaced mid-click', async () => {
|
||||
const wrapper = await mountLoaded()
|
||||
const row = wrapper.get('[data-testid="library-row"]').element
|
||||
const icon = wrapper.get('[data-testid="library-row-toggle"] svg').element
|
||||
// A browser can re-render between one click's listeners; detaching the icon before the row's own
|
||||
// handler runs reproduces that, and the row must still see that the click came from the button.
|
||||
const detach = () => icon.remove()
|
||||
row.addEventListener('click', detach, { capture: true, once: true })
|
||||
icon.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[id="library-detail-4"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('opens one row at a time and closes on a second click', async () => {
|
||||
librariesRef.value = [makeLibrary({ id: 4, name: 'Novels' }), makeLibrary({ id: 5, name: 'Comics' })]
|
||||
overviewRef.value = [makeEntry({ libraryId: 4 }), makeEntry({ libraryId: 5 })]
|
||||
@@ -672,6 +910,31 @@ describe('LibrariesSettings ledger', () => {
|
||||
headerTarget.remove()
|
||||
})
|
||||
|
||||
it("opens a new library seeded from an existing one's settings", async () => {
|
||||
librariesRef.value = [makeLibrary({ id: 5, name: 'Novels' })]
|
||||
const wrapper = await mountLoaded()
|
||||
wrapper.findComponent(LibraryRowActions).vm.$emit('duplicate', librariesRef.value[0]!)
|
||||
await flushPromises()
|
||||
const editor = wrapper.findComponent({ name: 'LibraryCreatorModal' })
|
||||
expect(editor.props('template')).toEqual(librariesRef.value[0])
|
||||
expect(editor.props('library')).toBeNull()
|
||||
})
|
||||
|
||||
it('announces a finished scan with its outcome and a way to see it', async () => {
|
||||
librariesRef.value = [makeLibrary({ id: 4, name: 'Novels' })]
|
||||
overviewRef.value = [makeEntry({ libraryId: 4, lastScan: makeScan() })]
|
||||
const wrapper = await mountLoaded()
|
||||
progressRef.value = new Map([[4, { jobId: 81, libraryId: 4, status: 'completed', processed: 5, total: 5, added: 3, updated: 1, missing: 0 }]])
|
||||
await flushPromises()
|
||||
|
||||
const [message, options] = toastSuccess.mock.calls.at(-1)!
|
||||
expect(message).toBe('Scan finished for "Novels": +3 added, 1 updated')
|
||||
expect(options?.action?.label).toBe('View changes')
|
||||
options!.action!.onClick()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[id="library-detail-4"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('puts the page actions in the settings header slot when one exists', async () => {
|
||||
const headerTarget = document.createElement('div')
|
||||
headerTarget.id = 'settings-header-actions'
|
||||
@@ -679,8 +942,8 @@ describe('LibrariesSettings ledger', () => {
|
||||
librariesRef.value = [makeLibrary()]
|
||||
const wrapper = await mountLoaded({ realTeleport: true })
|
||||
|
||||
expect(headerTarget.textContent).toContain('Add Library')
|
||||
expect(headerTarget.textContent).toContain('Scan All')
|
||||
expect(headerTarget.textContent).toContain('Add library')
|
||||
expect(headerTarget.textContent).toContain('Scan all')
|
||||
|
||||
wrapper.unmount()
|
||||
headerTarget.remove()
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Library, LibraryOverviewEntry } from '@bookorbit/types'
|
||||
|
||||
import { formatFamily, toFormatSegments } from '../lib/library-formats'
|
||||
import { shortenPath } from '../lib/library-paths'
|
||||
import { libraryProblem } from '../lib/library-problems'
|
||||
import { isLibrarySortField, matchesLibraryQuery, sortLibraries } from '../lib/library-sort'
|
||||
|
||||
function library(overrides: Partial<Library> & Pick<Library, 'id' | 'name'>): Library {
|
||||
@@ -184,3 +185,36 @@ describe('isLibrarySortField', () => {
|
||||
expect(isLibrarySortField('nonsense')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('libraryProblem', () => {
|
||||
const scan = (overrides: Partial<NonNullable<LibraryOverviewEntry['lastScan']>> = {}) => ({
|
||||
status: 'completed' as const,
|
||||
triggeredBy: 'schedule' as const,
|
||||
startedAt: '2026-09-28T06:00:00.000Z',
|
||||
completedAt: '2026-09-28T06:00:01.000Z',
|
||||
addedCount: 0,
|
||||
updatedCount: 0,
|
||||
missingCount: 0,
|
||||
errorMessage: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
it('flags a library that has never been scanned', () => {
|
||||
expect(libraryProblem(null, false)).toEqual({ kind: 'never' })
|
||||
})
|
||||
|
||||
it('flags a failed scan with the scan it came from', () => {
|
||||
const failed = scan({ status: 'failed', errorMessage: 'Server restarted during scan' })
|
||||
expect(libraryProblem(failed, false)).toEqual({ kind: 'failed', scan: failed })
|
||||
})
|
||||
|
||||
it('has nothing to say about a completed scan, missing books included', () => {
|
||||
expect(libraryProblem(scan(), false)).toBeNull()
|
||||
expect(libraryProblem(scan({ missingCount: 11 }), false)).toBeNull()
|
||||
})
|
||||
|
||||
it('stays quiet while a scan is running, since it is about to replace the last one', () => {
|
||||
expect(libraryProblem(null, true)).toBeNull()
|
||||
expect(libraryProblem(scan({ status: 'failed' }), true)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Search, X } from '@lucide/vue'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
import { isLibrarySortField, LIBRARY_SORT_FIELDS, type LibrarySortField } from '../lib/library-sort'
|
||||
|
||||
@@ -19,7 +20,13 @@ const emit = defineEmits<{ 'update:query': [value: string]; 'update:sortBy': [va
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const sortOptions = computed(() => LIBRARY_SORT_FIELDS.map((value) => ({ value, label: t(`settings.admin.libraries.sort.${value}`) })))
|
||||
/** The default order is the sidebar's display order, and the label says so. */
|
||||
const sortOptions = computed(() =>
|
||||
LIBRARY_SORT_FIELDS.map((value) => ({
|
||||
value,
|
||||
label: t(value === 'default' ? 'settings.admin.libraries.sort.sidebar' : `settings.admin.libraries.sort.${value}`),
|
||||
})),
|
||||
)
|
||||
|
||||
function handleQueryInput(event: Event) {
|
||||
emit('update:query', (event.target as HTMLInputElement).value)
|
||||
@@ -65,15 +72,32 @@ function handleSortChange(event: Event) {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<p class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground lg:ms-auto lg:justify-end">
|
||||
<span>{{ t('settings.admin.libraries.totalLibraries', { count: libraryCount }) }}</span>
|
||||
<!-- The figures carry the weight; the units stay quiet beside them. -->
|
||||
<p
|
||||
data-testid="libraries-totals"
|
||||
class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-[13px] text-muted-foreground lg:ms-auto lg:justify-end"
|
||||
>
|
||||
<span
|
||||
><span class="font-semibold tabular-nums text-foreground">{{ formatNumber(libraryCount) }}</span>
|
||||
{{ t('settings.admin.libraries.librariesUnit', { count: libraryCount }) }}</span
|
||||
>
|
||||
<span class="opacity-50" aria-hidden="true">·</span>
|
||||
<span>{{ t('settings.admin.libraries.folderCount', { count: folderCount }) }}</span>
|
||||
<span
|
||||
><span class="font-semibold tabular-nums text-foreground">{{ formatNumber(folderCount) }}</span>
|
||||
{{ t('settings.admin.libraries.foldersUnit', { count: folderCount }) }}</span
|
||||
>
|
||||
<template v-if="!statsPending">
|
||||
<span class="opacity-50" aria-hidden="true">·</span>
|
||||
<span>{{ t('settings.admin.libraries.bookCount', { count: totalBooks }) }}</span>
|
||||
<span
|
||||
><span class="font-semibold tabular-nums text-foreground">{{ formatNumber(totalBooks) }}</span>
|
||||
{{ t('settings.admin.libraries.booksUnit', { count: totalBooks }) }}</span
|
||||
>
|
||||
<span class="opacity-50" aria-hidden="true">·</span>
|
||||
<span>{{ t('settings.admin.libraries.onDiskUnit', { size: formatBytes(totalSizeBytes) }) }}</span>
|
||||
<i18n-t keypath="settings.admin.libraries.onDiskUnit" tag="span" scope="global">
|
||||
<template #size
|
||||
><span class="font-semibold tabular-nums text-foreground">{{ formatBytes(totalSizeBytes) }}</span></template
|
||||
>
|
||||
</i18n-t>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CalendarClock, Eye, FileEdit, Pencil } from '@lucide/vue'
|
||||
import { CalendarClock, Check, ChevronDown, Eye, FileEdit, Pencil } from '@lucide/vue'
|
||||
import type { Library } from '@bookorbit/types'
|
||||
import { parseCronToHuman } from '@/features/library/utils/cron'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { SCHEDULE_PRESET_CRONS } from '@/features/library/utils/library-summary'
|
||||
import { formatSchedule } from '../lib/library-schedule'
|
||||
|
||||
const props = defineProps<{ library: Library }>()
|
||||
const props = withDefaults(defineProps<{ library: Library; interactive?: boolean; saving?: boolean }>(), { interactive: false, saving: false })
|
||||
|
||||
export type AutomationToggle = 'watch' | 'fileWriteEnabled' | 'fileRenameEnabled'
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [library: Library, setting: AutomationToggle]
|
||||
setSchedule: [library: Library, cron: string | null]
|
||||
editSchedule: [library: Library]
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const schedule = computed(() => formatSchedule(props.library.autoScanCronExpression, t, locale.value))
|
||||
const scheduleValue = computed(() => schedule.value?.label ?? t('settings.admin.libraries.stateOff'))
|
||||
|
||||
/**
|
||||
* Every setting renders in both states with its real value, so a schedule reads as its actual
|
||||
* cron description rather than an icon, and "off" is stated rather than left out.
|
||||
* Every setting keeps its row, so the four read the same way in every library. Only "on" gets a pill:
|
||||
* a column of dashed "Off" boxes was most of the ink on the page while saying nothing was happening.
|
||||
*/
|
||||
const rows = computed(() => {
|
||||
const schedule = parseCronToHuman(props.library.autoScanCronExpression, locale.value)
|
||||
const off = t('settings.admin.libraries.stateOff')
|
||||
const on = t('settings.admin.libraries.stateOn')
|
||||
const off = t('settings.admin.libraries.stateOff')
|
||||
return [
|
||||
{
|
||||
key: 'watch',
|
||||
setting: 'watch' as AutomationToggle,
|
||||
icon: Eye,
|
||||
on: props.library.watch,
|
||||
label: t('settings.admin.libraries.capability.watch'),
|
||||
value: props.library.watch ? on : off,
|
||||
title: '',
|
||||
aria: t('settings.admin.libraries.quick.watchAria', { name: props.library.name }),
|
||||
},
|
||||
{
|
||||
key: 'schedule',
|
||||
icon: CalendarClock,
|
||||
on: Boolean(schedule.value),
|
||||
label: t('settings.admin.libraries.capability.schedule'),
|
||||
value: scheduleValue.value,
|
||||
title: schedule.value?.title ?? '',
|
||||
setting: null,
|
||||
aria: '',
|
||||
},
|
||||
{ key: 'schedule', icon: CalendarClock, on: Boolean(schedule), label: t('settings.admin.libraries.capability.schedule'), value: schedule ?? off },
|
||||
{
|
||||
key: 'fileWrite',
|
||||
icon: FileEdit,
|
||||
on: props.library.fileWriteEnabled,
|
||||
label: t('settings.admin.libraries.capability.fileWrite'),
|
||||
value: props.library.fileWriteEnabled ? on : off,
|
||||
title: '',
|
||||
setting: 'fileWriteEnabled' as AutomationToggle,
|
||||
aria: t('settings.admin.libraries.quick.fileWriteAria', { name: props.library.name }),
|
||||
},
|
||||
{
|
||||
key: 'fileRename',
|
||||
@@ -39,25 +74,90 @@ const rows = computed(() => {
|
||||
on: props.library.fileRenameEnabled,
|
||||
label: t('settings.admin.libraries.capability.fileRename'),
|
||||
value: props.library.fileRenameEnabled ? on : off,
|
||||
title: '',
|
||||
setting: 'fileRenameEnabled' as AutomationToggle,
|
||||
aria: t('settings.admin.libraries.quick.renameAria', { name: props.library.name }),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const presetOptions = computed(() => [
|
||||
{ cron: null, label: t('settings.admin.libraries.stateOff') },
|
||||
...SCHEDULE_PRESET_CRONS.map((preset) => ({ cron: preset.cron, label: t(`library.creator.schedule.presets.${preset.preset}`) })),
|
||||
])
|
||||
|
||||
function requestToggle(setting: AutomationToggle) {
|
||||
emit('toggle', props.library, setting)
|
||||
}
|
||||
|
||||
function pickSchedule(cron: string | null) {
|
||||
if (cron === (props.library.autoScanCronExpression ?? null)) return
|
||||
emit('setSchedule', props.library, cron)
|
||||
}
|
||||
|
||||
function requestCustomSchedule() {
|
||||
emit('editSchedule', props.library)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="flex flex-col gap-1.5">
|
||||
<li v-for="row in rows" :key="row.key" class="flex items-center gap-2.5 text-[12.5px]">
|
||||
<li v-for="row in rows" :key="row.key" :data-testid="`automation-${row.key}`" class="flex min-h-5 items-center gap-2.5 text-[12.5px]">
|
||||
<component :is="row.icon" :size="13" class="shrink-0" :class="row.on ? 'text-primary' : 'text-muted-foreground'" aria-hidden="true" />
|
||||
<span class="whitespace-nowrap" :class="row.on ? 'text-foreground' : 'text-muted-foreground'">{{ row.label }}</span>
|
||||
<!-- On and off differ by fill and border style as well as wording, so the state is readable
|
||||
without relying on colour or on reading the value. -->
|
||||
|
||||
<!-- Every setting changes in place. Writing and renaming only act on the next save of a book's details,
|
||||
so switching them on touches no file by itself; the bulk rewrite stays behind "Sync metadata to files". -->
|
||||
<span v-if="interactive && row.setting" class="ms-auto flex items-center gap-2">
|
||||
<span class="text-[11px] font-medium" :class="row.on ? 'text-primary' : 'text-muted-foreground'" aria-hidden="true">{{ row.value }}</span>
|
||||
<ToggleSwitch :model-value="row.on" :disabled="saving" :aria-label="row.aria" @update:model-value="requestToggle(row.setting)" />
|
||||
</span>
|
||||
<DropdownMenu v-else-if="interactive && row.key === 'schedule'">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="schedule-trigger"
|
||||
class="ms-auto inline-flex min-w-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
||||
:class="
|
||||
row.on
|
||||
? 'border-primary/35 bg-primary/12 text-primary hover:border-primary/60'
|
||||
: 'border-border text-muted-foreground hover:border-primary/45 hover:text-foreground'
|
||||
"
|
||||
:title="row.title || undefined"
|
||||
:disabled="saving"
|
||||
:aria-label="t('settings.admin.libraries.quick.scheduleAria', { name: library.name, value: row.value })"
|
||||
>
|
||||
<span class="truncate">{{ row.value }}</span>
|
||||
<ChevronDown :size="12" class="shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-56">
|
||||
<DropdownMenuLabel class="text-xs font-normal text-muted-foreground">{{ row.label }}</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
v-for="option in presetOptions"
|
||||
:key="option.cron ?? 'off'"
|
||||
:data-testid="`schedule-option-${option.cron ?? 'off'}`"
|
||||
@click="pickSchedule(option.cron)"
|
||||
>
|
||||
<Check :size="14" :class="option.cron === (library.autoScanCronExpression ?? null) ? 'opacity-100' : 'opacity-0'" aria-hidden="true" />
|
||||
{{ option.label }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="requestCustomSchedule">
|
||||
<CalendarClock :size="14" aria-hidden="true" />
|
||||
{{ t('settings.admin.libraries.quick.customSchedule') }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span
|
||||
class="ms-auto inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium"
|
||||
:class="row.on ? 'border-primary/35 bg-primary/12 text-primary' : 'border-dashed border-border text-muted-foreground'"
|
||||
v-else-if="row.on"
|
||||
class="ms-auto inline-flex min-w-0 items-center gap-1.5 rounded-full border border-primary/35 bg-primary/12 px-2 py-0.5 text-[11px] font-medium text-primary"
|
||||
:title="row.title || undefined"
|
||||
>
|
||||
<span class="size-1.5 shrink-0 rounded-full" :class="row.on ? 'bg-primary' : 'border border-muted-foreground'" aria-hidden="true" />
|
||||
<span class="size-1.5 shrink-0 rounded-full bg-primary" aria-hidden="true" />
|
||||
<span class="truncate">{{ row.value }}</span>
|
||||
</span>
|
||||
<span v-else class="ms-auto px-1 text-[11.5px] text-muted-foreground">{{ row.value }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CalendarClock, Eye, FileEdit, Pencil } from '@lucide/vue'
|
||||
import type { Library } from '@bookorbit/types'
|
||||
import { parseCronToHuman } from '@/features/library/utils/cron'
|
||||
|
||||
const props = defineProps<{ library: Library }>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
/**
|
||||
* Every slot renders in both states. An omitted chip and a disabled setting used to look the same,
|
||||
* which made a library's configuration unreadable from the list.
|
||||
*/
|
||||
const slots = computed(() => {
|
||||
const schedule = parseCronToHuman(props.library.autoScanCronExpression, locale.value)
|
||||
return [
|
||||
{ key: 'watch', icon: Eye, on: props.library.watch, label: t('settings.admin.libraries.capability.watch'), detail: null },
|
||||
{ key: 'schedule', icon: CalendarClock, on: Boolean(schedule), label: t('settings.admin.libraries.capability.schedule'), detail: schedule },
|
||||
{ key: 'fileWrite', icon: FileEdit, on: props.library.fileWriteEnabled, label: t('settings.admin.libraries.capability.fileWrite'), detail: null },
|
||||
{
|
||||
key: 'fileRename',
|
||||
icon: Pencil,
|
||||
on: props.library.fileRenameEnabled,
|
||||
label: t('settings.admin.libraries.capability.fileRename'),
|
||||
detail: null,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
function slotTitle(slot: { label: string; on: boolean; detail: string | null }): string {
|
||||
if (!slot.on) return t('settings.admin.libraries.capability.off', { name: slot.label })
|
||||
return slot.detail ? `${slot.label}: ${slot.detail}` : t('settings.admin.libraries.capability.on', { name: slot.label })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="flex items-center gap-1">
|
||||
<li
|
||||
v-for="slot in slots"
|
||||
:key="slot.key"
|
||||
:title="slotTitle(slot)"
|
||||
class="flex size-6 items-center justify-center rounded-md border"
|
||||
:class="slot.on ? 'border-border bg-foreground/6 text-foreground' : 'border-dashed border-border/70 text-muted-foreground opacity-45'"
|
||||
>
|
||||
<component :is="slot.icon" :size="12" aria-hidden="true" />
|
||||
<span class="sr-only">{{ slotTitle(slot) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -1,29 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Clock, FolderOpen, History, Pencil, Tags, Users } from '@lucide/vue'
|
||||
import type { Library, LibraryScanHistoryEntry } from '@bookorbit/types'
|
||||
import { Check, Clock, Copy, FolderOpen, History, Pencil, Tags, Users } from '@lucide/vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { Library, LibraryAccessEntry, LibraryScanHistoryEntry } from '@bookorbit/types'
|
||||
import { formatKeyName } from '@/features/book/lib/book-formats'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { formatDate, formatList, formatNumber } from '@/i18n/formatters'
|
||||
import { METADATA_LABELS } from '@/features/library/composables/useLibraryCreator'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { METADATA_LABELS, type LibraryCreatorSectionId } from '@/features/library/composables/useLibraryCreator'
|
||||
import { copyToClipboard } from '@/lib/clipboard'
|
||||
import LibraryScanHistory from './LibraryScanHistory.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
library: Library
|
||||
history: LibraryScanHistoryEntry[] | null
|
||||
accessCount: number | null
|
||||
access: LibraryAccessEntry[] | null
|
||||
loading: boolean
|
||||
failed: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ edit: [library: Library] }>()
|
||||
const emit = defineEmits<{ edit: [library: Library, section: LibraryCreatorSectionId] }>()
|
||||
|
||||
const PEOPLE_SHOWN = 3
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const organizationLabel = computed(() =>
|
||||
props.library.organizationMode === 'book_per_file' ? t('settings.admin.libraries.fileMode') : t('settings.admin.libraries.folderMode'),
|
||||
props.library.organizationMode === 'book_per_file'
|
||||
? t('library.creator.scanner.scanMode.fileAsBook.title')
|
||||
: t('library.creator.scanner.scanMode.folderAsBook.title'),
|
||||
)
|
||||
const excludeLabel = computed(() =>
|
||||
props.library.excludePatterns.length === 0
|
||||
@@ -40,14 +47,37 @@ const formatsLabel = computed(() =>
|
||||
? t('settings.admin.libraries.detail.allSupported')
|
||||
: formatList(props.library.allowedFormats.map((format) => formatKeyName(format))),
|
||||
)
|
||||
const accessLabel = computed(() => (props.accessCount === null ? '' : t('settings.admin.libraries.detail.peopleCount', { count: props.accessCount })))
|
||||
/** The first few names answer "who can see this" without opening the editor; the rest are counted. */
|
||||
const shownPeople = computed(() => (props.access ?? []).slice(0, PEOPLE_SHOWN))
|
||||
const hiddenPeople = computed(() => Math.max(0, (props.access?.length ?? 0) - PEOPLE_SHOWN))
|
||||
const copiedPath = ref<number | null>(null)
|
||||
const readingThresholdLabel = computed(() => formatNumber(props.library.readingThreshold / 100, { style: 'percent', maximumFractionDigits: 2 }))
|
||||
const finishedThresholdLabel = computed(() =>
|
||||
formatNumber(props.library.markAsFinishedPercentComplete / 100, { style: 'percent', maximumFractionDigits: 2 }),
|
||||
)
|
||||
|
||||
function requestEdit() {
|
||||
emit('edit', props.library)
|
||||
function editFolders() {
|
||||
emit('edit', props.library, 'folders')
|
||||
}
|
||||
|
||||
function editMetadata() {
|
||||
emit('edit', props.library, 'metadata')
|
||||
}
|
||||
|
||||
function manageAccess() {
|
||||
emit('edit', props.library, 'access')
|
||||
}
|
||||
|
||||
async function copyPath(folderId: number, path: string) {
|
||||
if (!(await copyToClipboard(path))) {
|
||||
toast.error(t('settings.admin.libraries.pathCopyFailed'))
|
||||
return
|
||||
}
|
||||
copiedPath.value = folderId
|
||||
toast.success(t('settings.admin.libraries.pathCopied'))
|
||||
setTimeout(() => {
|
||||
if (copiedPath.value === folderId) copiedPath.value = null
|
||||
}, 1600)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -67,7 +97,7 @@ function requestEdit() {
|
||||
size="sm"
|
||||
type="button"
|
||||
class="ms-auto h-6 px-2 text-xs font-medium normal-case tracking-normal"
|
||||
@click="requestEdit"
|
||||
@click="editFolders"
|
||||
>
|
||||
<Pencil :size="12" aria-hidden="true" />
|
||||
{{ t('common.edit') }}
|
||||
@@ -77,10 +107,24 @@ function requestEdit() {
|
||||
<div
|
||||
v-for="(folder, index) in library.folders"
|
||||
:key="folder.id"
|
||||
class="flex items-center gap-3 border-t border-border py-1.5 first:border-t-0"
|
||||
class="flex items-start gap-3 border-t border-border py-1.5 first:border-t-0"
|
||||
>
|
||||
<dt class="shrink-0 text-[12.5px] text-muted-foreground">{{ t('settings.admin.libraries.detail.path', { index: index + 1 }) }}</dt>
|
||||
<dd class="ms-auto min-w-0 truncate font-mono text-[11px] text-foreground" dir="ltr" :title="folder.path">{{ folder.path }}</dd>
|
||||
<dt class="shrink-0 pt-px text-[12.5px] text-muted-foreground">{{ t('settings.admin.libraries.detail.path', { index: index + 1 }) }}</dt>
|
||||
<dd class="ms-auto flex min-w-0 items-start gap-1">
|
||||
<span data-testid="library-full-path" class="min-w-0 break-all text-end font-mono text-[11px] leading-5 text-foreground" dir="ltr">{{
|
||||
folder.path
|
||||
}}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
class="size-5 shrink-0"
|
||||
:aria-label="copiedPath === folder.id ? t('settings.admin.libraries.pathCopied') : t('settings.admin.libraries.copyPath')"
|
||||
@click="copyPath(folder.id, folder.path)"
|
||||
>
|
||||
<component :is="copiedPath === folder.id ? Check : Copy" :size="12" aria-hidden="true" />
|
||||
</Button>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 border-t border-border py-1.5">
|
||||
<dt class="shrink-0 text-[12.5px] text-muted-foreground">{{ t('settings.admin.libraries.detail.organization') }}</dt>
|
||||
@@ -102,7 +146,7 @@ function requestEdit() {
|
||||
size="sm"
|
||||
type="button"
|
||||
class="ms-auto h-6 px-2 text-xs font-medium normal-case tracking-normal"
|
||||
@click="requestEdit"
|
||||
@click="editMetadata"
|
||||
>
|
||||
<Pencil :size="12" aria-hidden="true" />
|
||||
{{ t('common.edit') }}
|
||||
@@ -134,13 +178,33 @@ function requestEdit() {
|
||||
<h4 class="mb-2.5 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<Users :size="12" aria-hidden="true" />
|
||||
{{ t('settings.admin.libraries.detail.accessTitle') }}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
class="ms-auto h-6 px-2 text-xs font-medium normal-case tracking-normal"
|
||||
@click="manageAccess"
|
||||
>
|
||||
<Users :size="12" aria-hidden="true" />
|
||||
{{ t('settings.admin.libraries.detail.manageAccess') }}
|
||||
</Button>
|
||||
</h4>
|
||||
<dl>
|
||||
<div class="flex items-center gap-3 py-1.5">
|
||||
<div class="flex items-start gap-3 py-1.5">
|
||||
<dt class="shrink-0 text-[12.5px] text-muted-foreground">{{ t('settings.admin.libraries.detail.peopleWithAccess') }}</dt>
|
||||
<dd class="ms-auto text-[12.5px] font-medium text-foreground">
|
||||
<dd class="ms-auto min-w-0 text-[12.5px] font-medium text-foreground">
|
||||
<Skeleton v-if="loading" class="h-4 w-16" />
|
||||
<span v-else>{{ accessLabel || '—' }}</span>
|
||||
<span v-else-if="access === null">-</span>
|
||||
<span v-else-if="access.length === 0">{{ t('settings.admin.libraries.detail.peopleCount', { count: 0 }) }}</span>
|
||||
<ul v-else data-testid="library-access-people" class="flex flex-wrap justify-end gap-x-2.5 gap-y-1">
|
||||
<li v-for="person in shownPeople" :key="person.userId" class="inline-flex items-center gap-1.5 whitespace-nowrap">
|
||||
<UserAvatar :name="person.name" size-class="size-4 shrink-0" text-class="text-[8px]" />
|
||||
{{ person.name }}
|
||||
</li>
|
||||
<li v-if="hiddenPeople > 0" class="text-muted-foreground">
|
||||
{{ t('settings.admin.libraries.detail.peopleMore', { count: hiddenPeople }) }}
|
||||
</li>
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 border-t border-border py-1.5">
|
||||
|
||||
@@ -13,17 +13,22 @@ const { t } = useI18n()
|
||||
|
||||
const isFileMode = computed(() => props.library.organizationMode === 'book_per_file')
|
||||
const modeIcon = computed(() => (isFileMode.value ? FileText : Folder))
|
||||
const modeLabel = computed(() => (isFileMode.value ? t('settings.admin.libraries.fileMode') : t('settings.admin.libraries.folderMode')))
|
||||
/** The editor's own name for the setting, so the list and the editor never call it two things. */
|
||||
const modeLabel = computed(() =>
|
||||
isFileMode.value ? t('library.creator.scanner.scanMode.fileAsBook.title') : t('library.creator.scanner.scanMode.folderAsBook.title'),
|
||||
)
|
||||
|
||||
const firstFolder = computed(() => props.library.folders[0]?.path ?? null)
|
||||
const extraFolders = computed(() => Math.max(0, props.library.folders.length - 1))
|
||||
const allPaths = computed(() => props.library.folders.map((folder) => folder.path).join('\n'))
|
||||
|
||||
/** The ledger row has room for the whole path; the mobile card keeps the identifying tail. */
|
||||
const displayPath = computed(() => {
|
||||
if (!firstFolder.value) return ''
|
||||
return props.prominent ? firstFolder.value : shortenPath(firstFolder.value)
|
||||
/** Split so the last segment, the one that names the library's folder, can be set apart. */
|
||||
const pathHead = computed(() => {
|
||||
const path = firstFolder.value ?? ''
|
||||
const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
return cut >= 0 ? path.slice(0, cut + 1) : ''
|
||||
})
|
||||
const pathLeaf = computed(() => (firstFolder.value ?? '').slice(pathHead.value.length))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -47,6 +52,7 @@ const displayPath = computed(() => {
|
||||
|
||||
<p class="flex min-w-0 items-center gap-2 text-muted-foreground" :class="prominent ? 'mt-1.5 text-[12.5px]' : 'mt-0.5 text-xs'">
|
||||
<span
|
||||
data-testid="library-mode"
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border px-1.5 py-0.5 text-[11px] font-medium"
|
||||
:class="
|
||||
isFileMode
|
||||
@@ -57,14 +63,17 @@ const displayPath = computed(() => {
|
||||
<component :is="modeIcon" :size="11" class="shrink-0" aria-hidden="true" />
|
||||
{{ modeLabel }}
|
||||
</span>
|
||||
<!-- One folder is already stated by the path line underneath. -->
|
||||
<template v-if="prominent">
|
||||
<span class="shrink-0">{{ t('settings.admin.libraries.folderCount', { count: library.folders.length }) }}</span>
|
||||
<span v-if="library.folders.length > 1" class="shrink-0">{{
|
||||
t('settings.admin.libraries.folderCount', { count: library.folders.length })
|
||||
}}</span>
|
||||
</template>
|
||||
<template v-else-if="firstFolder">
|
||||
<span class="shrink-0 opacity-50" aria-hidden="true">·</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span class="min-w-0 truncate font-mono" dir="ltr" :title="allPaths">{{ displayPath }}</span>
|
||||
<span class="min-w-0 truncate font-mono" dir="ltr" :title="allPaths">{{ shortenPath(firstFolder) }}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent class="max-w-sm">
|
||||
<p v-for="folder in library.folders" :key="folder.id" dir="ltr" class="break-all font-mono text-xs">{{ folder.path }}</p>
|
||||
@@ -78,7 +87,13 @@ const displayPath = computed(() => {
|
||||
<Folder :size="12" class="shrink-0 opacity-60" aria-hidden="true" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span class="min-w-0 truncate font-mono" dir="ltr" :title="allPaths">{{ displayPath }}</span>
|
||||
<!-- Cut from the start, so the folder that names the library is what stays visible: the right-to-left box
|
||||
overflows on its left, and the bdi keeps the path itself left-to-right in either locale direction. -->
|
||||
<span data-testid="library-path" class="library-path min-w-0 font-mono" :title="allPaths">
|
||||
<bdi dir="ltr"
|
||||
>{{ pathHead }}<span class="font-semibold text-foreground">{{ pathLeaf }}</span></bdi
|
||||
>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent class="max-w-sm">
|
||||
<p v-for="folder in library.folders" :key="folder.id" dir="ltr" class="break-all font-mono text-xs">{{ folder.path }}</p>
|
||||
@@ -91,3 +106,18 @@ const displayPath = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.library-path {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[dir='rtl'] .library-path {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Eye, EyeOff, FileEdit } from '@lucide/vue'
|
||||
import type { Library, LibraryOverviewEntry, ScanProgressEvent } from '@bookorbit/types'
|
||||
import { describeSchedule, writtenKindCount } from '@/features/library/utils/library-summary'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
import LibraryCapabilityStrip from './LibraryCapabilityStrip.vue'
|
||||
import { libraryProblem } from '../lib/library-problems'
|
||||
import LibraryFormatBar from './LibraryFormatBar.vue'
|
||||
import LibraryIdentity from './LibraryIdentity.vue'
|
||||
import LibraryProblemStrip from './LibraryProblemStrip.vue'
|
||||
import LibraryRowActions from './LibraryRowActions.vue'
|
||||
import LibraryScanCell from './LibraryScanCell.vue'
|
||||
|
||||
@@ -25,10 +28,37 @@ defineEmits<{
|
||||
refreshCovers: [library: Library]
|
||||
syncFiles: [library: Library]
|
||||
remove: [library: Library]
|
||||
duplicate: [library: Library]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/** The editor's own summaries, so a phone reads the same words the Automation and File updates sections use. */
|
||||
function automationSummary(library: Library): string {
|
||||
const summary = describeSchedule(library.autoScanCronExpression)
|
||||
const schedule = library.autoScanCronExpression
|
||||
? summary
|
||||
? t(`library.creator.schedule.presets.${summary.preset}`)
|
||||
: t('library.creator.summary.customSchedule')
|
||||
: null
|
||||
if (library.watch && schedule) return t('library.creator.summary.watchingScheduled', { schedule })
|
||||
if (library.watch) return t('library.creator.summary.watchingOnly')
|
||||
if (schedule) return t('library.creator.summary.scheduledOnly', { schedule })
|
||||
return t('library.creator.summary.manualOnly')
|
||||
}
|
||||
|
||||
function fileSummary(library: Library): string | null {
|
||||
const count = writtenKindCount(library)
|
||||
if (library.fileWriteEnabled && library.fileRenameEnabled) return t('library.creator.summary.writesAndRenames', { count })
|
||||
if (library.fileWriteEnabled) return t('library.creator.summary.writes', { count })
|
||||
if (library.fileRenameEnabled) return t('library.creator.summary.renames')
|
||||
return null
|
||||
}
|
||||
|
||||
function problemFor(library: Library) {
|
||||
return props.overviewLoaded ? libraryProblem(entryFor(library.id)?.lastScan, props.isScanning(library.id)) : null
|
||||
}
|
||||
|
||||
function entryFor(libraryId: number): LibraryOverviewEntry | undefined {
|
||||
return props.overview.get(libraryId)
|
||||
}
|
||||
@@ -43,12 +73,17 @@ function cardAccent(libraryId: number): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul data-testid="libraries-ledger-cards" class="space-y-2.5 md:hidden">
|
||||
<li v-for="library in libraries" :key="library.id" class="rounded-lg border bg-card shadow-xs" :class="cardAccent(library.id)">
|
||||
<ul data-testid="libraries-ledger-cards" class="grid gap-2.5 @min-[40rem]:grid-cols-2 @min-[75rem]:hidden">
|
||||
<li
|
||||
v-for="library in libraries"
|
||||
:key="library.id"
|
||||
class="flex flex-col overflow-hidden rounded-lg border bg-card shadow-xs"
|
||||
:class="cardAccent(library.id)"
|
||||
>
|
||||
<div class="px-3 pt-3">
|
||||
<LibraryIdentity :library="library" />
|
||||
</div>
|
||||
<div class="px-3 pt-2.5">
|
||||
<div class="flex-1 px-3 pb-3 pt-2.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<p class="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-0.5 text-sm">
|
||||
<span class="font-semibold tabular-nums text-foreground">{{ formatNumber(entryFor(library.id)?.totalBooks ?? 0) }}</span>
|
||||
@@ -58,13 +93,29 @@ function cardAccent(libraryId: number): string {
|
||||
<span class="text-xs opacity-50" aria-hidden="true">·</span>
|
||||
<span class="font-medium tabular-nums text-foreground">{{ formatBytes(entryFor(library.id)?.totalSizeBytes ?? 0) }}</span>
|
||||
</p>
|
||||
<LibraryCapabilityStrip class="shrink-0" :library="library" />
|
||||
</div>
|
||||
<p data-testid="library-card-automation" class="mt-1.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[12.5px] text-foreground">
|
||||
<component
|
||||
:is="library.watch ? Eye : EyeOff"
|
||||
:size="12"
|
||||
class="shrink-0"
|
||||
:class="library.watch || library.autoScanCronExpression ? 'text-primary' : 'text-muted-foreground'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{ automationSummary(library) }}</span>
|
||||
<template v-if="fileSummary(library)">
|
||||
<span class="text-muted-foreground" aria-hidden="true">·</span>
|
||||
<FileEdit :size="12" class="shrink-0 text-primary" aria-hidden="true" />
|
||||
<span>{{ fileSummary(library) }}</span>
|
||||
</template>
|
||||
</p>
|
||||
<LibraryFormatBar class="mt-2" show-legend :counts="entryFor(library.id)?.formatCounts ?? {}" :legend-limit="2" />
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-3 border-t border-border px-3 py-2.5">
|
||||
<LibraryProblemStrip v-if="problemFor(library)" class="px-3" :library="library" :problem="problemFor(library)!" @scan="$emit('scan', $event)" />
|
||||
<div class="flex items-center gap-3 border-t border-border px-3 py-2.5">
|
||||
<LibraryScanCell
|
||||
class="min-w-0 flex-1"
|
||||
:library="library"
|
||||
:last-scan="entryFor(library.id)?.lastScan ?? null"
|
||||
:progress="progressFor(library.id)"
|
||||
:pending="!overviewLoaded"
|
||||
@@ -80,6 +131,7 @@ function cardAccent(libraryId: number): string {
|
||||
@refresh-covers="$emit('refreshCovers', $event)"
|
||||
@sync-files="$emit('syncFiles', $event)"
|
||||
@remove="$emit('remove', $event)"
|
||||
@duplicate="$emit('duplicate', $event)"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Library, LibraryOverviewEntry, LibraryScanHistoryEntry, ScanProgressEvent } from '@bookorbit/types'
|
||||
import type { Library, LibraryAccessEntry, LibraryOverviewEntry, LibraryScanHistoryEntry, ScanProgressEvent } from '@bookorbit/types'
|
||||
import type { LibraryCreatorSectionId } from '@/features/library/composables/useLibraryCreator'
|
||||
import type { AutomationToggle } from './LibraryAutomationList.vue'
|
||||
import LibraryLedgerRow from './LibraryLedgerRow.vue'
|
||||
|
||||
defineProps<{
|
||||
@@ -12,61 +14,97 @@ defineProps<{
|
||||
isScanning: (libraryId: number) => boolean
|
||||
isRefreshingCovers: (libraryId: number) => boolean
|
||||
isSyncingFiles: (libraryId: number) => boolean
|
||||
isSavingAutomation: (libraryId: number) => boolean
|
||||
isJustFinished: (libraryId: number) => boolean
|
||||
historyFor: (libraryId: number) => LibraryScanHistoryEntry[] | null
|
||||
accessCountFor: (libraryId: number) => number | null
|
||||
accessFor: (libraryId: number) => LibraryAccessEntry[] | null
|
||||
isDetailLoading: (libraryId: number) => boolean
|
||||
isDetailFailed: (libraryId: number) => boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
toggle: [library: Library]
|
||||
scan: [library: Library]
|
||||
edit: [library: Library]
|
||||
edit: [library: Library, section?: LibraryCreatorSectionId]
|
||||
refreshCovers: [library: Library]
|
||||
syncFiles: [library: Library]
|
||||
remove: [library: Library]
|
||||
duplicate: [library: Library]
|
||||
toggleSetting: [library: Library, setting: AutomationToggle]
|
||||
setSchedule: [library: Library, cron: string | null]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function forwardToggle(library: Library) {
|
||||
emit('toggle', library)
|
||||
}
|
||||
function forwardScan(library: Library) {
|
||||
emit('scan', library)
|
||||
}
|
||||
function forwardEdit(library: Library, section?: LibraryCreatorSectionId) {
|
||||
emit('edit', library, section)
|
||||
}
|
||||
function forwardRefreshCovers(library: Library) {
|
||||
emit('refreshCovers', library)
|
||||
}
|
||||
function forwardSyncFiles(library: Library) {
|
||||
emit('syncFiles', library)
|
||||
}
|
||||
function forwardRemove(library: Library) {
|
||||
emit('remove', library)
|
||||
}
|
||||
function forwardDuplicate(library: Library) {
|
||||
emit('duplicate', library)
|
||||
}
|
||||
function forwardToggleSetting(library: Library, setting: AutomationToggle) {
|
||||
emit('toggleSetting', library, setting)
|
||||
}
|
||||
function forwardSetSchedule(library: Library, cron: string | null) {
|
||||
emit('setSchedule', library, cron)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Below the row's natural width the whole grid scrolls, the same way the users roster does. -->
|
||||
<div data-testid="libraries-ledger-list" class="hidden overflow-x-auto md:block">
|
||||
<div class="min-w-[75rem]">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="grid grid-cols-[minmax(0,1fr)_16.75rem_19.75rem_14.5rem] gap-7 pb-2 pe-[21px] ps-[15px] text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
<span>{{ t('settings.admin.libraries.columns.library') }}</span>
|
||||
<span>{{ t('settings.admin.libraries.columns.contents') }}</span>
|
||||
<span>{{ t('settings.admin.libraries.columns.automation') }}</span>
|
||||
<span>{{ t('settings.admin.libraries.columns.lastScan') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<LibraryLedgerRow
|
||||
v-for="library in libraries"
|
||||
:key="library.id"
|
||||
:library="library"
|
||||
:entry="overview.get(library.id)"
|
||||
:overview-loaded="overviewLoaded"
|
||||
:expanded="expandedId === library.id"
|
||||
:progress="progressFor(library.id)"
|
||||
:scanning="isScanning(library.id)"
|
||||
:refreshing-covers="isRefreshingCovers(library.id)"
|
||||
:syncing-files="isSyncingFiles(library.id)"
|
||||
:history="historyFor(library.id)"
|
||||
:access-count="accessCountFor(library.id)"
|
||||
:detail-loading="isDetailLoading(library.id)"
|
||||
:detail-failed="isDetailFailed(library.id)"
|
||||
@toggle="$emit('toggle', $event)"
|
||||
@scan="$emit('scan', $event)"
|
||||
@edit="$emit('edit', $event)"
|
||||
@refresh-covers="$emit('refreshCovers', $event)"
|
||||
@sync-files="$emit('syncFiles', $event)"
|
||||
@remove="$emit('remove', $event)"
|
||||
/>
|
||||
</div>
|
||||
<!-- The ledger needs 75rem; below that its container shows the cards instead of scrolling sideways. -->
|
||||
<div data-testid="libraries-ledger-list" class="hidden @min-[75rem]:block">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="grid grid-cols-[minmax(0,1fr)_16.75rem_19.75rem_14.5rem] gap-7 pb-2 pe-[21px] ps-[15px] text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
<span>{{ t('settings.admin.libraries.columns.library') }}</span>
|
||||
<span>{{ t('settings.admin.libraries.columns.contents') }}</span>
|
||||
<span>{{ t('settings.admin.libraries.columns.automation') }}</span>
|
||||
<span>{{ t('settings.admin.libraries.columns.lastScan') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<LibraryLedgerRow
|
||||
v-for="library in libraries"
|
||||
:key="library.id"
|
||||
:library="library"
|
||||
:entry="overview.get(library.id)"
|
||||
:overview-loaded="overviewLoaded"
|
||||
:expanded="expandedId === library.id"
|
||||
:progress="progressFor(library.id)"
|
||||
:scanning="isScanning(library.id)"
|
||||
:refreshing-covers="isRefreshingCovers(library.id)"
|
||||
:syncing-files="isSyncingFiles(library.id)"
|
||||
:saving-automation="isSavingAutomation(library.id)"
|
||||
:just-finished="isJustFinished(library.id)"
|
||||
:history="historyFor(library.id)"
|
||||
:access="accessFor(library.id)"
|
||||
:detail-loading="isDetailLoading(library.id)"
|
||||
:detail-failed="isDetailFailed(library.id)"
|
||||
@toggle="forwardToggle"
|
||||
@scan="forwardScan"
|
||||
@edit="forwardEdit"
|
||||
@refresh-covers="forwardRefreshCovers"
|
||||
@sync-files="forwardSyncFiles"
|
||||
@remove="forwardRemove"
|
||||
@duplicate="forwardDuplicate"
|
||||
@toggle-setting="forwardToggleSetting"
|
||||
@set-schedule="forwardSetSchedule"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChevronDown, ChevronRight } from '@lucide/vue'
|
||||
import type { Library, LibraryOverviewEntry, LibraryScanHistoryEntry, ScanProgressEvent } from '@bookorbit/types'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
import type { Library, LibraryAccessEntry, LibraryOverviewEntry, LibraryScanHistoryEntry, ScanProgressEvent } from '@bookorbit/types'
|
||||
import type { LibraryCreatorSectionId } from '@/features/library/composables/useLibraryCreator'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
import LibraryAutomationList from './LibraryAutomationList.vue'
|
||||
import { libraryProblem } from '../lib/library-problems'
|
||||
import LibraryAutomationList, { type AutomationToggle } from './LibraryAutomationList.vue'
|
||||
import LibraryDetailPanel from './LibraryDetailPanel.vue'
|
||||
import LibraryFormatBar from './LibraryFormatBar.vue'
|
||||
import LibraryIdentity from './LibraryIdentity.vue'
|
||||
import LibraryProblemStrip from './LibraryProblemStrip.vue'
|
||||
import LibraryRowActions from './LibraryRowActions.vue'
|
||||
import LibraryScanCell from './LibraryScanCell.vue'
|
||||
|
||||
@@ -21,8 +24,10 @@ const props = defineProps<{
|
||||
scanning: boolean
|
||||
refreshingCovers: boolean
|
||||
syncingFiles: boolean
|
||||
savingAutomation: boolean
|
||||
justFinished: boolean
|
||||
history: LibraryScanHistoryEntry[] | null
|
||||
accessCount: number | null
|
||||
access: LibraryAccessEntry[] | null
|
||||
detailLoading: boolean
|
||||
detailFailed: boolean
|
||||
}>()
|
||||
@@ -30,16 +35,22 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
toggle: [library: Library]
|
||||
scan: [library: Library]
|
||||
edit: [library: Library]
|
||||
edit: [library: Library, section?: LibraryCreatorSectionId]
|
||||
refreshCovers: [library: Library]
|
||||
syncFiles: [library: Library]
|
||||
remove: [library: Library]
|
||||
duplicate: [library: Library]
|
||||
toggleSetting: [library: Library, setting: AutomationToggle]
|
||||
setSchedule: [library: Library, cron: string | null]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const totalBooks = computed(() => props.entry?.totalBooks ?? 0)
|
||||
const panelId = computed(() => `library-detail-${props.library.id}`)
|
||||
const historyOpen = ref(false)
|
||||
|
||||
const problem = computed(() => (props.overviewLoaded ? libraryProblem(props.entry?.lastScan, props.scanning) : null))
|
||||
|
||||
/** Failed and never-scanned libraries are outlined so the list reads at a glance. */
|
||||
const accent = computed(() => {
|
||||
@@ -48,18 +59,87 @@ const accent = computed(() => {
|
||||
const lastScan = props.entry?.lastScan
|
||||
if (!lastScan) return 'border-[var(--pill-warning)]/45'
|
||||
if (lastScan.status === 'failed') return 'border-destructive/45'
|
||||
return 'border-border'
|
||||
return 'border-border hover:border-foreground/15'
|
||||
})
|
||||
|
||||
function requestToggle() {
|
||||
emit('toggle', props.library)
|
||||
}
|
||||
|
||||
const ROW_CLICK_IGNORED = 'a, button, input, select, textarea, [role="menu"], [role="dialog"], [data-row-ignore]'
|
||||
|
||||
/**
|
||||
* A click on the row's empty space opens the details, the way the chevron does. Anything interactive
|
||||
* inside keeps its own click, and the open panel is left alone so selecting its text never collapses it.
|
||||
* Keyboard users keep the chevron, which is the accessible control.
|
||||
*
|
||||
* The check reads the event's dispatch path, not `target.closest()`: Vue can re-render between listeners
|
||||
* of one click, and a clicked icon that was swapped out is detached, so it no longer has a button above it.
|
||||
*/
|
||||
function handleRowClick(event: MouseEvent) {
|
||||
const path = event.composedPath()
|
||||
const row = event.currentTarget
|
||||
for (const node of path) {
|
||||
if (node === row) break
|
||||
if (node instanceof Element && node.matches(ROW_CLICK_IGNORED)) return
|
||||
}
|
||||
if (window.getSelection()?.toString()) return
|
||||
emit('toggle', props.library)
|
||||
}
|
||||
|
||||
function forwardEdit(library: Library, section?: LibraryCreatorSectionId) {
|
||||
emit('edit', library, section)
|
||||
}
|
||||
|
||||
function forwardScan(library: Library) {
|
||||
emit('scan', library)
|
||||
}
|
||||
|
||||
function forwardRefreshCovers(library: Library) {
|
||||
emit('refreshCovers', library)
|
||||
}
|
||||
|
||||
function forwardSyncFiles(library: Library) {
|
||||
emit('syncFiles', library)
|
||||
}
|
||||
|
||||
function forwardRemove(library: Library) {
|
||||
emit('remove', library)
|
||||
}
|
||||
|
||||
function forwardDuplicate(library: Library) {
|
||||
emit('duplicate', library)
|
||||
}
|
||||
|
||||
function forwardToggleSetting(library: Library, setting: AutomationToggle) {
|
||||
emit('toggleSetting', library, setting)
|
||||
}
|
||||
|
||||
function forwardSetSchedule(library: Library, cron: string | null) {
|
||||
emit('setSchedule', library, cron)
|
||||
}
|
||||
|
||||
function editSchedule(library: Library) {
|
||||
emit('edit', library, 'schedule')
|
||||
}
|
||||
|
||||
/** Waits a frame so the closing menu hands focus back before the popover takes it. */
|
||||
function openHistory() {
|
||||
requestAnimationFrame(() => {
|
||||
historyOpen.value = true
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-hidden rounded-xl border bg-card shadow-xs transition-colors" :class="accent">
|
||||
<div
|
||||
class="overflow-hidden rounded-xl border bg-card shadow-xs transition-colors"
|
||||
:class="[accent, justFinished ? 'library-row-finished' : '']"
|
||||
data-testid="library-row"
|
||||
@click="handleRowClick"
|
||||
>
|
||||
<!-- Four zones on one grid, vertically centred so the shorter ones never leave a void. -->
|
||||
<div class="grid min-h-24 grid-cols-[minmax(0,1fr)_16.75rem_19.75rem_14.5rem] items-center gap-7 py-[15px] pe-5 ps-3.5">
|
||||
<div class="grid min-h-24 cursor-pointer grid-cols-[minmax(0,1fr)_16.75rem_19.75rem_14.5rem] items-center gap-7 py-[15px] pe-5 ps-3.5">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -71,7 +151,12 @@ function requestToggle() {
|
||||
:aria-label="t('settings.admin.libraries.toggleDetail', { name: library.name })"
|
||||
@click="requestToggle"
|
||||
>
|
||||
<component :is="expanded ? ChevronDown : ChevronRight" :size="14" aria-hidden="true" />
|
||||
<ChevronRight
|
||||
:size="14"
|
||||
class="transition-transform duration-150 motion-reduce:transition-none"
|
||||
:class="expanded ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<LibraryIdentity class="min-w-0 flex-1" :library="library" prominent />
|
||||
</div>
|
||||
@@ -85,33 +170,77 @@ function requestToggle() {
|
||||
<LibraryFormatBar class="mt-2.5" show-legend legend-reserve :counts="entry?.formatCounts ?? {}" :legend-limit="5" />
|
||||
</div>
|
||||
|
||||
<LibraryAutomationList :library="library" />
|
||||
<LibraryAutomationList
|
||||
:library="library"
|
||||
interactive
|
||||
:saving="savingAutomation"
|
||||
@toggle="forwardToggleSetting"
|
||||
@set-schedule="forwardSetSchedule"
|
||||
@edit-schedule="editSchedule"
|
||||
/>
|
||||
|
||||
<div class="flex w-full flex-col gap-2.5">
|
||||
<LibraryScanCell :last-scan="entry?.lastScan ?? null" :progress="progress" :pending="!overviewLoaded" />
|
||||
<LibraryScanCell
|
||||
v-model:history-open="historyOpen"
|
||||
:library="library"
|
||||
:last-scan="entry?.lastScan ?? null"
|
||||
:progress="progress"
|
||||
:pending="!overviewLoaded"
|
||||
with-history
|
||||
@scan="forwardScan"
|
||||
/>
|
||||
<LibraryRowActions
|
||||
:library="library"
|
||||
:scanning="scanning"
|
||||
:refreshing-covers="refreshingCovers"
|
||||
:syncing-files="syncingFiles"
|
||||
@scan="emit('scan', $event)"
|
||||
@edit="emit('edit', $event)"
|
||||
@refresh-covers="emit('refreshCovers', $event)"
|
||||
@sync-files="emit('syncFiles', $event)"
|
||||
@remove="emit('remove', $event)"
|
||||
:can-show-history="Boolean(entry?.lastScan)"
|
||||
@scan="forwardScan"
|
||||
@edit="forwardEdit"
|
||||
@refresh-covers="forwardRefreshCovers"
|
||||
@sync-files="forwardSyncFiles"
|
||||
@remove="forwardRemove"
|
||||
@duplicate="forwardDuplicate"
|
||||
@history="openHistory"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LibraryProblemStrip v-if="problem" :library="library" :problem="problem" @scan="forwardScan" />
|
||||
|
||||
<LibraryDetailPanel
|
||||
v-if="expanded"
|
||||
:id="panelId"
|
||||
data-row-ignore
|
||||
:library="library"
|
||||
:history="history"
|
||||
:access-count="accessCount"
|
||||
:access="access"
|
||||
:loading="detailLoading"
|
||||
:failed="detailFailed"
|
||||
@edit="emit('edit', $event)"
|
||||
@edit="forwardEdit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* One pass of the success tint once a scan finishes, so the row that changed is findable. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.library-row-finished {
|
||||
animation: library-row-finished 2.2s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.library-row-finished {
|
||||
border-color: color-mix(in oklch, var(--pill-success) 45%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes library-row-finished {
|
||||
0%,
|
||||
30% {
|
||||
background-color: color-mix(in oklch, var(--pill-success) 12%, var(--card));
|
||||
border-color: color-mix(in oklch, var(--pill-success) 45%, transparent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CircleAlert, RefreshCw, TriangleAlert } from '@lucide/vue'
|
||||
import type { Library } from '@bookorbit/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { relativeTimestamp } from '@/lib/relative-time'
|
||||
import type { LibraryProblem } from '../lib/library-problems'
|
||||
|
||||
const props = defineProps<{ library: Library; problem: LibraryProblem }>()
|
||||
const emit = defineEmits<{ scan: [library: Library] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const tone = computed(() => (props.problem.kind === 'failed' ? 'error' : 'warning'))
|
||||
const icon = computed(() => (props.problem.kind === 'failed' ? CircleAlert : TriangleAlert))
|
||||
|
||||
const title = computed(() => {
|
||||
const problem = props.problem
|
||||
if (problem.kind === 'never') return t('settings.admin.libraries.problem.neverTitle')
|
||||
return t('settings.admin.libraries.problem.failedTitle', { time: relativeTimestamp(problem.scan.startedAt) })
|
||||
})
|
||||
|
||||
const body = computed(() => {
|
||||
const problem = props.problem
|
||||
if (problem.kind === 'never') return t('settings.admin.libraries.problem.neverBody', { count: props.library.folders.length })
|
||||
return problem.scan.errorMessage ?? ''
|
||||
})
|
||||
|
||||
function requestScan() {
|
||||
emit('scan', props.library)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-testid="library-problem"
|
||||
class="flex flex-wrap items-center gap-x-3 gap-y-2 border-t px-4 py-2.5 text-[13px]"
|
||||
:class="tone === 'error' ? 'border-destructive/25 bg-destructive/7' : 'border-[var(--pill-warning)]/25 bg-[var(--pill-warning)]/7'"
|
||||
>
|
||||
<component
|
||||
:is="icon"
|
||||
:size="14"
|
||||
class="shrink-0"
|
||||
:class="tone === 'error' ? 'text-destructive' : 'text-[var(--pill-warning)]'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p class="min-w-0 flex-1 text-foreground">
|
||||
<span class="font-semibold">{{ title }}</span>
|
||||
<span v-if="body" :class="problem.kind === 'failed' ? 'font-mono text-xs' : ''"> {{ body }}</span>
|
||||
</p>
|
||||
<Button v-if="problem.kind === 'never'" size="sm" type="button" class="h-7" @click="requestScan">
|
||||
<RefreshCw :size="13" aria-hidden="true" />
|
||||
{{ t('settings.admin.libraries.problem.scanNow') }}
|
||||
</Button>
|
||||
<Button v-else variant="outline" size="sm" type="button" class="h-7" @click="requestScan">
|
||||
<RefreshCw :size="13" aria-hidden="true" />
|
||||
{{ t('settings.admin.libraries.problem.retry') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { FileEdit, Images, MoreHorizontal, Pencil, RefreshCw, Trash2 } from '@lucide/vue'
|
||||
import { Copy, FileEdit, History, Images, MoreHorizontal, Pencil, RefreshCw, Trash2 } from '@lucide/vue'
|
||||
import type { Library } from '@bookorbit/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
|
||||
const props = defineProps<{ library: Library; scanning: boolean; refreshingCovers: boolean; syncingFiles: boolean }>()
|
||||
const props = withDefaults(
|
||||
defineProps<{ library: Library; scanning: boolean; refreshingCovers: boolean; syncingFiles: boolean; canShowHistory?: boolean }>(),
|
||||
{ canShowHistory: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
scan: [library: Library]
|
||||
@@ -13,6 +16,8 @@ const emit = defineEmits<{
|
||||
refreshCovers: [library: Library]
|
||||
syncFiles: [library: Library]
|
||||
remove: [library: Library]
|
||||
duplicate: [library: Library]
|
||||
history: [library: Library]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -32,6 +37,12 @@ function requestSyncFiles() {
|
||||
function requestRemove() {
|
||||
emit('remove', props.library)
|
||||
}
|
||||
function requestDuplicate() {
|
||||
emit('duplicate', props.library)
|
||||
}
|
||||
function requestHistory() {
|
||||
emit('history', props.library)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -51,6 +62,15 @@ function requestRemove() {
|
||||
<Pencil />
|
||||
{{ t('settings.admin.libraries.editLibrary') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="library.type !== 'podcasts'" @click="requestDuplicate">
|
||||
<Copy />
|
||||
{{ t('settings.admin.libraries.duplicateSettings') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="canShowHistory" @click="requestHistory">
|
||||
<History />
|
||||
{{ t('settings.admin.libraries.detail.historyTitle') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem :disabled="refreshingCovers" @click="requestRefreshCovers">
|
||||
<Images :class="refreshingCovers ? 'animate-pulse motion-reduce:animate-none' : ''" />
|
||||
{{ t('settings.admin.libraries.refreshCovers') }}
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Check, CircleAlert, RefreshCw, TriangleAlert } from '@lucide/vue'
|
||||
import type { LibraryLastScan, ScanProgressEvent } from '@bookorbit/types'
|
||||
import { RefreshCw, TriangleAlert } from '@lucide/vue'
|
||||
import type { Library, LibraryLastScan, ScanProgressEvent } from '@bookorbit/types'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
import { relativeTimestamp } from '@/lib/relative-time'
|
||||
import LibraryScanHistoryPopover from './LibraryScanHistoryPopover.vue'
|
||||
import LibraryScanOutcome from './LibraryScanOutcome.vue'
|
||||
|
||||
const props = defineProps<{ lastScan: LibraryLastScan | null; progress: ScanProgressEvent | undefined; pending: boolean }>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
library: Library
|
||||
lastScan: LibraryLastScan | null
|
||||
progress: ScanProgressEvent | undefined
|
||||
pending: boolean
|
||||
/** The ledger opens the recent history from this cell; the mobile card leaves it plain. */
|
||||
withHistory?: boolean
|
||||
historyOpen?: boolean
|
||||
}>(),
|
||||
{ withHistory: false, historyOpen: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ 'update:historyOpen': [open: boolean]; scan: [library: Library] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -25,27 +40,62 @@ const state = computed(() => {
|
||||
return 'completed'
|
||||
})
|
||||
|
||||
const timeLabel = computed(() => (props.lastScan ? relativeTimestamp(props.lastScan.startedAt) : ''))
|
||||
|
||||
/** "Manual - +3 added, 1 missing", or an explicit "no change" so a finished scan never reads as blank. */
|
||||
const outcomeLabel = computed(() => {
|
||||
const scan = props.lastScan
|
||||
if (!scan) return ''
|
||||
const trigger = t(`settings.admin.libraries.trigger.${scan.triggeredBy}`)
|
||||
const parts: string[] = []
|
||||
if (scan.addedCount > 0) parts.push(t('settings.admin.libraries.scanAdded', { count: scan.addedCount }))
|
||||
if (scan.updatedCount > 0) parts.push(t('settings.admin.libraries.scanUpdated', { count: scan.updatedCount }))
|
||||
if (scan.missingCount > 0) parts.push(t('settings.admin.libraries.scanMissing', { count: scan.missingCount }))
|
||||
const outcome = parts.length > 0 ? parts.join(t('settings.admin.libraries.outcomeSeparator')) : t('settings.admin.libraries.scanNoChange')
|
||||
return t('settings.admin.libraries.scanOutcome', { trigger, outcome })
|
||||
})
|
||||
|
||||
const progressLabel = computed(() => {
|
||||
const event = running.value
|
||||
if (!event) return ''
|
||||
if (event.total <= 0) return t('settings.admin.libraries.scanCounting')
|
||||
return t('settings.admin.libraries.scanCounts', { processed: formatNumber(event.processed), total: formatNumber(event.total) })
|
||||
})
|
||||
|
||||
/** What the running scan has found so far; the socket event already carries the counts. */
|
||||
const liveOutcome = computed(() => {
|
||||
const event = running.value
|
||||
if (!event || event.total <= 0) return ''
|
||||
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.scanNothingNew')
|
||||
})
|
||||
|
||||
/**
|
||||
* Time left from the rate since this cell first saw the job counting, so a page opened mid-scan does not
|
||||
* divide by the whole job's elapsed time. Hidden until two seconds of data exist.
|
||||
*/
|
||||
const rateStart = ref<{ jobId: number; at: number; processed: number } | null>(null)
|
||||
watch(running, (event) => {
|
||||
if (!event || event.total <= 0) {
|
||||
if (!event) rateStart.value = null
|
||||
return
|
||||
}
|
||||
if (rateStart.value?.jobId !== event.jobId) rateStart.value = { jobId: event.jobId, at: Date.now(), processed: event.processed }
|
||||
})
|
||||
const timeLeft = computed(() => {
|
||||
const event = running.value
|
||||
const start = rateStart.value
|
||||
if (!event || !start || event.total <= 0 || start.jobId !== event.jobId) return ''
|
||||
const elapsedSeconds = (Date.now() - start.at) / 1000
|
||||
const done = event.processed - start.processed
|
||||
if (elapsedSeconds < 2 || done <= 0) return ''
|
||||
const seconds = Math.max(1, Math.round((event.total - event.processed) / (done / elapsedSeconds)))
|
||||
if (seconds < 60) return t('settings.admin.libraries.scanTimeLeftSeconds', { count: seconds })
|
||||
return t('settings.admin.libraries.scanTimeLeftMinutes', { count: Math.round(seconds / 60) })
|
||||
})
|
||||
|
||||
const historyEnabled = computed(() => props.withHistory && Boolean(props.lastScan) && !running.value)
|
||||
|
||||
function setHistoryOpen(open: boolean) {
|
||||
emit('update:historyOpen', open)
|
||||
}
|
||||
|
||||
function closeHistory() {
|
||||
emit('update:historyOpen', false)
|
||||
}
|
||||
|
||||
function requestScan(library: Library) {
|
||||
emit('update:historyOpen', false)
|
||||
emit('scan', library)
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Every state fills the same two lines, so a scan starting never changes the row height. -->
|
||||
@@ -72,6 +122,9 @@ const progressLabel = computed(() => {
|
||||
</div>
|
||||
<span class="truncate text-xs tabular-nums text-muted-foreground">{{ progressLabel }}</span>
|
||||
</div>
|
||||
<p v-if="liveOutcome" data-testid="scan-live" class="mt-0.5 truncate text-xs tabular-nums text-muted-foreground">
|
||||
{{ liveOutcome }}<template v-if="timeLeft"> · {{ timeLeft }}</template>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="state === 'pending'">
|
||||
@@ -86,23 +139,21 @@ const progressLabel = computed(() => {
|
||||
<p class="mt-0.5 truncate text-xs text-muted-foreground">{{ t('settings.admin.libraries.neverScannedHint') }}</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="state === 'failed'">
|
||||
<p class="flex items-center gap-1.5 text-sm font-medium text-destructive">
|
||||
<CircleAlert :size="13" class="shrink-0" aria-hidden="true" />
|
||||
<span class="truncate">{{ t('settings.admin.libraries.scanFailedAt', { time: timeLabel }) }}</span>
|
||||
</p>
|
||||
<p v-if="lastScan?.errorMessage" class="mt-0.5 truncate font-mono text-xs text-destructive" :title="lastScan.errorMessage">
|
||||
{{ lastScan.errorMessage }}
|
||||
</p>
|
||||
<p v-else class="mt-0.5 truncate text-xs text-muted-foreground">{{ outcomeLabel }}</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<Check :size="13" class="shrink-0 text-[var(--pill-success)]" aria-hidden="true" />
|
||||
<span class="truncate">{{ t('settings.admin.libraries.scannedAt', { time: timeLabel }) }}</span>
|
||||
</p>
|
||||
<p class="mt-0.5 truncate text-xs text-muted-foreground" :title="outcomeLabel">{{ outcomeLabel }}</p>
|
||||
</template>
|
||||
<Popover v-else-if="historyEnabled && lastScan" :open="historyOpen" @update:open="setHistoryOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="scan-status"
|
||||
class="-mx-1.5 -my-1 block w-[calc(100%+0.75rem)] rounded-md px-1.5 py-1 text-start transition-colors hover:bg-foreground/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<LibraryScanOutcome :scan="lastScan" />
|
||||
<span class="sr-only">{{ t('settings.admin.libraries.historyFor', { name: library.name }) }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-auto p-0">
|
||||
<LibraryScanHistoryPopover :library="library" :open="historyOpen" :scanning="false" @close="closeHistory" @scan="requestScan" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<LibraryScanOutcome v-else-if="lastScan" :scan="lastScan" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -71,14 +71,15 @@ function resultParts(entry: LibraryScanHistoryEntry): { added: number; updated:
|
||||
</template>
|
||||
</td>
|
||||
<td class="py-1.5 text-end">
|
||||
<!-- Only the exceptions get a chip, so a failure stands out in a column of completed scans. -->
|
||||
<span v-if="entry.status === 'completed'" class="text-[11px] text-muted-foreground">
|
||||
{{ t('settings.admin.libraries.history.statusValue.completed') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center rounded-full border px-2 py-0.5 text-[10.5px] font-semibold"
|
||||
:class="
|
||||
entry.status === 'failed'
|
||||
? 'border-destructive/35 bg-destructive/12 text-destructive'
|
||||
: entry.status === 'running'
|
||||
? 'border-primary/40 bg-primary/15 text-primary'
|
||||
: 'border-[var(--pill-success)]/35 bg-[var(--pill-success)]/12 text-[var(--pill-success)]'
|
||||
entry.status === 'failed' ? 'border-destructive/35 bg-destructive/12 text-destructive' : 'border-primary/40 bg-primary/15 text-primary'
|
||||
"
|
||||
>
|
||||
{{ t(`settings.admin.libraries.history.statusValue.${entry.status}`) }}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RefreshCw, X } from '@lucide/vue'
|
||||
import type { Library, LibraryScanHistoryEntry } from '@bookorbit/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { api } from '@/lib/api'
|
||||
import LibraryScanHistory from './LibraryScanHistory.vue'
|
||||
|
||||
const HISTORY_LIMIT = 10
|
||||
|
||||
const props = defineProps<{ library: Library; open: boolean; scanning: boolean }>()
|
||||
const emit = defineEmits<{ close: []; scan: [library: Library] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const entries = ref<LibraryScanHistoryEntry[] | null>(null)
|
||||
const failed = ref(false)
|
||||
|
||||
/** Loads each time it opens: a scan may have finished since, and the list is ten rows. */
|
||||
async function load() {
|
||||
entries.value = null
|
||||
failed.value = false
|
||||
try {
|
||||
const res = await api(`/api/v1/scanner/libraries/${props.library.id}/scan-history?limit=${HISTORY_LIMIT}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data: unknown = await res.json()
|
||||
entries.value = Array.isArray(data) ? (data as LibraryScanHistoryEntry[]) : []
|
||||
} catch {
|
||||
failed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) void load()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function requestClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function requestScan() {
|
||||
emit('scan', props.library)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-[36rem] max-w-[calc(100vw-2rem)]" role="group" :aria-label="t('settings.admin.libraries.historyFor', { name: library.name })">
|
||||
<div class="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<p class="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
|
||||
{{ t('settings.admin.libraries.historyFor', { name: library.name }) }}
|
||||
</p>
|
||||
<Button variant="ghost" size="icon-sm" type="button" :aria-label="t('common.close')" @click="requestClose">
|
||||
<X :size="15" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-80 overflow-y-auto px-4 py-3">
|
||||
<div v-if="entries === null && !failed" class="space-y-1.5" aria-hidden="true">
|
||||
<Skeleton v-for="index in 4" :key="index" class="h-6 w-full" />
|
||||
</div>
|
||||
<p v-else-if="failed" role="alert" class="text-[12.5px] text-destructive">{{ t('settings.admin.libraries.detail.historyFailed') }}</p>
|
||||
<p v-else-if="entries && entries.length === 0" class="text-[12.5px] text-muted-foreground">
|
||||
{{ t('settings.admin.libraries.detail.historyEmpty') }}
|
||||
</p>
|
||||
<LibraryScanHistory v-else-if="entries" :entries="entries" />
|
||||
</div>
|
||||
<div class="flex justify-end border-t border-border px-4 py-2.5">
|
||||
<Button variant="outline" size="sm" type="button" :disabled="scanning" @click="requestScan">
|
||||
<RefreshCw :size="14" :class="scanning ? 'animate-spin motion-reduce:animate-none' : ''" aria-hidden="true" />
|
||||
{{ t('settings.admin.libraries.problem.scanNow') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Check, CircleAlert } from '@lucide/vue'
|
||||
import type { LibraryLastScan } from '@bookorbit/types'
|
||||
import { formatDateTime } from '@/i18n/formatters'
|
||||
import { relativeTimestamp } from '@/lib/relative-time'
|
||||
|
||||
const props = defineProps<{ scan: LibraryLastScan }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const timeLabel = computed(() => relativeTimestamp(props.scan.startedAt))
|
||||
const exactTime = computed(() => formatDateTime(new Date(props.scan.startedAt)))
|
||||
const trigger = computed(() => t(`settings.admin.libraries.scanTrigger.${props.scan.triggeredBy}`))
|
||||
|
||||
/** Added in green and missing in amber, the way the history table colours them; "no change" when nothing moved. */
|
||||
const outcomeParts = computed(() => {
|
||||
const scan = props.scan
|
||||
const parts: { key: string; text: string; tone: string }[] = []
|
||||
if (scan.addedCount > 0)
|
||||
parts.push({ key: 'added', text: t('settings.admin.libraries.scanAdded', { count: scan.addedCount }), tone: 'text-[var(--pill-success)]' })
|
||||
if (scan.updatedCount > 0) parts.push({ key: 'updated', text: t('settings.admin.libraries.scanUpdated', { count: scan.updatedCount }), tone: '' })
|
||||
if (scan.missingCount > 0)
|
||||
parts.push({ key: 'missing', text: t('settings.admin.libraries.scanMissing', { count: scan.missingCount }), tone: 'text-[var(--pill-warning)]' })
|
||||
if (parts.length === 0) parts.push({ key: 'none', text: t('settings.admin.libraries.scanNoChange'), tone: '' })
|
||||
return parts
|
||||
})
|
||||
const outcomeTitle = computed(() =>
|
||||
t('settings.admin.libraries.scanOutcome', {
|
||||
trigger: trigger.value,
|
||||
outcome: outcomeParts.value.map((part) => part.text).join(t('settings.admin.libraries.outcomeSeparator')),
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="scan.status === 'failed'">
|
||||
<p class="flex items-center gap-1.5 text-sm font-medium text-destructive">
|
||||
<CircleAlert :size="13" class="shrink-0" aria-hidden="true" />
|
||||
<span class="truncate" :title="exactTime">{{ t('settings.admin.libraries.scanFailedAt', { time: timeLabel }) }}</span>
|
||||
</p>
|
||||
<p v-if="scan.errorMessage" class="mt-0.5 truncate font-mono text-xs text-destructive" :title="scan.errorMessage">{{ scan.errorMessage }}</p>
|
||||
<p v-else class="mt-0.5 truncate text-xs text-muted-foreground" :title="outcomeTitle">{{ outcomeTitle }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<Check :size="13" class="shrink-0 text-[var(--pill-success)]" aria-hidden="true" />
|
||||
<span class="truncate" :title="exactTime">{{ t('settings.admin.libraries.scannedAt', { time: timeLabel }) }}</span>
|
||||
</p>
|
||||
<i18n-t
|
||||
keypath="settings.admin.libraries.scanOutcome"
|
||||
tag="p"
|
||||
class="mt-0.5 truncate text-xs text-muted-foreground"
|
||||
scope="global"
|
||||
:title="outcomeTitle"
|
||||
>
|
||||
<template #trigger>{{ trigger }}</template>
|
||||
<template #outcome>
|
||||
<template v-for="(part, index) in outcomeParts" :key="part.key">
|
||||
<template v-if="index > 0">{{ t('settings.admin.libraries.outcomeSeparator') }}</template>
|
||||
<span :class="part.tone">{{ part.text }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,7 +4,7 @@ import { api } from '@/lib/api'
|
||||
|
||||
interface LibraryDetail {
|
||||
history: LibraryScanHistoryEntry[]
|
||||
accessCount: number
|
||||
access: LibraryAccessEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,7 +38,7 @@ export function useLibraryDetail() {
|
||||
if (requestGeneration !== generation) return
|
||||
details.value = new Map(details.value).set(libraryId, {
|
||||
history: Array.isArray(history) ? history : [],
|
||||
accessCount: Array.isArray(access) ? access.length : 0,
|
||||
access: Array.isArray(access) ? access : [],
|
||||
})
|
||||
const nextFailed = new Set(failed.value)
|
||||
nextFailed.delete(libraryId)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { LibraryLastScan } from '@bookorbit/types'
|
||||
|
||||
/** Something about a library's last scan that needs a person, worst first. */
|
||||
export type LibraryProblem = { kind: 'never' } | { kind: 'failed'; scan: LibraryLastScan }
|
||||
|
||||
/**
|
||||
* Reads the problem off the overview's last scan. A library that is scanning right now has none: the
|
||||
* running scan is about to replace whatever the last one said.
|
||||
*/
|
||||
export function libraryProblem(lastScan: LibraryLastScan | null | undefined, scanning: boolean): LibraryProblem | null {
|
||||
if (scanning) return null
|
||||
if (!lastScan) return { kind: 'never' }
|
||||
if (lastScan.status === 'failed') return { kind: 'failed', scan: lastScan }
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { formatDate } from '@/i18n/formatters'
|
||||
import { parseCronToHuman } from '@/features/library/utils/cron'
|
||||
import { describeSchedule } from '@/features/library/utils/library-summary'
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string
|
||||
|
||||
/** Jan 2, 2000 was a Sunday, so adding a cron weekday (0 = Sunday) lands on that day. */
|
||||
function sampleDate(hour: number, minute: number, weekday = 0): Date {
|
||||
return new Date(2000, 0, 2 + weekday, hour, minute)
|
||||
}
|
||||
|
||||
/**
|
||||
* A schedule as the editor names it ("Weekly · Mon 12:00 AM"), plus the full cron description for the
|
||||
* tooltip. Schedules the presets cannot name keep the full description as their label. Cron runs in the
|
||||
* server's time zone, the same as the sentence it replaces.
|
||||
*/
|
||||
export function formatSchedule(cron: string | null | undefined, t: Translate, locale: string): { label: string; title: string } | null {
|
||||
if (!cron) return null
|
||||
const sentence = parseCronToHuman(cron, locale) ?? cron
|
||||
const summary = describeSchedule(cron)
|
||||
if (!summary) return { label: sentence, title: sentence }
|
||||
const preset = t(`library.creator.schedule.presets.${summary.preset}`)
|
||||
if (summary.preset === 'daily') {
|
||||
const time = formatDate(sampleDate(summary.hour, summary.minute), { hour: 'numeric', minute: '2-digit' })
|
||||
return { label: t('settings.admin.libraries.schedule.at', { preset, time }), title: sentence }
|
||||
}
|
||||
if (summary.preset === 'weekly') {
|
||||
const date = sampleDate(summary.hour, summary.minute, summary.weekday)
|
||||
const day = formatDate(date, { weekday: 'short' })
|
||||
const time = formatDate(date, { hour: 'numeric', minute: '2-digit' })
|
||||
return { label: t('settings.admin.libraries.schedule.weeklyAt', { preset, day, time }), title: sentence }
|
||||
}
|
||||
return { label: preset, title: sentence }
|
||||
}
|
||||
@@ -1083,9 +1083,9 @@
|
||||
"libraries": {
|
||||
"title": "Libraries",
|
||||
"subtitle": "Manage your media libraries and trigger content scans.",
|
||||
"scanAll": "Scan All",
|
||||
"scanAll": "Scan all",
|
||||
"scanning": "Scanning...",
|
||||
"addLibrary": "Add Library",
|
||||
"addLibrary": "Add library",
|
||||
"scan": "Scan",
|
||||
"editLibrary": "Edit library",
|
||||
"refreshCovers": "Refresh covers",
|
||||
@@ -1147,7 +1147,7 @@
|
||||
"scanMissing": "{count, plural, one {# missing} other {# missing}}",
|
||||
"scanNoChange": "no change",
|
||||
"outcomeSeparator": ", ",
|
||||
"scanOutcome": "{trigger} - {outcome}",
|
||||
"scanOutcome": "{trigger} · {outcome}",
|
||||
"syncConfirmBody": "This will overwrite the metadata inside every supported file in {name} directly on disk. This cannot be undone.",
|
||||
"columns": {
|
||||
"library": "Library",
|
||||
@@ -1162,7 +1162,8 @@
|
||||
"name": "Name",
|
||||
"books": "Books",
|
||||
"size": "Size on disk",
|
||||
"lastScan": "Last scan"
|
||||
"lastScan": "Last scan",
|
||||
"sidebar": "Sidebar order"
|
||||
},
|
||||
"trigger": {
|
||||
"manual": "Manual",
|
||||
@@ -1220,10 +1221,57 @@
|
||||
"patternCount": "{count, plural, one {# pattern} other {# patterns}}",
|
||||
"peopleCount": "{count, plural, =0 {No explicit grants} one {# person} other {# people}}",
|
||||
"historyEmpty": "This library has never been scanned.",
|
||||
"historyFailed": "Could not load the scan history for this library."
|
||||
"historyFailed": "Could not load the scan history for this library.",
|
||||
"manageAccess": "Manage",
|
||||
"peopleMore": "{count, plural, one {+# more} other {+# more}}"
|
||||
},
|
||||
"scannedAt": "Scanned {time}",
|
||||
"detailRegion": "Details for {name}"
|
||||
"detailRegion": "Details for {name}",
|
||||
"scanTrigger": {
|
||||
"manual": "Manual",
|
||||
"watcher": "Watcher",
|
||||
"schedule": "Scheduled"
|
||||
},
|
||||
"schedule": {
|
||||
"at": "{preset} · {time}",
|
||||
"weeklyAt": "{preset} · {day} {time}"
|
||||
},
|
||||
"librariesUnit": "{count, plural, one {library} other {libraries}}",
|
||||
"foldersUnit": "{count, plural, one {folder} other {folders}}",
|
||||
"copyPath": "Copy path",
|
||||
"pathCopied": "Path copied",
|
||||
"pathCopyFailed": "Could not copy the path",
|
||||
"problem": {
|
||||
"neverTitle": "Never scanned.",
|
||||
"neverBody": "{count, plural, one {Nothing in its folder is indexed yet.} other {Nothing in its # folders is indexed yet.}}",
|
||||
"failedTitle": "The last scan failed {time}.",
|
||||
"scanNow": "Scan now",
|
||||
"retry": "Retry scan"
|
||||
},
|
||||
"quick": {
|
||||
"watchAria": "Watch folders for {name}",
|
||||
"scheduleAria": "Scheduled scan for {name}: {value}",
|
||||
"customSchedule": "Custom schedule…",
|
||||
"watchOn": "Watching folders for \"{name}\"",
|
||||
"watchOff": "Stopped watching folders for \"{name}\"",
|
||||
"scheduleSet": "Scheduled scan for \"{name}\": {value}",
|
||||
"scheduleOff": "Scheduled scan turned off for \"{name}\"",
|
||||
"saveFailed": "Could not update \"{name}\"",
|
||||
"fileWriteAria": "Write to file for {name}",
|
||||
"renameAria": "Rename files for {name}",
|
||||
"fileWriteOn": "\"{name}\" will write details into its files the next time they are saved",
|
||||
"fileWriteOff": "\"{name}\" will no longer write details into its files",
|
||||
"renameOn": "\"{name}\" will rename files to your naming pattern the next time their details are saved",
|
||||
"renameOff": "\"{name}\" will no longer rename files"
|
||||
},
|
||||
"historyFor": "Scan history for {name}",
|
||||
"scanNothingNew": "nothing new yet",
|
||||
"scanTimeLeftSeconds": "{count, plural, one {about # second left} other {about # seconds left}}",
|
||||
"scanTimeLeftMinutes": "{count, plural, one {about # minute left} other {about # minutes left}}",
|
||||
"scanFinished": "Scan finished for \"{name}\": {outcome}",
|
||||
"scanFinishedFailed": "Scan failed for \"{name}\"",
|
||||
"viewChanges": "View changes",
|
||||
"duplicateSettings": "Duplicate settings"
|
||||
},
|
||||
"authorEnrichment": {
|
||||
"configuration": "Configuration",
|
||||
@@ -9108,7 +9156,8 @@
|
||||
"fileSizeRange": "File-size limits must be whole numbers from 1 to 10,000 MB.",
|
||||
"saveFailed": "Failed to save library.",
|
||||
"connection": "Could not connect to the server. Check your connection and try again."
|
||||
}
|
||||
},
|
||||
"copyName": "{name} copy"
|
||||
},
|
||||
"folderPicker": {
|
||||
"title": "Browse server folders",
|
||||
|
||||
Reference in New Issue
Block a user