cc-haha/desktop/src/lib/cronDescribe.ts
程序员阿江(Relakkes) 8888e1c3fb chore: comprehensive code review fixes — security, perf, leaks, quality, docs
- security: XSS sanitization with DOMPurify in Markdown/Mermaid/PermissionDialog;
  path whitelist in filesystem API; fake keys in test/config files
- perf: fine-grained Zustand selectors in Sidebar/StatusBar/ContentRouter;
  50ms throttle on streaming deltas; React.memo + useMemo in MessageList;
  useRef for frequent keyboard shortcut state; AbortController 30s timeout
- leaks: WS session TTL timers (5-min cleanup on close); batch splice for
  sdkMessages/stderrLines; folderPath validation in cronScheduler
- quality: optimistic update rollback in settingsStore; error state in
  providerStore/teamStore; i18n for all hardcoded English strings
- docs: desktop architecture and features docs updated; VitePress nav fixed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:27:17 +08:00

189 lines
5.7 KiB
TypeScript

/**
* Cron expression utilities: human-readable description & validation.
* Works with standard 5-field cron: minute hour day-of-month month day-of-week
*/
import type { TranslationKey } from '../i18n'
type TFunc = (key: TranslationKey, params?: Record<string, string | number>) => string
function pad(n: number): string {
return n.toString().padStart(2, '0')
}
function formatTime(hour: number, minute: number): string {
return `${pad(hour)}:${pad(minute)}`
}
function describeDow(field: string, t: TFunc): string {
const parts = field.split(',')
const days: number[] = []
for (const part of parts) {
const range = part.match(/^(\d+)-(\d+)$/)
if (range) {
const start = parseInt(range[1]!)
const end = parseInt(range[2]!)
for (let i = start; i <= end; i++) days.push(i)
} else {
days.push(parseInt(part))
}
}
return days.map((d) => t(`cron.dow.${d % 7}` as any)).join(', ') // dynamic key
}
export function describeCron(cron: string, t: TFunc): string {
const fields = cron.trim().split(/\s+/)
if (fields.length !== 5) return t('cron.customSchedule', { cron })
const min = fields[0]!
const hour = fields[1]!
const dom = fields[2]!
const month = fields[3]!
const dow = fields[4]!
// */N * * * * → every N minutes
if (hour === '*' && dom === '*' && month === '*' && dow === '*') {
const stepMatch = min.match(/^\*\/(\d+)$/)
if (stepMatch) {
const n = parseInt(stepMatch[1]!)
if (n === 1) return t('cron.everyMinute')
return t('cron.everyNMinutes', { n })
}
if (min === '*') return t('cron.everyMinute')
}
// M */N * * * → every N hours (optionally at :M)
if (/^\d+$/.test(min) && dom === '*' && month === '*' && dow === '*') {
const hourStep = hour.match(/^\*\/(\d+)$/)
if (hourStep) {
const n = parseInt(hourStep[1]!)
const m = parseInt(min)
if (m === 0) {
if (n === 1) return t('cron.everyHour')
return t('cron.everyNHours', { n })
}
return t('cron.everyNHoursAtMinute', { n, m: pad(m) })
}
}
// M H * * <dow> → daily / weekdays / specific days
if (/^\d+$/.test(min) && /^\d+$/.test(hour) && dom === '*' && month === '*') {
const time = formatTime(parseInt(hour), parseInt(min))
if (dow === '*') {
return t('cron.dailyAt', { time })
}
if (dow === '1-5') {
return t('cron.weekdaysAt', { time })
}
if (/^[\d,\-]+$/.test(dow)) {
const days = describeDow(dow, t)
return t('cron.specificDaysAt', { days, time })
}
}
// M H D * * → monthly on day D
if (/^\d+$/.test(min) && /^\d+$/.test(hour) && /^\d+$/.test(dom) && month === '*' && dow === '*') {
const time = formatTime(parseInt(hour), parseInt(min))
return t('cron.monthlyAt', { day: parseInt(dom), time })
}
return t('cron.customSchedule', { cron })
}
/**
* Reverse-parse a cron expression back into UI-friendly state
* for the task edit modal.
*/
export type FrequencyKey = 'everyNMinutes' | 'everyNHours' | 'daily' | 'weekdays' | 'specificDays' | 'monthly' | 'customCron'
export type ParsedCron = {
frequency: FrequencyKey
time: string
minuteInterval: number
hourInterval: number
minuteOffset: number
selectedDays: number[]
monthDay: number
customCron: string
}
const DEFAULTS: ParsedCron = {
frequency: 'customCron',
time: '09:00',
minuteInterval: 15,
hourInterval: 1,
minuteOffset: 0,
selectedDays: [1],
monthDay: 1,
customCron: '',
}
export function parseCron(cron: string): ParsedCron {
const fields = cron.trim().split(/\s+/)
if (fields.length !== 5) return { ...DEFAULTS, customCron: cron }
const min = fields[0]!
const hour = fields[1]!
const dom = fields[2]!
const month = fields[3]!
const dow = fields[4]!
// */N * * * * → everyNMinutes
if (/^\*\/\d+$/.test(min) && hour === '*' && dom === '*' && month === '*' && dow === '*') {
return { ...DEFAULTS, frequency: 'everyNMinutes', minuteInterval: parseInt(min.split('/')[1]!) }
}
// M */N * * * → everyNHours
if (/^\d+$/.test(min) && /^\*\/\d+$/.test(hour) && dom === '*' && month === '*' && dow === '*') {
return { ...DEFAULTS, frequency: 'everyNHours', minuteOffset: parseInt(min), hourInterval: parseInt(hour.split('/')[1]!) }
}
// M H ... patterns need time
if (/^\d+$/.test(min) && /^\d+$/.test(hour)) {
const time = formatTime(parseInt(hour), parseInt(min))
// M H * * * → daily
if (dom === '*' && month === '*' && dow === '*') {
return { ...DEFAULTS, frequency: 'daily', time }
}
// M H * * 1-5 → weekdays
if (dom === '*' && month === '*' && dow === '1-5') {
return { ...DEFAULTS, frequency: 'weekdays', time }
}
// M H * * <list> → specificDays
if (dom === '*' && month === '*' && /^[\d,]+$/.test(dow)) {
return { ...DEFAULTS, frequency: 'specificDays', time, selectedDays: dow.split(',').map(Number) }
}
// M H D * * → monthly
if (/^\d+$/.test(dom) && month === '*' && dow === '*') {
return { ...DEFAULTS, frequency: 'monthly', time, monthDay: parseInt(dom) }
}
}
return { ...DEFAULTS, customCron: cron }
}
export function isValidCron(cron: string): boolean {
const fields = cron.trim().split(/\s+/)
if (fields.length !== 5) return false
const fieldPattern = /^(\*|(\d+(-\d+)?(\/\d+)?)(,(\d+(-\d+)?(\/\d+)?))*)$/
const maxValues = [59, 23, 31, 12, 7]
const minValues = [0, 0, 1, 1, 0]
for (let i = 0; i < 5; i++) {
const field = fields[i]!
if (/^\*\/\d+$/.test(field)) continue
if (field === '*') continue
if (!fieldPattern.test(field)) return false
const nums = field.replace(/\/\d+/g, '').split(/[,\-]/).filter((s) => /^\d+$/.test(s))
for (const num of nums) {
const n = parseInt(num)
if (n < minValues[i]! || n > maxValues[i]!) return false
}
}
return true
}