fix(library): respect locale for cron time display

Derive the clock convention from the active locale so custom scan schedules match the rest of the interface.

Closes #1391
This commit is contained in:
neon
2026-09-13 17:59:10 +00:00
parent 614194202b
commit 2f22abe03e
5 changed files with 30 additions and 25 deletions
@@ -2,11 +2,11 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Eye } from '@lucide/vue' import { Eye } from '@lucide/vue'
import cronstrue from 'cronstrue'
import { isFiveFieldCronExpression } from '@bookorbit/types' import { isFiveFieldCronExpression } from '@bookorbit/types'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue' import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { parseCronToHuman } from '@/features/library/utils/cron'
const { t } = useI18n() const { t, locale } = useI18n()
const props = defineProps<{ const props = defineProps<{
watch: boolean watch: boolean
@@ -70,11 +70,8 @@ function humanReadableCron(cron: string | null): string {
'0 0 * * 1': t('library.creator.schedule.human.weeklyMonday'), '0 0 * * 1': t('library.creator.schedule.human.weeklyMonday'),
} }
if (map[cron]) return map[cron] if (map[cron]) return map[cron]
try { const description = parseCronToHuman(cron, locale.value)
return cronstrue.toString(cron) return !description || description === cron ? t('library.creator.schedule.human.invalid') : description
} catch {
return t('library.creator.schedule.human.invalid')
}
} }
</script> </script>
@@ -8,7 +8,7 @@ describe('isFiveFieldCronExpression', () => {
'accepts %s and previews it', 'accepts %s and previews it',
(expression) => { (expression) => {
expect(isFiveFieldCronExpression(expression)).toBe(true) expect(isFiveFieldCronExpression(expression)).toBe(true)
expect(parseCronToHuman(expression)).not.toBe(expression) expect(parseCronToHuman(expression, 'en')).not.toBe(expression)
}, },
) )
@@ -22,48 +22,55 @@ describe('isFiveFieldCronExpression', () => {
describe('parseCronToHuman', () => { describe('parseCronToHuman', () => {
it('returns null for null input', () => { it('returns null for null input', () => {
expect(parseCronToHuman(null)).toBeNull() expect(parseCronToHuman(null, 'en')).toBeNull()
}) })
it('returns null for undefined input', () => { it('returns null for undefined input', () => {
expect(parseCronToHuman(undefined)).toBeNull() expect(parseCronToHuman(undefined, 'en')).toBeNull()
}) })
it('returns null for empty string', () => { it('returns null for empty string', () => {
expect(parseCronToHuman('')).toBeNull() expect(parseCronToHuman('', 'en')).toBeNull()
}) })
it('parses a daily cron to a human-readable string', () => { it('uses a 12-hour clock for locales that prefer it', () => {
const result = parseCronToHuman('0 2 * * *') const result = parseCronToHuman('17 14 * * *', 'en')
expect(result).not.toBeNull() expect(result).not.toBeNull()
expect(result!.toLowerCase()).toContain('02:00 am') expect(result!.toLowerCase()).toContain('02:17 pm')
})
it('uses a 24-hour clock for locales that prefer it', () => {
const result = parseCronToHuman('17 14 * * *', 'nl')
expect(result).not.toBeNull()
expect(result).toContain('14:17')
expect(result).not.toMatch(/am|pm/i)
}) })
it('parses an every-5-minutes cron', () => { it('parses an every-5-minutes cron', () => {
const result = parseCronToHuman('*/5 * * * *') const result = parseCronToHuman('*/5 * * * *', 'en')
expect(result).not.toBeNull() expect(result).not.toBeNull()
expect(result!.toLowerCase()).toContain('every 5 minutes') expect(result!.toLowerCase()).toContain('every 5 minutes')
}) })
it('parses a weekly cron', () => { it('parses a weekly cron', () => {
const result = parseCronToHuman('0 0 * * 1') const result = parseCronToHuman('0 0 * * 1', 'en')
expect(result).not.toBeNull() expect(result).not.toBeNull()
expect(result!.toLowerCase()).toContain('monday') expect(result!.toLowerCase()).toContain('monday')
}) })
it('falls back to the raw cron string on invalid input', () => { it('falls back to the raw cron string on invalid input', () => {
const garbage = 'not-a-cron-at-all' const garbage = 'not-a-cron-at-all'
expect(parseCronToHuman(garbage)).toBe(garbage) expect(parseCronToHuman(garbage, 'en')).toBe(garbage)
}) })
it('parses a monthly cron', () => { it('parses a monthly cron', () => {
const result = parseCronToHuman('0 0 1 * *') const result = parseCronToHuman('0 0 1 * *', 'en')
expect(result).not.toBeNull() expect(result).not.toBeNull()
expect(result!.toLowerCase()).toContain('day 1') expect(result!.toLowerCase()).toContain('day 1')
}) })
it('parses an hourly cron', () => { it('parses an hourly cron', () => {
const result = parseCronToHuman('0 * * * *') const result = parseCronToHuman('0 * * * *', 'en')
expect(result).not.toBeNull() expect(result).not.toBeNull()
expect(result!.toLowerCase()).toContain('every hour') expect(result!.toLowerCase()).toContain('every hour')
}) })
+3 -2
View File
@@ -1,9 +1,10 @@
import cronstrue from 'cronstrue' import cronstrue from 'cronstrue'
export function parseCronToHuman(cron: string | null | undefined): string | null { export function parseCronToHuman(cron: string | null | undefined, locale: string): string | null {
if (!cron) return null if (!cron) return null
try { try {
return cronstrue.toString(cron, { use24HourTimeFormat: false, verbose: false }) const use24HourTimeFormat = new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hour12 === false
return cronstrue.toString(cron, { use24HourTimeFormat, verbose: false })
} catch { } catch {
return cron return cron
} }
@@ -7,14 +7,14 @@ import { parseCronToHuman } from '@/features/library/utils/cron'
const props = defineProps<{ library: Library }>() const props = defineProps<{ library: Library }>()
const { t } = useI18n() const { t, locale } = useI18n()
/** /**
* Every setting renders in both states with its real value, so a schedule reads as its actual * Every setting renders in both states with its real value, so a schedule reads as its actual
* cron description rather than an icon, and "off" is stated rather than left out. * cron description rather than an icon, and "off" is stated rather than left out.
*/ */
const rows = computed(() => { const rows = computed(() => {
const schedule = parseCronToHuman(props.library.autoScanCronExpression) const schedule = parseCronToHuman(props.library.autoScanCronExpression, locale.value)
const off = t('settings.admin.libraries.stateOff') const off = t('settings.admin.libraries.stateOff')
const on = t('settings.admin.libraries.stateOn') const on = t('settings.admin.libraries.stateOn')
return [ return [
@@ -7,14 +7,14 @@ import { parseCronToHuman } from '@/features/library/utils/cron'
const props = defineProps<{ library: Library }>() const props = defineProps<{ library: Library }>()
const { t } = useI18n() const { t, locale } = useI18n()
/** /**
* Every slot renders in both states. An omitted chip and a disabled setting used to look the same, * Every slot renders in both states. An omitted chip and a disabled setting used to look the same,
* which made a library's configuration unreadable from the list. * which made a library's configuration unreadable from the list.
*/ */
const slots = computed(() => { const slots = computed(() => {
const schedule = parseCronToHuman(props.library.autoScanCronExpression) const schedule = parseCronToHuman(props.library.autoScanCronExpression, locale.value)
return [ return [
{ key: 'watch', icon: Eye, on: props.library.watch, label: t('settings.admin.libraries.capability.watch'), detail: null }, { key: 'watch', icon: Eye, on: props.library.watch, label: t('settings.admin.libraries.capability.watch'), detail: null },
{ key: 'schedule', icon: CalendarClock, on: Boolean(schedule), label: t('settings.admin.libraries.capability.schedule'), detail: schedule }, { key: 'schedule', icon: CalendarClock, on: Boolean(schedule), label: t('settings.admin.libraries.capability.schedule'), detail: schedule },