chore: merge origin/main into main

Preserve both divergent commits while reconciling the local and remote histories.
This commit is contained in:
neon
2026-09-10 16:45:53 +00:00
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",