From c857fcc578d12691fe960ef14c9537a8a8cf2a67 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:41:33 -0700 Subject: [PATCH] Display valid multi-day automation schedules (#26850) Co-authored-by: ggbdpq --- .../AutomationSchedulePicker.test.ts | 11 +++++ .../automations/automation-schedule-label.ts | 3 ++ src/shared/automation-schedules.test.ts | 44 +++++++++++++++++++ src/shared/automation-schedules.ts | 21 ++++++--- src/shared/orchestration-check-output.ts | 13 +----- .../terminal-control-character-escape.ts | 15 +++++++ 6 files changed, 90 insertions(+), 17 deletions(-) create mode 100644 src/shared/terminal-control-character-escape.ts diff --git a/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts b/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts index c05cc2874f5..4bffecd89bf 100644 --- a/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts +++ b/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts @@ -129,6 +129,17 @@ describe('AutomationSchedulePicker', () => { } ) + it.each(['en', 'zh', 'ja', 'ko', 'es'])( + 'shows a valid multi-day weekly rule without an invalid label in %s (#24985)', + async (locale) => { + await i18n.changeLanguage(locale) + const rrule = 'FREQ=WEEKLY;BYDAY=TU,TH,SA;BYHOUR=8;BYMINUTE=0' + + expect(formatUiAutomationSchedule(rrule)).toBe(rrule) + expect(formatUiAutomationSchedule('*/30 9-17 * * MON-FRI')).not.toBe('Invalid schedule') + } + ) + it('localizes the valid-custom-cron status without matching English copy (#14404)', async () => { await i18n.changeLanguage('zh') diff --git a/src/renderer/src/components/automations/automation-schedule-label.ts b/src/renderer/src/components/automations/automation-schedule-label.ts index fbd3518fa2f..04ef7223d18 100644 --- a/src/renderer/src/components/automations/automation-schedule-label.ts +++ b/src/renderer/src/components/automations/automation-schedule-label.ts @@ -21,6 +21,9 @@ export function formatUiAutomationScheduleDescriptor( ) } if (descriptor.kind === 'custom') { + if (descriptor.expression) { + return descriptor.expression + } return translate( 'auto.components.automations.automation.schedule.label.ba20c92073', 'Custom schedule' diff --git a/src/shared/automation-schedules.test.ts b/src/shared/automation-schedules.test.ts index 71b1a2bf70b..e19bcd76bce 100644 --- a/src/shared/automation-schedules.test.ts +++ b/src/shared/automation-schedules.test.ts @@ -109,6 +109,50 @@ describe('automation schedules', () => { expect(formatAutomationSchedule('FREQ=YEARLY')).toBe('Invalid schedule') }) + it.each([ + ['TU,TH,SA', new Date(2026, 9, 6, 8)], + ['MO,WE,FR,SU', new Date(2026, 9, 7, 8)], + ['MO,TU,WE,TH,FR,SA,SU', new Date(2026, 9, 6, 8)] + ])( + 'labels a runnable multi-day weekly rule %s without calling it invalid (#24985)', + (days, next) => { + const rrule = `FREQ=WEEKLY;BYDAY=${days};BYHOUR=8;BYMINUTE=0` + const start = new Date(2026, 9, 5, 9).getTime() + + expect(isValidAutomationSchedule(rrule)).toBe(true) + expect(nextAutomationOccurrenceAfter(rrule, start, start)).toBe(next.getTime()) + expect(formatAutomationSchedule(` ${rrule} `)).toBe(rrule) + expect(describeAutomationSchedule(` ${rrule} `)).toEqual({ + kind: 'custom', + expression: rrule + }) + } + ) + + it.each(['FREQ=WEEKLY;BYDAY=TU,NO;BYHOUR=8;BYMINUTE=0', 'FREQ=YEARLY', '0 0 31 2 *'])( + 'keeps the invalid label for schedules the scheduler rejects: %s', + (schedule) => { + expect(isValidAutomationSchedule(schedule)).toBe(false) + expect(formatAutomationSchedule(schedule)).toBe('Invalid schedule') + expect(describeAutomationSchedule(schedule)).toEqual({ kind: 'invalid' }) + } + ) + + it.each([ + ['\u001b[2J', '\\x1b[2J'], + ['\u001b]52;c;ZXZpbA==\u0007', '\\x1b]52;c;ZXZpbA==\\x07'], + ['\u009b2J', '\\x9b2J'], + ['\t\r\n', '\\x09\\x0d\\x0a'] + ])('renders accepted control bytes as printable schedule text', (control, escaped) => { + const prefix = 'FREQ=WEEKLY;BYDAY=TU,TH;BYHOUR=8;BYMINUTE=0;X=' + const expression = `${prefix}${control};Y=keep` + const expected = `${prefix}${escaped};Y=keep` + + expect(isValidAutomationSchedule(expression)).toBe(true) + expect(formatAutomationSchedule(expression)).toBe(expected) + expect(describeAutomationSchedule(expression)).toEqual({ kind: 'custom', expression: expected }) + }) + it('formats hourly schedules using the stored minute', () => { expect(formatAutomationSchedule('FREQ=HOURLY;BYMINUTE=5')).toBe('Hourly at :05') }) diff --git a/src/shared/automation-schedules.ts b/src/shared/automation-schedules.ts index 993d8fbf831..538c790b685 100644 --- a/src/shared/automation-schedules.ts +++ b/src/shared/automation-schedules.ts @@ -1,10 +1,11 @@ import { - parseAutomationRrule, parseCronExpression, parseSchedule, + tryParseAutomationRrule, type ParsedCron } from './automation-schedule-parsing' import { cronHasPossibleOccurrence } from './automation-cron-occurrence' +import { escapeTerminalControlCharacters } from './terminal-control-character-escape' export type AutomationCronScheduleClassification = | { kind: 'hourly'; minute: number; label: string } @@ -20,7 +21,8 @@ export type AutomationScheduleDescriptor = | { kind: 'daily'; hour: number; minute: number } | { kind: 'weekdays'; hour: number; minute: number } | { kind: 'weekly'; hour: number; minute: number; dayOfWeek: number } - | { kind: 'custom' } + // `expression` carries a scheduler-valid RRULE no preset sentence can name (#24985). + | { kind: 'custom'; expression?: string } | { kind: 'invalid' } // Why: shared labels feed the CLI, which must stay English regardless of OS or UI locale. @@ -74,7 +76,9 @@ function setContainsRange(values: Set, min: number, max: number): boolea return true } -function formatParsedRruleSchedule(schedule: ReturnType): string { +function formatParsedRruleSchedule( + schedule: NonNullable> +): string { if (schedule.preset === 'hourly') { return `Hourly at :${String(schedule.minute).padStart(2, '0')}` } @@ -151,7 +155,11 @@ export function formatAutomationSchedule(scheduleExpression: string): string { if (schedule.kind === 'cron') { return classifyParsedCronSchedule(schedule).label } - return formatParsedRruleSchedule(parseAutomationRrule(trimmed)) + const rrule = tryParseAutomationRrule(trimmed) + if (rrule === null) { + return escapeTerminalControlCharacters(trimmed, true) + } + return formatParsedRruleSchedule(rrule) } catch { return 'Invalid schedule' } @@ -177,7 +185,10 @@ export function describeAutomationSchedule( if (schedule.kind === 'cron') { return toScheduleDescriptor(classifyParsedCronSchedule(schedule)) } - const rrule = parseAutomationRrule(trimmed) + const rrule = tryParseAutomationRrule(trimmed) + if (rrule === null) { + return { kind: 'custom', expression: escapeTerminalControlCharacters(trimmed, true) } + } if (rrule.preset === 'hourly') { return { kind: 'hourly', minute: rrule.minute } } diff --git a/src/shared/orchestration-check-output.ts b/src/shared/orchestration-check-output.ts index 5429c542341..4c9012e5117 100644 --- a/src/shared/orchestration-check-output.ts +++ b/src/shared/orchestration-check-output.ts @@ -1,4 +1,5 @@ import { ORCHESTRATION_LEGACY_RUN_ID } from './orchestration-rpc-contract' +import { escapeTerminalControlCharacters } from './terminal-control-character-escape' export type OrchestrationMessageSummary = { id: string @@ -132,18 +133,6 @@ function formatMessagePriorityTag(message: OrchestrationMessageSummary): string return message.priority === 'urgent' ? ' [URGENT]' : message.priority === 'high' ? ' [HIGH]' : '' } -function escapeTerminalControlCharacters(value: string): string { - return [...value] - .map((character) => { - const code = character.charCodeAt(0) - if (character === '\n' || (code >= 0x20 && code < 0x7f) || code > 0x9f) { - return character - } - return `\\x${code.toString(16).padStart(2, '0')}` - }) - .join('') -} - function formatQuotedMessageField(label: string, value?: string): string { return `[${label}]\n${escapeTerminalControlCharacters(value ?? '') .split('\n') diff --git a/src/shared/terminal-control-character-escape.ts b/src/shared/terminal-control-character-escape.ts new file mode 100644 index 00000000000..822ed509aeb --- /dev/null +++ b/src/shared/terminal-control-character-escape.ts @@ -0,0 +1,15 @@ +export function escapeTerminalControlCharacters(value: string, escapeLineBreaks = false): string { + return [...value] + .map((character) => { + const code = character.charCodeAt(0) + if ( + (!escapeLineBreaks && character === '\n') || + (code >= 0x20 && code < 0x7f) || + code > 0x9f + ) { + return character + } + return `\\x${code.toString(16).padStart(2, '0')}` + }) + .join('') +}