fix(cron): harden preset controls after review

This commit is contained in:
Rod Boev
2026-07-04 15:22:37 -04:00
parent 9742fd8e60
commit 2e27ff64d6
3 changed files with 140 additions and 19 deletions
+70
View File
@@ -1591,6 +1591,11 @@ const LOCALES = {
cron_schedule_preset_weekly: 'Weekly',
cron_schedule_preset_monthly: 'Monthly',
cron_schedule_preset_custom: 'Custom',
cron_schedule_hour_label: 'Hour',
cron_schedule_minute_label: 'Minute',
cron_schedule_weekday_label: 'Weekday (0-6, Sun-Sat)',
cron_schedule_month_day_label: 'Day',
cron_schedule_time_hint: 'Time is server time; cron runs server-side.',
cron_schedule_hint: "Use 'every 1h' or a cron expression for recurring jobs. Bare durations like '30m' run once.",
cron_schedule_once_warning: "Duration forms like '30m' run once and are removed after running. Use 'every 30m' to keep a recurring job.",
cron_prompt_label: 'Prompt',
@@ -3256,6 +3261,11 @@ const LOCALES = {
cron_schedule_preset_weekly: 'Ogni settimana',
cron_schedule_preset_monthly: 'Ogni mese',
cron_schedule_preset_custom: 'Personalizzato',
cron_schedule_hour_label: 'Ora',
cron_schedule_minute_label: 'Minuto',
cron_schedule_weekday_label: 'Giorno della settimana (0-6, Dom-Sab)',
cron_schedule_month_day_label: 'Giorno',
cron_schedule_time_hint: 'L\'ora è quella del server; cron viene eseguito lato server.',
cron_schedule_hint: 'Usa \'every 1h\' o un\'espressione cron per job ricorrenti. Durate semplici come \'30m\' vengono eseguite una volta sola.',
cron_schedule_once_warning: 'Forme di durata come \'30m\' vengono eseguite una volta e rimosse dopo l\'esecuzione. Usa \'every 30m\' per mantenere un job ricorrente.',
cron_prompt_label: 'Prompt',
@@ -4929,6 +4939,11 @@ const LOCALES = {
cron_schedule_preset_weekly: '毎週',
cron_schedule_preset_monthly: '毎月',
cron_schedule_preset_custom: 'カスタム',
cron_schedule_hour_label: '時',
cron_schedule_minute_label: '分',
cron_schedule_weekday_label: '曜日 (0-6, 日-土)',
cron_schedule_month_day_label: '日',
cron_schedule_time_hint: '時刻はサーバー時刻です。cron はサーバー側で実行されます。',
cron_schedule_hint: "繰り返し実行には 'every 1h' または Cron 式を使います。'30m' のような期間だけの指定は 1 回だけ実行されます。",
cron_schedule_once_warning: "'30m' のような期間指定は 1 回だけ実行され、実行後に削除されます。繰り返すには 'every 30m' を使ってください。",
cron_prompt_label: 'プロンプト',
@@ -6244,6 +6259,11 @@ const LOCALES = {
cron_schedule_preset_weekly: 'Еженедельно',
cron_schedule_preset_monthly: 'Ежемесячно',
cron_schedule_preset_custom: 'Пользовательский',
cron_schedule_hour_label: 'Час',
cron_schedule_minute_label: 'Минута',
cron_schedule_weekday_label: 'День недели (0-6, вс-сб)',
cron_schedule_month_day_label: 'День',
cron_schedule_time_hint: 'Время указано по серверу; cron выполняется на сервере.',
cron_schedule_hint: "Для повторяющихся заданий используйте 'every 1h' или cron-выражение. Простые интервалы вроде '30m' выполняются один раз.",
cron_schedule_once_warning: "Интервалы вроде '30m' выполняются один раз и удаляются после запуска. Используйте 'every 30m' для повторяющегося задания.",
cron_prompt_label: 'Запрос',
@@ -7836,6 +7856,11 @@ const LOCALES = {
cron_schedule_preset_weekly: 'Semanal',
cron_schedule_preset_monthly: 'Mensual',
cron_schedule_preset_custom: 'Personalizado',
cron_schedule_hour_label: 'Hora',
cron_schedule_minute_label: 'Minuto',
cron_schedule_weekday_label: 'Día de la semana (0-6, dom-sáb)',
cron_schedule_month_day_label: 'Día',
cron_schedule_time_hint: 'La hora es la del servidor; cron se ejecuta del lado del servidor.',
cron_schedule_hint: "Usa 'every 1h' o una expresión cron para trabajos recurrentes. Duraciones como '30m' se ejecutan una sola vez.",
cron_schedule_once_warning: "Las duraciones como '30m' se ejecutan una vez y se eliminan después de correr. Usa 'every 30m' para mantener un trabajo recurrente.",
cron_prompt_label: 'Prompt',
@@ -9110,6 +9135,11 @@ const LOCALES = {
cron_schedule_preset_weekly: 'Wöchentlich',
cron_schedule_preset_monthly: 'Monatlich',
cron_schedule_preset_custom: 'Benutzerdefiniert',
cron_schedule_hour_label: 'Stunde',
cron_schedule_minute_label: 'Minute',
cron_schedule_weekday_label: 'Wochentag (0-6, So-Sa)',
cron_schedule_month_day_label: 'Tag',
cron_schedule_time_hint: 'Die Zeit ist Serverzeit; cron läuft serverseitig.',
cron_schedule_hint: "Für wiederkehrende Aufgaben 'every 1h' oder einen Cron-Ausdruck verwenden. Reine Dauern wie '30m' laufen einmal.",
cron_schedule_once_warning: "Dauerangaben wie '30m' laufen einmal und werden nach der Ausführung entfernt. Verwende 'every 30m' für eine wiederkehrende Aufgabe.",
cron_prompt_label: 'Prompt',
@@ -11056,6 +11086,11 @@ const LOCALES = {
cron_schedule_preset_weekly: '每周',
cron_schedule_preset_monthly: '每月',
cron_schedule_preset_custom: '自定义',
cron_schedule_hour_label: '小时',
cron_schedule_minute_label: '分钟',
cron_schedule_weekday_label: '星期 (0-6,周日-周六)',
cron_schedule_month_day_label: '日期',
cron_schedule_time_hint: '时间以服务器时间为准;cron 在服务器端运行。',
cron_schedule_hint: "循环任务请用 'every 1h' 或 Cron 表达式。像 '30m' 这样的裸时长只会运行一次。",
cron_schedule_once_warning: "像 '30m' 这样的时长写法只会运行一次,并在运行后移除。要保留循环任务,请使用 'every 30m'。",
cron_prompt_label: '提示词',
@@ -13023,6 +13058,11 @@ const LOCALES = {
cron_schedule_preset_weekly: '每週',
cron_schedule_preset_monthly: '每月',
cron_schedule_preset_custom: '自訂',
cron_schedule_hour_label: '小時',
cron_schedule_minute_label: '分鐘',
cron_schedule_weekday_label: '星期 (0-6,週日-週六)',
cron_schedule_month_day_label: '日期',
cron_schedule_time_hint: '時間以伺服器時間為準;cron 在伺服器端執行。',
cron_schedule_hint: '週期性任務請用「every 1h」或 Cron 表示式。像「30m」這樣的裸時長只會執行一次。',
cron_schedule_once_warning: '像「30m」這樣的時長寫法只會執行一次,並在執行後移除。要保留週期性任務,請使用「every 30m」。',
cron_prompt_label: '提示',
@@ -14429,6 +14469,11 @@ const LOCALES = {
cron_schedule_preset_weekly: 'Semanal',
cron_schedule_preset_monthly: 'Mensal',
cron_schedule_preset_custom: 'Personalizado',
cron_schedule_hour_label: 'Hora',
cron_schedule_minute_label: 'Minuto',
cron_schedule_weekday_label: 'Dia da semana (0-6, dom-sáb)',
cron_schedule_month_day_label: 'Dia',
cron_schedule_time_hint: 'O horário usa a hora do servidor; cron roda no servidor.',
cron_schedule_hint: "Use 'every 1h' ou uma expressão Cron para tarefas recorrentes. Durações como '30m' rodam uma vez.",
cron_schedule_once_warning: "Durações como '30m' rodam uma vez e são removidas após executar. Use 'every 30m' para manter uma tarefa recorrente.",
cron_prompt_label: 'Prompt',
@@ -16078,6 +16123,11 @@ const LOCALES = {
cron_schedule_preset_weekly: 'Weekly',
cron_schedule_preset_monthly: 'Monthly',
cron_schedule_preset_custom: 'Custom',
cron_schedule_hour_label: '시간',
cron_schedule_minute_label: '분',
cron_schedule_weekday_label: '요일 (0-6, 일-토)',
cron_schedule_month_day_label: '일',
cron_schedule_time_hint: '시간은 서버 시간 기준이며 cron은 서버에서 실행됩니다.',
cron_schedule_hint: "Use 'every 1h' or a cron expression for recurring jobs. Bare durations like '30m' run once.",
cron_schedule_once_warning: "Duration forms like '30m' run once and are removed after running. Use 'every 30m' to keep a recurring job.",
cron_prompt_label: 'Prompt',
@@ -17819,6 +17869,11 @@ const LOCALES = {
cron_schedule_preset_weekly: 'Hebdomadaire',
cron_schedule_preset_monthly: 'Mensuel',
cron_schedule_preset_custom: 'Personnalisé',
cron_schedule_hour_label: 'Heure',
cron_schedule_minute_label: 'Minute',
cron_schedule_weekday_label: 'Jour de la semaine (0-6, dim-sam)',
cron_schedule_month_day_label: 'Jour',
cron_schedule_time_hint: 'L\'heure est celle du serveur; cron s\'exécute côté serveur.',
cron_schedule_hint: "Utilisez 'every 1h' ou une expression cron pour les tâches récurrentes. Les durées simples comme '30m' s'exécutent une fois.",
cron_schedule_once_warning: "Les formes de durée comme '30m' s'exécutent une fois et sont supprimées après l'exécution. Utilisez 'every 30m' pour conserver une tâche récurrente.",
cron_prompt_label: 'Invite',
@@ -19392,6 +19447,11 @@ const LOCALES = {
cron_schedule_preset_weekly: 'Haftalık',
cron_schedule_preset_monthly: 'Aylık',
cron_schedule_preset_custom: 'Özel',
cron_schedule_hour_label: 'Saat',
cron_schedule_minute_label: 'Dakika',
cron_schedule_weekday_label: 'Haftanın günü (0-6, Paz-Cmt)',
cron_schedule_month_day_label: 'Gün',
cron_schedule_time_hint: 'Saat sunucu saatidir; cron sunucu tarafında çalışır.',
cron_schedule_hint: "Use 'every 1h' or a cron expression for recurring jobs. Bare durations like '30m' run once.",
cron_schedule_once_warning: "Duration forms like '30m' run once and are removed after running. Use 'every 30m' to keep a recurring job.",
cron_prompt_label: 'Çabuk',
@@ -21145,6 +21205,11 @@ const LOCALES = {
cron_schedule_preset_weekly: 'Co tydzień',
cron_schedule_preset_monthly: 'Co miesiąc',
cron_schedule_preset_custom: 'Niestandardowe',
cron_schedule_hour_label: 'Godzina',
cron_schedule_minute_label: 'Minuta',
cron_schedule_weekday_label: 'Dzień tygodnia (0-6, niedz.-sob.)',
cron_schedule_month_day_label: 'Dzień',
cron_schedule_time_hint: 'Czas jest czasem serwera; cron działa po stronie serwera.',
cron_schedule_hint: "Użyj 'every 1h' lub wyrażenia cron dla zadań cyklicznych. Same formy czasu trwania, jak '30m', zostaną uruchomione tylko raz.",
cron_schedule_once_warning: 'Formy czasu trwania, takie jak \'30m\', są uruchamiane raz i usuwane po uruchomieniu. Użyj \'every 30m\', aby utrzymać zadanie cykliczne.',
cron_prompt_label: 'Monit',
@@ -22652,6 +22717,11 @@ const LOCALES = {
cron_schedule_preset_weekly: 'Hàng tuần',
cron_schedule_preset_monthly: 'Hàng tháng',
cron_schedule_preset_custom: 'Tùy chỉnh',
cron_schedule_hour_label: 'Giờ',
cron_schedule_minute_label: 'Phút',
cron_schedule_weekday_label: 'Thứ trong tuần (0-6, CN-T7)',
cron_schedule_month_day_label: 'Ngày',
cron_schedule_time_hint: 'Thời gian dùng giờ máy chủ; cron chạy phía máy chủ.',
cron_schedule_hint: "Dùng 'every 1h' hoặc biểu thức cron cho job lặp lại. Các khoảng thời gian đơn như '30m' chỉ chạy một lần.",
cron_schedule_once_warning: "Các dạng thời lượng như '30m' chỉ chạy một lần và sẽ bị xóa sau khi chạy. Dùng 'every 30m' để giữ job lặp lại.",
cron_prompt_label: 'Prompt',
+21 -18
View File
@@ -507,7 +507,7 @@ function _cronSchedulePresetFieldEl(field) {
function _cronSchedulePresetBounds(field) {
if (field === 'hour') return { min: 0, max: 23 };
if (field === 'minute') return { min: 0, max: 59 };
if (field === 'weekday') return { min: 0, max: 7 };
if (field === 'weekday') return { min: 0, max: 6 };
if (field === 'monthDay') return { min: 1, max: 31 };
return { min: 0, max: 999 };
}
@@ -583,8 +583,8 @@ function _cronSchedulePresetValuesForSelection(presetId) {
return {};
}
function _cronSchedulePresetValueForSelection(presetId) {
const values = _cronSchedulePresetValuesForSelection(presetId);
function _cronSchedulePresetValueForSelection(presetId, selectedValues) {
const values = selectedValues || _cronSchedulePresetValuesForSelection(presetId);
if (presetId === 'hourly') return `${values.minute} * * * *`;
if (presetId === 'daily') return `${values.minute} ${values.hour} * * *`;
if (presetId === 'weekdays') return `${values.minute} ${values.hour} * * 1-5`;
@@ -613,8 +613,8 @@ function _cronSchedulePresetStateForInput(value) {
if (hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
return { presetId: 'hourly', minute };
}
if (_cronSchedulePresetRawFieldInBounds('hour', hour) && dayOfMonth === '*' && month === '*' && _cronSchedulePresetRawFieldInBounds('weekday', dayOfWeek)) {
return { presetId: 'weekly', minute, hour, weekday: dayOfWeek };
if (_cronSchedulePresetRawFieldInBounds('hour', hour) && dayOfMonth === '*' && month === '*' && (_cronSchedulePresetRawFieldInBounds('weekday', dayOfWeek) || dayOfWeek === '7')) {
return { presetId: 'weekly', minute, hour, weekday: dayOfWeek === '7' ? '0' : dayOfWeek };
}
if (_cronSchedulePresetRawFieldInBounds('hour', hour) && _cronSchedulePresetRawFieldInBounds('monthDay', dayOfMonth) && month === '*' && dayOfWeek === '*') {
return { presetId: 'monthly', minute, hour, monthDay: dayOfMonth };
@@ -647,9 +647,11 @@ function _applyCronSchedulePresetSelection() {
if (!presetEl || !scheduleEl) return;
const presetId = presetEl.value;
if (presetId !== 'custom') {
scheduleEl.value = _cronSchedulePresetValueForSelection(presetId);
_cronSchedulePresetApplyValues(_cronSchedulePresetValuesForSelection(presetId));
_syncCronSchedulePresetAndWarning();
const values = _cronSchedulePresetValuesForSelection(presetId);
_cronSchedulePresetApplyValues(values);
scheduleEl.value = _cronSchedulePresetValueForSelection(presetId, values);
_cronSchedulePresetSyncVisibility(presetId);
_syncCronScheduleWarning();
return;
}
_cronSchedulePresetSyncVisibility(presetId);
@@ -659,15 +661,16 @@ function _applyCronSchedulePresetSelection() {
function _initCronSchedulePresetControls() {
const presetEl = $('cronFormSchedulePreset');
const scheduleEl = $('cronFormSchedule');
const presetParams = $('cronFormSchedulePresetParams');
if (!presetEl || !scheduleEl || !presetParams) return;
if (!presetEl || !scheduleEl) return;
presetEl.addEventListener('change', _applyCronSchedulePresetSelection);
['cronFormScheduleHour', 'cronFormScheduleMinute', 'cronFormScheduleWeekday', 'cronFormScheduleMonthDay'].forEach((id) => {
const el = $(id);
if (!el) return;
el.addEventListener('change', _applyCronSchedulePresetSelection);
el.addEventListener('input', _applyCronSchedulePresetSelection);
});
if ($('cronFormSchedulePresetParams')) {
['cronFormScheduleHour', 'cronFormScheduleMinute', 'cronFormScheduleWeekday', 'cronFormScheduleMonthDay'].forEach((id) => {
const el = $(id);
if (!el) return;
el.addEventListener('change', _applyCronSchedulePresetSelection);
el.addEventListener('input', _applyCronSchedulePresetSelection);
});
}
scheduleEl.addEventListener('input', _syncCronSchedulePresetAndWarning);
scheduleEl.addEventListener('change', _syncCronSchedulePresetAndWarning);
_syncCronSchedulePresetAndWarning();
@@ -1535,13 +1538,13 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notif
</div>
<div class="cron-schedule-preset-field" id="cronFormScheduleWeekdayField">
<label for="cronFormScheduleWeekday">${esc(t('cron_schedule_weekday_label') || 'Weekday')}</label>
<input type="number" id="cronFormScheduleWeekday" min="0" max="7" step="1" value="1" autocomplete="off">
<input type="number" id="cronFormScheduleWeekday" min="0" max="6" step="1" value="1" autocomplete="off">
</div>
<div class="cron-schedule-preset-field" id="cronFormScheduleMonthDayField">
<label for="cronFormScheduleMonthDay">${esc(t('cron_schedule_month_day_label') || 'Day')}</label>
<input type="number" id="cronFormScheduleMonthDay" min="1" max="31" step="1" value="1" autocomplete="off">
</div>
<div class="detail-form-hint cron-schedule-preset-time-hint">Time is server time; cron runs server-side.</div>
<div class="detail-form-hint cron-schedule-preset-time-hint">${esc(t('cron_schedule_time_hint') || 'Time is server time; cron runs server-side.')}</div>
</div>
</div>
</div>
+49 -1
View File
@@ -92,6 +92,7 @@ const cases = {
shorthand: _cronSchedulePresetIdForValue("@daily"),
invalidDaily: _cronSchedulePresetIdForValue("99 99 * * *"),
invalidHourly: _cronSchedulePresetIdForValue("60 * * * *"),
ambiguousSunday: _cronSchedulePresetIdForValue("0 9 * * 7"),
invalidWeekly: _cronSchedulePresetIdForValue("0 9 * * 8"),
invalidMonthDayZero: _cronSchedulePresetIdForValue("0 9 0 * *"),
invalidMonthly: _cronSchedulePresetIdForValue("0 9 32 * *"),
@@ -112,6 +113,7 @@ console.log(JSON.stringify(cases));
assert cases["shorthand"] == "custom"
assert cases["invalidDaily"] == "custom"
assert cases["invalidHourly"] == "custom"
assert cases["ambiguousSunday"] == "weekly"
assert cases["invalidWeekly"] == "custom"
assert cases["invalidMonthDayZero"] == "custom"
assert cases["invalidMonthly"] == "custom"
@@ -213,6 +215,9 @@ const weekdaysSync = snapshot();
elements.cronFormSchedule.value = '45 8 * * 3';
elements.cronFormSchedule.dispatchEvent('input');
const weeklySync = snapshot();
elements.cronFormSchedule.value = '0 9 * * 7';
elements.cronFormSchedule.dispatchEvent('input');
const weeklyRawSundaySync = snapshot();
elements.cronFormScheduleWeekday.value = '3';
elements.cronFormScheduleWeekday.dispatchEvent('change');
elements.cronFormScheduleHour.value = '8';
@@ -263,6 +268,7 @@ console.log(JSON.stringify({
dailyWrite,
weekdaysSync,
weeklySync,
weeklyRawSundaySync,
weeklyWrite,
monthlySync,
monthlyWrite,
@@ -304,6 +310,9 @@ console.log(JSON.stringify({
assert result["weeklySync"]["schedule"] == "45 8 * * 3"
assert result["weeklySync"]["weekdayFieldDisplay"] == ""
assert result["weeklySync"]["monthDayFieldDisplay"] == "none"
assert result["weeklyRawSundaySync"]["preset"] == "weekly"
assert result["weeklyRawSundaySync"]["schedule"] == "0 9 * * 7"
assert result["weeklyRawSundaySync"]["weekday"] == "0"
assert result["weeklyWrite"]["schedule"] == "45 8 * * 3"
assert result["weeklyWrite"]["kind"] == "cron"
@@ -339,6 +348,45 @@ console.log(JSON.stringify({
assert result["customSelectionPreserved"]["paramsDisplay"] == "none"
def test_cron_schedule_raw_warning_listener_survives_missing_preset_params():
script = _cron_schedule_source() + r"""
const elements = {};
function $(id) { return elements[id]; }
function makeElement(initialValue = '') {
return {
value: initialValue,
style: { display: '' },
listeners: {},
addEventListener(type, handler) {
(this.listeners[type] || (this.listeners[type] = [])).push(handler);
},
dispatchEvent(eventType) {
const handlers = this.listeners[eventType] || [];
for (const handler of handlers) handler({ type: eventType, target: this });
},
};
}
elements.cronFormSchedule = makeElement();
elements.cronFormSchedulePreset = makeElement('custom');
elements.cronFormScheduleOnceWarning = { style: { display: 'none' } };
_initCronSchedulePresetControls();
elements.cronFormSchedule.value = '30m';
elements.cronFormSchedule.dispatchEvent('input');
console.log(JSON.stringify({
warning: elements.cronFormScheduleOnceWarning.style.display,
inputListeners: (elements.cronFormSchedule.listeners.input || []).length,
presetChangeListeners: (elements.cronFormSchedulePreset.listeners.change || []).length,
}));
"""
result = json.loads(_run_node(script))
assert result["warning"] == ""
assert result["inputListeners"] == 1
assert result["presetChangeListeners"] == 1
def test_cron_schedule_custom_selection_preserves_raw_schedule_exactly():
script = _cron_schedule_source() + r"""
const elements = {};
@@ -433,7 +481,7 @@ def test_cron_form_surfaces_one_shot_warning_copy_markers_and_preset_markup():
assert ".cron-schedule-preset-params" in style
assert ".cron-schedule-preset-field" in style
assert ".cron-schedule-preset-time-hint" in style
assert "Time is server time; cron runs server-side." in panels
assert "cron_schedule_time_hint" in panels
assert "fields: ['minute']" in panels
assert "fields: ['hour', 'minute']" in panels
assert "fields: ['hour', 'minute', 'weekday']" in panels