mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
chore: merge origin/main into main
Preserve both divergent commits while reconciling the local and remote histories.
This commit is contained in:
@@ -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,
|
||||
|
||||
+1
@@ -93,6 +93,7 @@ function makeLibrary(): Library {
|
||||
formatPriority: [],
|
||||
allowedFormats: [],
|
||||
organizationMode: 'book_per_file',
|
||||
addedAtSource: 'imported',
|
||||
excludePatterns: [],
|
||||
readingThreshold: 10,
|
||||
markAsFinishedPercentComplete: 90,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user