mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(library): run scans on the stored auto-scan schedule
The auto-scan cron expression was validated, persisted and shown in the UI, but nothing ever consumed it, so a library configured with a schedule never scanned and no scan job was ever recorded with the schedule trigger. Register a cron job per configured library on boot and keep it in step as libraries are created, updated and deleted. An occurrence that lands while a scan is already running is skipped rather than retried, and a failing occurrence leaves the job registered so later ones still fire. Schedules run in the server timezone, matching the cleanup, audit and statistics jobs, so a deployment that sets TZ gets the wall-clock time its users expect. Move cron validation into a single shared grammar used by both the API and the schedule editor. The two sides previously disagreed in both directions: the UI accepted step-zero and reversed ranges that the API rejected, and rejected day names the API accepted. The grammar is a strict subset of what the scheduler and the human-readable preview can each parse, so an expression the editor marks valid can always be scheduled. Closes #985
This commit is contained in:
@@ -3,6 +3,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Eye } from '@lucide/vue'
|
||||
import cronstrue from 'cronstrue'
|
||||
import { isFiveFieldCronExpression } from '@bookorbit/types'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -27,8 +28,6 @@ const presets = computed(() => [
|
||||
{ label: t('library.creator.schedule.presets.custom'), value: '__custom__' },
|
||||
])
|
||||
|
||||
const CRON_REGEX = /^((\*|\d+(-\d+)?(,\d+(-\d+)?)*)(\/\d+)? ){4}(\*|\d+(-\d+)?(,\d+(-\d+)?)*)(\/\d+)?$/
|
||||
|
||||
const isCustom = computed(() => {
|
||||
if (props.autoScanCronExpression === null) return false
|
||||
return !presets.value.some((p) => p.value === props.autoScanCronExpression)
|
||||
@@ -36,7 +35,7 @@ const isCustom = computed(() => {
|
||||
|
||||
const isCronValid = computed(() => {
|
||||
if (!isCustom.value || !props.autoScanCronExpression) return true
|
||||
return CRON_REGEX.test(props.autoScanCronExpression)
|
||||
return isFiveFieldCronExpression(props.autoScanCronExpression)
|
||||
})
|
||||
|
||||
const selectedPreset = computed(() => {
|
||||
@@ -74,7 +73,7 @@ function humanReadableCron(cron: string | null): string {
|
||||
try {
|
||||
return cronstrue.toString(cron)
|
||||
} catch {
|
||||
return 'Enter a valid schedule to see a preview'
|
||||
return t('library.creator.schedule.human.invalid')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -98,6 +97,7 @@ function humanReadableCron(cron: string | null): string {
|
||||
<!-- Auto-scan schedule -->
|
||||
<div>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-widest text-foreground mb-3">{{ t('library.creator.schedule.autoScanSchedule') }}</p>
|
||||
<p class="mb-3 text-xs text-muted-foreground">{{ t('library.creator.schedule.timezoneHint') }}</p>
|
||||
<div class="mb-4 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<button
|
||||
v-for="preset in presets"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import { DEFAULT_FORMAT_PRIORITY, FORMAT_LABELS } from '@bookorbit/types'
|
||||
import { DEFAULT_FORMAT_PRIORITY, FORMAT_LABELS, isFiveFieldCronExpression } from '@bookorbit/types'
|
||||
import type { CoverAspectRatio, Library, OrganizationMode, PrescanResult } from '@bookorbit/types'
|
||||
|
||||
export { DEFAULT_FORMAT_PRIORITY, FORMAT_LABELS }
|
||||
@@ -14,7 +14,6 @@ export const METADATA_LABELS: Record<string, string> = {
|
||||
|
||||
export type LibraryCreatorSectionId = 'details' | 'folders' | 'scanner' | 'metadata' | 'reading' | 'schedule' | 'fileWrite' | 'access'
|
||||
|
||||
const CRON_REGEX = /^((\*|\d+(-\d+)?(,\d+(-\d+)?)*)(\/\d+)? ){4}(\*|\d+(-\d+)?(,\d+(-\d+)?)*)(\/\d+)?$/
|
||||
const FILE_SIZE_MIN_MB = 1
|
||||
const FILE_SIZE_MAX_MB = 10_000
|
||||
|
||||
@@ -66,7 +65,7 @@ export function useLibraryCreator() {
|
||||
if (!form.name.trim()) errors.details = 'Enter a library name.'
|
||||
else if (!form.icon?.trim()) errors.details = 'Choose an icon.'
|
||||
if (form.folders.length === 0) errors.folders = 'Add at least one folder.'
|
||||
if (form.autoScanCronExpression && !CRON_REGEX.test(form.autoScanCronExpression)) {
|
||||
if (form.autoScanCronExpression && !isFiveFieldCronExpression(form.autoScanCronExpression)) {
|
||||
errors.schedule = 'Enter a valid 5-field cron expression.'
|
||||
}
|
||||
if (form.readingThreshold < 0.05 || form.readingThreshold > 5) {
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isFiveFieldCronExpression } from '@bookorbit/types'
|
||||
|
||||
import { parseCronToHuman } from '../cron'
|
||||
|
||||
describe('isFiveFieldCronExpression', () => {
|
||||
it.each(['0 4 * * *', '*/30 * * * *', '0 0 * * 1', '0 4 * * MON', '0 4 * * *', '1-5/2 * * * *', '0 4 * jan-mar *'])(
|
||||
'accepts %s and previews it',
|
||||
(expression) => {
|
||||
expect(isFiveFieldCronExpression(expression)).toBe(true)
|
||||
expect(parseCronToHuman(expression)).not.toBe(expression)
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['not a cron', '0 99 * * *', '* * * * * *', '*/0 * * * *', '5-1 * * * *', '0 4 */0 * *', '@daily', '0 4 * * FRI-MON'])(
|
||||
'rejects %s',
|
||||
(expression) => {
|
||||
expect(isFiveFieldCronExpression(expression)).toBe(false)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('parseCronToHuman', () => {
|
||||
it('returns null for null input', () => {
|
||||
expect(parseCronToHuman(null)).toBeNull()
|
||||
|
||||
@@ -5672,6 +5672,7 @@
|
||||
"schedule": {
|
||||
"fileWatching": "File watching",
|
||||
"autoScanSchedule": "Auto-scan schedule",
|
||||
"timezoneHint": "Schedules run in the server's timezone.",
|
||||
"cronExpression": "Cron expression",
|
||||
"cronInvalid": "Invalid cron expression.",
|
||||
"cronFormat": "Format: minute hour day month weekday",
|
||||
@@ -5690,6 +5691,7 @@
|
||||
},
|
||||
"human": {
|
||||
"disabled": "Auto-scan disabled",
|
||||
"invalid": "Enter a valid schedule to see a preview",
|
||||
"hourly": "Every hour",
|
||||
"every6Hours": "Every 6 hours",
|
||||
"every12Hours": "Every 12 hours",
|
||||
|
||||
Reference in New Issue
Block a user