feat(library): per-library added_at source (imported/file_modified/file_created) (#1239)

* feat(scanner): set book added_at from earliest on-disk file mtime

A book's "date added" now reflects when its content files first landed on
disk (the earliest mtime across its content-role files) instead of the
moment BookOrbit imported it. Cover/metadata/supplement sidecars are
excluded. When no content file has a usable mtime, addedAt is left unset so
the DB defaultNow() fallback (import time) still applies. Only set at
initial book creation; re-scan/restore paths do not touch it.

* feat(library): add per-library added_at source with recompute

Add an addedAtSource setting to libraries so "date added" can follow the
book's files on disk rather than the moment the scanner happened to see
them. The setting takes imported (unchanged default), file_modified, or
file_created, guarded by a check constraint on the column.

Expose a recompute action so the chosen source can be applied to books
that are already imported. file_modified runs as a single set-based
update over the stored mtimes; file_created has to stat the files, since
birthtime is not persisted, so it batches those reads.

Leave every existing library on imported so behavior is unchanged unless
a user opts in.

* feat(library): add added_at source control to the library editor

Surface the new addedAtSource setting in the library editor as a radio
group, alongside a button that triggers the recompute for the library
being edited.

Keep the recompute out of the save path so changing the setting stays
cheap: the setting alone only affects books imported from then on, and
applying it to existing books is an explicit choice.

* i18n(library): translate added_at source strings (de/nl/pt/sl)

* fix(library): correct create payload, recompute floor and failure logging

The library creator posts its whole form, so omitting addedAtSource from
CreateLibraryDto made the global whitelist reject every create request
with a 400. Declare the field, validate it against the same source list
as the update DTO, and persist it so the initial scan uses the requested
source rather than silently falling back to imported.

Apply the scanner's usable-time floor to the mtime recompute query too.
It previously excluded only NULL, so a filesystem reporting epoch 0 would
rewrite added_at to 1970 instead of leaving the import time in place. The
floor now lives in library.constants rather than being spelled out twice.

Wrap the recompute in try/catch so a database or stat failure emits the
[fail] log the logging conventions require instead of propagating with
only a [start] line recorded.

* fix(library): gate recompute on the saved source and label the radios

The recompute button stayed enabled while the selected source differed
from the stored one. Because the request carries no source, clicking it
recomputed against the old stored value while the UI showed the new
selection. A hint already said to save first; disable the control as well
so the mismatch is not actionable.

Give the source list radiogroup semantics with aria-checked so screen
readers can tell which option is active, since selection was conveyed by
styling alone.

* test(library): assert the create DTO under the real validation options

The previous comment claimed the case exercised whitelist and
forbidNonWhitelisted, but the shared helper calls validate without them,
so it only proved @IsIn rejects an unknown value.

Drop the misleading comment and add a case that validates with the same
options main.ts gives ValidationPipe, so an undeclared addedAtSource
fails the way it would in a real request.

* docs(library): remove redundant date-added documentation

---------

Co-authored-by: Bob Bobber <user@example.com>
Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
This commit is contained in:
bobberb
2026-09-10 12:43:47 -04:00
committed by GitHub
parent c2f9eea839
commit 17aa7425f8
40 changed files with 20969 additions and 20 deletions
@@ -18,6 +18,7 @@ import {
X,
} from '@lucide/vue'
import type { CoverAspectRatio, Library, OrganizationMode } from '@bookorbit/types'
import { useLibraryAddedAt } from '../composables/useLibraryAddedAt'
import { useModal } from '@/composables/useModal'
import { api } from '@/lib/api'
import { useLibraryCreator, type LibraryCreatorSectionId } from '../composables/useLibraryCreator'
@@ -120,6 +121,7 @@ const initializing = ref(true)
const initializationWarning = ref<string | null>(null)
const initialFormSnapshot = ref('')
const nestedModalOpen = ref(false)
const addedAtRecompute = useLibraryAddedAt(editingLibraryId)
const attemptedSections = ref(new Set<LibraryCreatorSectionId>())
const sections = computed(() => (mode.value === 'create' ? ALL_SECTIONS.filter((section) => section.id !== 'access') : ALL_SECTIONS))
@@ -149,6 +151,12 @@ const sectionProps = computed(() => ({
organizationMode: form.organizationMode,
organizationModeLocked: mode.value === 'edit',
allowedFormats: form.allowedFormats,
addedAtSource: form.addedAtSource,
canRecomputeAddedAt: mode.value === 'edit',
storedAddedAtSource: creator.storedAddedAtSource.value,
recomputingAddedAt: addedAtRecompute.running.value,
recomputeJob: addedAtRecompute.job.value,
recomputeErrorKey: addedAtRecompute.errorKey.value,
excludePatterns: form.excludePatterns,
},
metadata: { metadataPrecedence: form.metadataPrecedence, formatPriority: form.formatPriority },
@@ -256,6 +264,14 @@ function handleOrganizationModeUpdate(value: OrganizationMode) {
form.organizationMode = value
}
function handleAddedAtSourceUpdate(value: Library['addedAtSource']) {
form.addedAtSource = value
}
function handleRecomputeAddedAt() {
if (!addedAtRecompute.running.value) void addedAtRecompute.start()
}
function handleNestedModalChange(value: boolean) {
nestedModalOpen.value = value
}
@@ -266,6 +282,8 @@ const sectionListeners = {
'update:coverAspectRatio': handleCoverAspectRatioUpdate,
'update:folders': handleFoldersUpdate,
'update:organizationMode': handleOrganizationModeUpdate,
'update:addedAtSource': handleAddedAtSourceUpdate,
recompute: handleRecomputeAddedAt,
'update:metadataPrecedence': (value: string[]) => (form.metadataPrecedence = value),
'update:formatPriority': (value: string[]) => (form.formatPriority = value),
'update:allowedFormats': (value: string[]) => (form.allowedFormats = value),
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Lock, Plus, X } from '@lucide/vue'
import type { OrganizationMode } from '@bookorbit/types'
import { Lock, Plus, RefreshCw, X } from '@lucide/vue'
import type { AddedAtRecomputeJob, AddedAtSource, OrganizationMode } from '@bookorbit/types'
import { formatNumber } from '@/i18n/formatters'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { FORMAT_LABELS } from '../composables/useLibraryCreator'
@@ -12,15 +13,69 @@ const props = defineProps<{
organizationMode: OrganizationMode
organizationModeLocked?: boolean
allowedFormats: string[]
addedAtSource: AddedAtSource
canRecomputeAddedAt?: boolean
storedAddedAtSource?: AddedAtSource | null
recomputingAddedAt?: boolean
recomputeJob?: AddedAtRecomputeJob | null
recomputeErrorKey?: string | null
excludePatterns: string[]
}>()
const emit = defineEmits<{
'update:organizationMode': [value: OrganizationMode]
'update:allowedFormats': [value: string[]]
'update:addedAtSource': [value: AddedAtSource]
'update:excludePatterns': [value: string[]]
recompute: []
}>()
const confirmingRecompute = ref(false)
const confirmButton = ref<HTMLButtonElement | null>(null)
const sourceGroup = ref<HTMLElement | null>(null)
const recomputeButton = ref<HTMLButtonElement | null>(null)
const progressText = computed(() => {
const job = props.recomputeJob
if (!job) return ''
const counters = ['processed', 'total', 'updated', 'unchanged', 'skipped', 'failed'] as const
return t('library.creator.scanner.addedAt.progressSummary', Object.fromEntries(counters.map((key) => [key, formatNumber(job[key])])))
})
const ADDED_AT_SOURCES: AddedAtSource[] = ['imported', 'file_modified', 'file_created']
const recomputeDisabled = computed(
() =>
props.recomputingAddedAt ||
!props.storedAddedAtSource ||
props.storedAddedAtSource === 'imported' ||
props.addedAtSource !== props.storedAddedAtSource,
)
const showSaveFirstHint = computed(() => props.canRecomputeAddedAt && props.addedAtSource !== props.storedAddedAtSource)
function selectAddedAtSource(source: AddedAtSource) {
emit('update:addedAtSource', source)
}
async function requestRecompute() {
confirmingRecompute.value = true
await nextTick()
confirmButton.value?.focus()
}
async function cancelRecompute() {
confirmingRecompute.value = false
await nextTick()
recomputeButton.value?.focus()
}
async function confirmRecompute() {
if (recomputeDisabled.value) return
confirmingRecompute.value = false
emit('recompute')
await nextTick()
sourceGroup.value?.querySelector<HTMLInputElement>('input:checked')?.focus()
}
// ── Scan mode ─────────────────────────────────────────────────────────────────
function handleSelectMode(mode: OrganizationMode) {
@@ -156,6 +211,100 @@ function onPatternKeydown(e: KeyboardEvent) {
</div>
</div>
<div>
<p id="added-at-title" class="text-[11px] font-semibold uppercase tracking-widest text-foreground mb-1">
{{ t('library.creator.scanner.addedAt.title') }}
</p>
<p id="added-at-hint" class="text-xs text-muted-foreground mb-3">{{ t('library.creator.scanner.addedAt.hint') }}</p>
<div ref="sourceGroup" role="radiogroup" aria-labelledby="added-at-title" aria-describedby="added-at-hint" class="space-y-2">
<label
v-for="source in ADDED_AT_SOURCES"
:key="source"
class="block w-full cursor-pointer rounded-lg border p-3 text-start transition-colors focus-within:ring-2 focus-within:ring-ring"
:class="addedAtSource === source ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'border-border bg-card hover:border-primary/40'"
>
<span class="mb-1 flex items-center gap-2">
<input
type="radio"
name="added-at-source"
:value="source"
:checked="addedAtSource === source"
class="accent-primary"
@change="selectAddedAtSource(source)"
/>
<span class="text-sm font-semibold text-foreground">{{ t(`library.creator.scanner.addedAt.options.${source}.title`) }}</span>
</span>
<span class="block ps-5.5 text-xs text-muted-foreground leading-relaxed">{{
t(`library.creator.scanner.addedAt.options.${source}.hint`)
}}</span>
</label>
</div>
<div v-if="canRecomputeAddedAt" class="mt-3 flex flex-wrap items-center gap-3">
<button
ref="recomputeButton"
type="button"
class="flex items-center gap-1.5 px-3 py-2 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
:disabled="recomputeDisabled"
@click="requestRecompute"
>
<RefreshCw :size="13" :class="recomputingAddedAt ? 'motion-safe:animate-spin' : ''" />
{{ recomputingAddedAt ? t('library.creator.scanner.addedAt.recomputing') : t('library.creator.scanner.addedAt.recompute') }}
</button>
<p class="text-xs text-muted-foreground">
{{ showSaveFirstHint ? t('library.creator.scanner.addedAt.recomputeSaveFirst') : t('library.creator.scanner.addedAt.recomputeHint') }}
</p>
</div>
<div
v-if="confirmingRecompute"
role="group"
:aria-label="t('library.creator.scanner.addedAt.recompute')"
class="mt-3 rounded-md border border-border p-3"
>
<p class="text-sm text-muted-foreground">{{ t('library.creator.scanner.addedAt.backgroundConfirm') }}</p>
<div class="mt-3 flex flex-wrap gap-2">
<button
ref="confirmButton"
type="button"
:disabled="recomputeDisabled"
class="rounded-md border border-border px-3 py-2 text-sm text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
@click="confirmRecompute"
>
{{ t('library.creator.scanner.addedAt.recompute') }}
</button>
<button
type="button"
class="rounded-md px-3 py-2 text-sm text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click="cancelRecompute"
>
{{ t('common.cancel') }}
</button>
</div>
</div>
<div v-if="recomputeJob" role="status" class="mt-3 space-y-2 text-sm text-muted-foreground">
<p>{{ t(`library.creator.scanner.addedAt.jobStatus.${recomputeJob.status}`) }}</p>
<progress
v-if="recomputeJob.status === 'running'"
:value="recomputeJob.total ? recomputeJob.processed : undefined"
:max="recomputeJob.total || 1"
:aria-label="t('library.creator.scanner.addedAt.recomputing')"
class="h-2 w-full accent-primary"
/>
<p>{{ progressText }}</p>
<p v-if="recomputeJob.failed > 0">{{ t('library.creator.scanner.addedAt.failedBooksHint') }}</p>
<ul v-if="recomputeJob.failureSamples.length" class="space-y-1">
<li v-for="failure in recomputeJob.failureSamples" :key="failure.bookId">
{{
t('library.creator.scanner.addedAt.failureSample', {
bookId: formatNumber(failure.bookId),
reason: t(`library.creator.scanner.addedAt.failureReasons.${failure.code}`),
})
}}
</li>
</ul>
</div>
<p v-if="recomputeErrorKey" role="alert" class="mt-3 text-sm text-destructive">{{ t(recomputeErrorKey) }}</p>
</div>
<!-- Filtering group -->
<div>
<!-- Allowed formats -->
@@ -1,5 +1,8 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Library } from '@bookorbit/types'
import { useLibraryCreator } from '../../composables/useLibraryCreator'
import LibraryCreatorScanner from '../LibraryCreatorScanner.vue'
import LibraryCreatorDetails from '../LibraryCreatorDetails.vue'
import LibraryCreatorModal from '../LibraryCreatorModal.vue'
@@ -14,6 +17,24 @@ describe('LibraryCreatorModal', () => {
vi.restoreAllMocks()
})
it('uses fetched saved settings for delegated managers whose library list omits the source', async () => {
const full = { ...useLibraryCreator().form, id: 7, name: 'Library', icon: 'BookOpen', folders: [], addedAtSource: 'file_created' }
apiMock.mockImplementation(
async (url) => ({ ok: true, json: async () => (String(url).endsWith('recompute-added-at') ? null : full) }) as Response,
)
const listEntry = { id: 7, name: 'Library', folders: [], coverAspectRatio: '2/3' } as unknown as Library
const wrapper = shallowMount(LibraryCreatorModal, { props: { library: listEntry }, global: { stubs: { teleport: true } } })
await flushPromises()
await wrapper
.findAll('nav button')
.find((button) => button.text().includes('Scanning'))!
.trigger('click')
const scanner = wrapper.getComponent(LibraryCreatorScanner)
expect(scanner.props('addedAtSource')).toBe('file_created')
expect(scanner.props('storedAddedAtSource')).toBe('file_created')
wrapper.unmount()
})
it('renders an accessible responsive dialog with required setup identified', async () => {
const wrapper = shallowMount(LibraryCreatorModal, {
attachTo: document.body,
@@ -15,6 +15,7 @@ function mountScanner(overrides: Partial<InstanceType<typeof LibraryCreatorScann
organizationMode: 'book_per_folder',
organizationModeLocked: false,
allowedFormats: [],
addedAtSource: 'imported',
excludePatterns: [],
...overrides,
},
@@ -45,4 +46,41 @@ describe('LibraryCreatorScanner', () => {
expect(wrapper.emitted('update:organizationMode')).toBeUndefined()
})
it('enables recompute only for saved file sources and requires confirmation', async () => {
const wrapper = mountScanner({ addedAtSource: 'file_created', storedAddedAtSource: 'file_created', canRecomputeAddedAt: true })
const recompute = wrapper.findAll('button').find((button) => button.text() === 'Recompute now')!
expect(recompute.attributes('disabled')).toBeUndefined()
await recompute.trigger('click')
expect(wrapper.emitted('recompute')).toBeUndefined()
await wrapper.get('[role="group"] button').trigger('click')
expect(wrapper.emitted('recompute')).toEqual([[]])
await wrapper.setProps({ addedAtSource: 'file_modified' })
expect(recompute.attributes('disabled')).toBeDefined()
wrapper.unmount()
})
it('uses native radios and exposes partial failures as accessible status', async () => {
const wrapper = mountScanner({
addedAtSource: 'file_created',
recomputeJob: {
id: 'job',
libraryId: 1,
source: 'file_created',
status: 'completed',
total: 2,
processed: 2,
updated: 1,
unchanged: 0,
skipped: 0,
failed: 1,
failureSamples: [],
},
})
const radios = wrapper.findAll('input[type="radio"]')
expect(radios).toHaveLength(3)
await radios[1]!.setValue()
expect(wrapper.emitted('update:addedAtSource')).toEqual([['file_modified']])
expect(wrapper.get('[role="status"]').text()).toContain('1 failed')
wrapper.unmount()
})
})
@@ -20,6 +20,7 @@ function makeLibrary(overrides: Partial<Library> = {}): Library {
formatPriority: [],
allowedFormats: [],
organizationMode: 'book_per_file',
addedAtSource: 'imported',
excludePatterns: [],
readingThreshold: 10,
markAsFinishedPercentComplete: 95,
@@ -0,0 +1,121 @@
import { effectScope, ref } from 'vue'
import { flushPromises } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AddedAtRecomputeJob } from '@bookorbit/types'
import { useLibraryAddedAt } from '../useLibraryAddedAt'
const apiMock = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<{ ok: boolean; json: () => Promise<unknown> }>>())
vi.mock('@/lib/api', () => ({ api: apiMock }))
const job: AddedAtRecomputeJob = {
id: 'job',
libraryId: 1,
source: 'file_modified',
status: 'running',
total: 100,
processed: 0,
updated: 0,
unchanged: 0,
skipped: 0,
failed: 0,
failureSamples: [],
}
const response = (value: unknown, ok = true) => ({ ok, json: async () => value })
let scope: ReturnType<typeof effectScope>
beforeEach(() => {
vi.useFakeTimers()
apiMock.mockReset()
scope = effectScope()
})
afterEach(() => {
scope.stop()
vi.useRealTimers()
})
function setup() {
const id = ref<number | null>(1)
const state = scope.run(() => useLibraryAddedAt(id))!
return { ...state, id }
}
describe('useLibraryAddedAt', () => {
it('resumes an active background job and stops polling when complete', async () => {
apiMock.mockResolvedValueOnce(response(job)).mockResolvedValueOnce(response({ ...job, status: 'completed', processed: 100, updated: 100 }))
const state = setup()
await flushPromises()
expect(state.running.value).toBe(true)
await vi.advanceTimersByTimeAsync(1000)
expect(state.job.value).toMatchObject({ status: 'completed', processed: 100 })
expect(state.running.value).toBe(false)
await vi.advanceTimersByTimeAsync(10_000)
expect(apiMock).toHaveBeenCalledTimes(2)
})
it('starts a job and does not submit duplicates while running', async () => {
apiMock.mockResolvedValueOnce(response(null)).mockResolvedValue(response(job))
const state = setup()
await flushPromises()
await state.start()
await state.start()
expect(apiMock).toHaveBeenCalledTimes(2)
expect(apiMock.mock.calls[1][1]).toMatchObject({ method: 'POST' })
expect(state.running.value).toBe(true)
})
it('localizes structured errors without displaying backend text', async () => {
apiMock
.mockResolvedValueOnce(response(null))
.mockResolvedValueOnce(response({ errorCode: 'ADDED_AT_BUSY', message: 'Internal server detail' }, false))
const state = setup()
await flushPromises()
await state.start()
expect(state.errorKey.value).toBe('library.creator.scanner.addedAt.errors.busy')
expect(state.running.value).toBe(false)
})
it('retains progress through a polling failure and reconnects', async () => {
apiMock
.mockResolvedValueOnce(response(job))
.mockRejectedValueOnce(new Error('network'))
.mockResolvedValue(response({ ...job, processed: 50 }))
const state = setup()
await flushPromises()
await vi.advanceTimersByTimeAsync(1000)
expect(state.job.value?.processed).toBe(0)
expect(state.errorKey.value).toBeTruthy()
await vi.advanceTimersByTimeAsync(5000)
expect(state.job.value?.processed).toBe(50)
expect(state.errorKey.value).toBeNull()
})
it('reports lost job state after a server restart instead of claiming success', async () => {
apiMock.mockResolvedValueOnce(response(job)).mockResolvedValueOnce(response(null))
const state = setup()
await flushPromises()
await vi.advanceTimersByTimeAsync(1000)
expect(state.running.value).toBe(false)
expect(state.errorKey.value).toBe('library.creator.scanner.addedAt.errors.interrupted')
})
it('ignores a stale response after switching libraries and aborts on disposal', async () => {
let resolveOld!: (value: ReturnType<typeof response>) => void
apiMock
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveOld = resolve
}),
)
.mockResolvedValueOnce(response({ ...job, id: 'new', libraryId: 2 }))
const state = setup()
state.id.value = 2
await flushPromises()
resolveOld(response(job))
await flushPromises()
expect(state.job.value?.libraryId).toBe(2)
scope.stop()
expect(apiMock.mock.calls[1]?.[1]?.signal?.aborted).toBe(true)
await vi.advanceTimersByTimeAsync(10_000)
expect(apiMock).toHaveBeenCalledTimes(2)
})
})
@@ -140,6 +140,7 @@ describe('useLibraryCreator', () => {
formatPriority: ['epub'],
allowedFormats: ['epub'],
organizationMode: 'book_per_folder',
addedAtSource: 'imported',
excludePatterns: [],
readingThreshold: 0.25,
markAsFinishedPercentComplete: 98,
@@ -252,6 +253,7 @@ function makeLibrary(overrides: Partial<Library> = {}): Library {
formatPriority: ['epub'],
allowedFormats: ['epub'],
organizationMode: 'book_per_folder',
addedAtSource: 'imported',
excludePatterns: [],
readingThreshold: 0.25,
markAsFinishedPercentComplete: 98,
@@ -0,0 +1,84 @@
import { computed, onScopeDispose, ref, watch, type Ref } from 'vue'
import type { AddedAtRecomputeJob } from '@bookorbit/types'
import { api } from '@/lib/api'
const ERROR_KEYS: Record<string, string> = {
ADDED_AT_SOURCE_IMPORTED: 'sourceImported',
ADDED_AT_ALREADY_RUNNING: 'alreadyRunning',
ADDED_AT_BUSY: 'busy',
}
const ERROR_PREFIX = 'library.creator.scanner.addedAt.errors.'
export function useLibraryAddedAt(libraryId: Ref<number | null>) {
const job = ref<AddedAtRecomputeJob | null>(null)
const starting = ref(false)
const errorKey = ref<string | null>(null)
const running = computed(() => starting.value || job.value?.status === 'running')
let timer: ReturnType<typeof setTimeout> | undefined
let controller: AbortController | undefined
let generation = 0
let disposed = false
function cancelRequest() {
clearTimeout(timer)
controller?.abort()
generation++
}
async function request(method: 'GET' | 'POST') {
cancelRequest()
const id = libraryId.value
if (id === null || disposed) return
const current = generation
const currentController = new AbortController()
controller = currentController
const signal = currentController.signal
const timeout = setTimeout(() => currentController.abort(), 10_000)
starting.value = method === 'POST'
errorKey.value = null
try {
const response = await api(`/api/v1/libraries/${id}/recompute-added-at`, { method, signal })
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as { errorCode?: string }
if (current === generation) errorKey.value = ERROR_PREFIX + (ERROR_KEYS[body.errorCode ?? ''] ?? 'requestFailed')
return
}
const result = (await response.json()) as AddedAtRecomputeJob | null
if (current !== generation) return
if (!result && job.value?.status === 'running') errorKey.value = ERROR_PREFIX + 'interrupted'
job.value = result
} catch {
if (current === generation) errorKey.value = ERROR_PREFIX + 'requestFailed'
} finally {
clearTimeout(timeout)
if (current === generation && !disposed) {
starting.value = false
if (job.value?.status === 'running') timer = setTimeout(refresh, errorKey.value ? 5000 : 1000)
}
}
}
function refresh() {
return request('GET')
}
function start() {
if (running.value) return Promise.resolve()
return request('POST')
}
watch(
libraryId,
() => {
job.value = null
void refresh()
},
{ immediate: true },
)
onScopeDispose(() => {
disposed = true
cancelRequest()
})
return { job, running, errorKey, start, refresh }
}
@@ -1,7 +1,7 @@
import { computed, reactive, ref } from 'vue'
import { api } from '@/lib/api'
import { DEFAULT_FORMAT_PRIORITY, FORMAT_LABELS, isFiveFieldCronExpression } from '@bookorbit/types'
import type { CoverAspectRatio, Library, OrganizationMode, PrescanResult } from '@bookorbit/types'
import type { AddedAtSource, CoverAspectRatio, Library, OrganizationMode, PrescanResult } from '@bookorbit/types'
export { DEFAULT_FORMAT_PRIORITY, FORMAT_LABELS }
@@ -30,6 +30,7 @@ function blankForm() {
formatPriority: [...DEFAULT_FORMAT_PRIORITY] as string[],
allowedFormats: [] as string[],
organizationMode: 'book_per_folder' as OrganizationMode,
addedAtSource: 'imported' as AddedAtSource,
excludePatterns: [] as string[],
readingThreshold: 0.25,
markAsFinishedPercentComplete: 98,
@@ -59,6 +60,7 @@ export function useLibraryCreator() {
const prescanLoading = ref(false)
const prescanResult = ref<PrescanResult | null>(null)
const error = ref<string | null>(null)
const storedAddedAtSource = ref<AddedAtSource | null>(null)
const validationErrors = computed<Partial<Record<LibraryCreatorSectionId, string>>>(() => {
const errors: Partial<Record<LibraryCreatorSectionId, string>> = {}
@@ -91,6 +93,7 @@ export function useLibraryCreator() {
})
function initCreate() {
storedAddedAtSource.value = null
Object.assign(form, blankForm())
mode.value = 'create'
editingLibraryId.value = null
@@ -111,6 +114,8 @@ export function useLibraryCreator() {
form.formatPriority = [...library.formatPriority, ...missing]
form.allowedFormats = [...library.allowedFormats]
form.organizationMode = library.organizationMode
form.addedAtSource = library.addedAtSource
storedAddedAtSource.value = library.addedAtSource
form.excludePatterns = [...library.excludePatterns]
form.readingThreshold = library.readingThreshold
form.markAsFinishedPercentComplete = library.markAsFinishedPercentComplete
@@ -206,6 +211,7 @@ export function useLibraryCreator() {
return {
form,
storedAddedAtSource,
mode,
editingLibraryId,
loading,
@@ -76,6 +76,7 @@ function makeLibrary(overrides: Partial<Library> = {}): Library {
formatPriority: [],
allowedFormats: [],
organizationMode: 'book_per_file',
addedAtSource: 'imported',
excludePatterns: [],
readingThreshold: 10,
markAsFinishedPercentComplete: 90,
@@ -17,6 +17,7 @@ function library(overrides: Partial<Library> & Pick<Library, 'id' | 'name'>): Li
formatPriority: [],
allowedFormats: [],
organizationMode: 'book_per_folder',
addedAtSource: 'imported',
excludePatterns: [],
readingThreshold: 10,
markAsFinishedPercentComplete: 90,
@@ -93,6 +93,7 @@ function makeLibrary(): Library {
formatPriority: [],
allowedFormats: [],
organizationMode: 'book_per_file',
addedAtSource: 'imported',
excludePatterns: [],
readingThreshold: 10,
markAsFinishedPercentComplete: 90,
+44
View File
@@ -4127,6 +4127,7 @@
"LibraryAccessRevoke": "Revoke library access",
"LibraryWriteMetadataToFiles": "Write metadata to files",
"LibraryBulkRename": "Rename library files",
"LibraryRecomputeAddedAt": "Recompute date added",
"BookUpload": "Upload book",
"BookMetadataUpdate": "Update book metadata",
"BookMetadataLocksUpdate": "Update metadata locks",
@@ -7975,6 +7976,49 @@
"hint": "Treats every file as an individual book. Best suited for libraries with one format per title. Avoid if your audiobooks span multiple files."
}
},
"addedAt": {
"title": "Date added",
"hint": "Choose what a book's \"date added\" reflects. This applies to books imported from now on.",
"options": {
"imported": {
"title": "When imported",
"hint": "The date BookOrbit first imported the book. This is the default."
},
"file_modified": {
"title": "File modified time",
"hint": "The file's last-modified time (mtime), often preserved when files are copied."
},
"file_created": {
"title": "File creation time",
"hint": "When the file first landed on disk (birthtime). Falls back to modified time on filesystems that don't record creation time."
}
},
"recompute": "Recompute now",
"recomputing": "Recomputing...",
"recomputeHint": "Update the date added for existing books using this setting.",
"recomputeSaveFirst": "Save your changes first, then recompute existing books.",
"backgroundConfirm": "Recompute dates for existing books? This runs in the background, and you can close this editor. Books with unreadable files keep their current date.",
"progressSummary": "Processed {processed} of {total}: {updated} updated, {unchanged} unchanged, {skipped} skipped, {failed} failed.",
"failedBooksHint": "Failed books kept their dates. Check file access, then run recompute again to retry.",
"jobStatus": {
"running": "Recomputing dates in the background.",
"completed": "Date recompute finished.",
"failed": "Recompute stopped before finishing. Completed changes were kept; you can safely run it again."
},
"errors": {
"sourceImported": "Save a file timestamp source before recomputing dates.",
"alreadyRunning": "A recompute is already running for this library.",
"busy": "Other recomputes are running. Try again shortly.",
"requestFailed": "Could not reach the recompute service. Close and reopen this editor to check its status.",
"interrupted": "The job status is no longer available. It may have been interrupted by a server restart. You can safely run recompute again."
},
"failureSample": "Book {bookId}: {reason}",
"failureReasons": {
"file_unavailable": "A content file could not be read.",
"unsafe_path": "A content file is outside its permitted library folder.",
"database_error": "The database update failed."
}
},
"allowedFormats": {
"title": "Allowed formats",
"allowAll": "Allow all",