mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
Reference in New Issue
Block a user