diff --git a/static/i18n.js b/static/i18n.js index bfb694a0..3e4362ba 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1592,6 +1592,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', @@ -3258,6 +3263,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', @@ -4932,6 +4942,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: 'プロンプト', @@ -6248,6 +6263,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: 'Запрос', @@ -7841,6 +7861,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', @@ -9116,6 +9141,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', @@ -11063,6 +11093,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: '提示词', @@ -13031,6 +13066,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: '提示', @@ -14438,6 +14478,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', @@ -16088,6 +16133,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', @@ -17830,6 +17880,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', @@ -19404,6 +19459,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', @@ -21158,6 +21218,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', @@ -22666,6 +22731,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', diff --git a/static/panels.js b/static/panels.js index c841a4ef..8a481301 100644 --- a/static/panels.js +++ b/static/panels.js @@ -467,12 +467,12 @@ function _syncCronScheduleWarning() { } const CRON_SCHEDULE_PRESETS = [ - { id: 'custom', value: '', label: 'cron_schedule_preset_custom', fallback: 'Custom' }, - { id: 'hourly', value: 'every 1h', label: 'cron_schedule_preset_hourly', fallback: 'Hourly' }, - { id: 'daily', value: '0 9 * * *', label: 'cron_schedule_preset_daily', fallback: 'Daily' }, - { id: 'weekdays', value: '0 9 * * 1-5', label: 'cron_schedule_preset_weekdays', fallback: 'Weekdays' }, - { id: 'weekly', value: '0 9 * * 1', label: 'cron_schedule_preset_weekly', fallback: 'Weekly' }, - { id: 'monthly', value: '0 9 1 * *', label: 'cron_schedule_preset_monthly', fallback: 'Monthly' }, + { id: 'custom', label: 'cron_schedule_preset_custom', fallback: 'Custom', fields: [] }, + { id: 'hourly', label: 'cron_schedule_preset_hourly', fallback: 'Hourly', fields: ['minute'], defaults: { minute: 0 } }, + { id: 'daily', label: 'cron_schedule_preset_daily', fallback: 'Daily', fields: ['hour', 'minute'], defaults: { hour: 9, minute: 0 } }, + { id: 'weekdays', label: 'cron_schedule_preset_weekdays', fallback: 'Weekdays', fields: ['hour', 'minute'], defaults: { hour: 9, minute: 0 } }, + { id: 'weekly', label: 'cron_schedule_preset_weekly', fallback: 'Weekly', fields: ['hour', 'minute', 'weekday'], defaults: { hour: 9, minute: 0, weekday: 1 } }, + { id: 'monthly', label: 'cron_schedule_preset_monthly', fallback: 'Monthly', fields: ['hour', 'minute', 'monthDay'], defaults: { hour: 9, minute: 0, monthDay: 1 } }, ]; function _cronSchedulePresetOptionHtml() { @@ -481,18 +481,159 @@ function _cronSchedulePresetOptionHtml() { .join(''); } -function _cronSchedulePresetIdForValue(value) { +function _cronSchedulePresetForId(presetId) { + return CRON_SCHEDULE_PRESETS.find((entry) => entry.id === presetId) || null; +} + +function _cronSchedulePresetControlIds() { + return { + hour: 'cronFormScheduleHour', + minute: 'cronFormScheduleMinute', + weekday: 'cronFormScheduleWeekday', + monthDay: 'cronFormScheduleMonthDay', + }; +} + +function _cronSchedulePresetFieldId(field) { + const ids = _cronSchedulePresetControlIds(); + return ids[field] || ''; +} + +function _cronSchedulePresetFieldEl(field) { + const id = _cronSchedulePresetFieldId(field); + return id ? $(id) : null; +} + +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: 6 }; + if (field === 'monthDay') return { min: 1, max: 31 }; + return { min: 0, max: 999 }; +} + +function _cronSchedulePresetNormalizeValue(field, value, fallback) { + const bounds = _cronSchedulePresetBounds(field); + const parsed = parseInt(String(value ?? '').trim(), 10); + const fallbackParsed = parseInt(String(fallback ?? bounds.min).trim(), 10); + const safeFallback = Number.isFinite(fallbackParsed) ? fallbackParsed : bounds.min; + const n = Number.isFinite(parsed) ? parsed : safeFallback; + return String(Math.min(bounds.max, Math.max(bounds.min, n))); +} + +function _cronSchedulePresetValueForField(field, fallback) { + const el = _cronSchedulePresetFieldEl(field); + const raw = el ? String(el.value || '').trim() : ''; + return _cronSchedulePresetNormalizeValue(field, raw, fallback); +} + +function _cronSchedulePresetRawFieldInBounds(field, value) { + const raw = String(value || '').trim(); + if (!/^\d+$/.test(raw)) return false; + const bounds = _cronSchedulePresetBounds(field); + const parsed = parseInt(raw, 10); + return Number.isFinite(parsed) && parsed >= bounds.min && parsed <= bounds.max; +} + +function _cronSchedulePresetApplyValues(values) { + ['hour', 'minute', 'weekday', 'monthDay'].forEach((field) => { + const el = _cronSchedulePresetFieldEl(field); + if (!el || values[field] == null) return; + el.value = _cronSchedulePresetNormalizeValue(field, values[field], values[field]); + }); +} + +function _cronSchedulePresetSyncVisibility(presetId) { + const wrapper = $('cronFormSchedulePresetParams'); + const preset = _cronSchedulePresetForId(presetId); + const showFields = preset && Array.isArray(preset.fields) ? preset.fields : []; + if (wrapper) wrapper.style.display = presetId === 'custom' ? 'none' : ''; + ['hour', 'minute', 'weekday', 'monthDay'].forEach((field) => { + const fieldWrap = $(`cronFormSchedule${field === 'monthDay' ? 'MonthDay' : field.charAt(0).toUpperCase() + field.slice(1)}Field`); + if (fieldWrap) fieldWrap.style.display = showFields.includes(field) ? '' : 'none'; + }); +} + +function _cronSchedulePresetValuesForSelection(presetId) { + const preset = _cronSchedulePresetForId(presetId); + const defaults = (preset && preset.defaults) || {}; + if (presetId === 'hourly') { + return { minute: _cronSchedulePresetValueForField('minute', defaults.minute) }; + } + if (presetId === 'daily' || presetId === 'weekdays') { + return { + hour: _cronSchedulePresetValueForField('hour', defaults.hour), + minute: _cronSchedulePresetValueForField('minute', defaults.minute), + }; + } + if (presetId === 'weekly') { + return { + hour: _cronSchedulePresetValueForField('hour', defaults.hour), + minute: _cronSchedulePresetValueForField('minute', defaults.minute), + weekday: _cronSchedulePresetValueForField('weekday', defaults.weekday), + }; + } + if (presetId === 'monthly') { + return { + hour: _cronSchedulePresetValueForField('hour', defaults.hour), + minute: _cronSchedulePresetValueForField('minute', defaults.minute), + monthDay: _cronSchedulePresetValueForField('monthDay', defaults.monthDay), + }; + } + return {}; +} + +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`; + if (presetId === 'weekly') return `${values.minute} ${values.hour} * * ${values.weekday}`; + if (presetId === 'monthly') return `${values.minute} ${values.hour} ${values.monthDay} * *`; + return ''; +} + +function _cronSchedulePresetStateForInput(value) { const schedule = String(value || '').trim(); - const preset = CRON_SCHEDULE_PRESETS.find((entry) => entry.value && entry.value === schedule); - return preset ? preset.id : 'custom'; + if (!schedule) return { presetId: 'custom' }; + if (/^every\s+1h$/i.test(schedule)) { + return { presetId: 'hourly', minute: '0' }; + } + if (schedule.startsWith('@')) return { presetId: 'custom' }; + const parts = schedule.split(/\s+/); + if (parts.length !== 5) return { presetId: 'custom' }; + const [minute, hour, dayOfMonth, month, dayOfWeek] = parts; + if (!_cronSchedulePresetRawFieldInBounds('minute', minute)) return { presetId: 'custom' }; + if (_cronSchedulePresetRawFieldInBounds('hour', hour) && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { + return { presetId: 'daily', minute, hour }; + } + if (_cronSchedulePresetRawFieldInBounds('hour', hour) && dayOfMonth === '*' && month === '*' && dayOfWeek === '1-5') { + return { presetId: 'weekdays', minute, hour }; + } + if (hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { + return { presetId: 'hourly', minute }; + } + 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 }; + } + return { presetId: 'custom' }; +} + +function _cronSchedulePresetIdForValue(value) { + return _cronSchedulePresetStateForInput(value).presetId; } function _syncCronSchedulePresetFromInput() { const presetEl = $('cronFormSchedulePreset'); const scheduleEl = $('cronFormSchedule'); if (!presetEl || !scheduleEl) return; - const presetId = _cronSchedulePresetIdForValue(scheduleEl.value); - presetEl.value = presetId; + const state = _cronSchedulePresetStateForInput(scheduleEl.value); + presetEl.value = state.presetId; + _cronSchedulePresetSyncVisibility(state.presetId); + if (state.presetId !== 'custom') _cronSchedulePresetApplyValues(state); } function _syncCronSchedulePresetAndWarning() { @@ -505,12 +646,15 @@ function _applyCronSchedulePresetSelection() { const scheduleEl = $('cronFormSchedule'); if (!presetEl || !scheduleEl) return; const presetId = presetEl.value; - const preset = CRON_SCHEDULE_PRESETS.find((entry) => entry.id === presetId); - if (preset && preset.value) { - scheduleEl.value = preset.value; - _syncCronSchedulePresetAndWarning(); + if (presetId !== 'custom') { + const values = _cronSchedulePresetValuesForSelection(presetId); + _cronSchedulePresetApplyValues(values); + scheduleEl.value = _cronSchedulePresetValueForSelection(presetId, values); + _cronSchedulePresetSyncVisibility(presetId); + _syncCronScheduleWarning(); return; } + _cronSchedulePresetSyncVisibility(presetId); _syncCronScheduleWarning(); } @@ -519,6 +663,14 @@ function _initCronSchedulePresetControls() { const scheduleEl = $('cronFormSchedule'); if (!presetEl || !scheduleEl) return; presetEl.addEventListener('change', _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(); @@ -1371,9 +1523,30 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notif
- +
+ + +
diff --git a/static/style.css b/static/style.css index b16d135c..36268367 100644 --- a/static/style.css +++ b/static/style.css @@ -6318,6 +6318,7 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view- .detail-form-row > label{font-size:12px;font-weight:500;color:var(--muted);letter-spacing:.01em;} .detail-form-row input[type="text"], .detail-form-row input[type="password"], +.detail-form-row input[type="number"], .detail-form-row input:not([type]), .detail-form-row select, .detail-form-row textarea{ @@ -6329,10 +6330,18 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view- } .detail-form-row input[type="text"]:focus, .detail-form-row input[type="password"]:focus, +.detail-form-row input[type="number"]:focus, .detail-form-row input:not([type]):focus, .detail-form-row select:focus, .detail-form-row textarea:focus{border-color:var(--accent,var(--accent-bg-strong,var(--border)));} .detail-form-row input:disabled{opacity:.6;cursor:not-allowed;} +.cron-schedule-preset-shell{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-start;} +.cron-schedule-preset-shell > select{width:auto;min-width:160px;flex:0 0 160px;} +.cron-schedule-preset-params{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;min-width:0;} +.cron-schedule-preset-field{display:flex;flex-direction:column;gap:4px;flex:0 0 110px;min-width:110px;} +.cron-schedule-preset-field label{font-size:11px;font-weight:500;color:var(--muted);letter-spacing:.01em;} +.cron-schedule-preset-field input[type="number"]{width:100%;} +.cron-schedule-preset-time-hint{flex-basis:100%;} .detail-form-row textarea{resize:vertical;font-family:'SF Mono',ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;} .detail-form-row .detail-form-hint{font-size:11px;color:var(--muted);line-height:1.5;} .detail-form-warning{font-size:11px;line-height:1.5;border:1px solid rgba(245,158,11,.35);background:rgba(245,158,11,.1);color:rgba(245,158,11,.98);border-radius:8px;padding:8px 10px;} diff --git a/tests/test_issue2031_cron_once_visibility.py b/tests/test_issue2031_cron_once_visibility.py index 3ea70781..8f525754 100644 --- a/tests/test_issue2031_cron_once_visibility.py +++ b/tests/test_issue2031_cron_once_visibility.py @@ -81,19 +81,28 @@ def test_cron_schedule_preset_matching(): script = _cron_schedule_source() + r""" const cases = { hourly: _cronSchedulePresetIdForValue("every 1h"), - daily: _cronSchedulePresetIdForValue("0 9 * * *"), - weekdays: _cronSchedulePresetIdForValue("0 9 * * 1-5"), - weekly: _cronSchedulePresetIdForValue("0 9 * * 1"), - monthly: _cronSchedulePresetIdForValue("0 9 1 * *"), + hourlyCron: _cronSchedulePresetIdForValue("15 * * * *"), + daily: _cronSchedulePresetIdForValue("30 7 * * *"), + weekdays: _cronSchedulePresetIdForValue("5 22 * * 1-5"), + weekly: _cronSchedulePresetIdForValue("45 8 * * 3"), + monthly: _cronSchedulePresetIdForValue("10 6 15 * *"), empty: _cronSchedulePresetIdForValue(""), - trimMatch: _cronSchedulePresetIdForValue(" 0 9 * * * "), + trimMatch: _cronSchedulePresetIdForValue(" 30 7 * * * "), custom: _cronSchedulePresetIdForValue("0 9 * * * 0"), + 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 * *"), }; console.log(JSON.stringify(cases)); """ cases = json.loads(_run_node(script)) assert cases["hourly"] == "hourly" + assert cases["hourlyCron"] == "hourly" assert cases["daily"] == "daily" assert cases["weekdays"] == "weekdays" assert cases["weekly"] == "weekly" @@ -101,16 +110,23 @@ console.log(JSON.stringify(cases)); assert cases["empty"] == "custom" assert cases["trimMatch"] == "daily" assert cases["custom"] == "custom" + 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" def test_cron_schedule_preset_controls_sync_raw_and_preset_paths(): script = _cron_schedule_source() + r""" const elements = {}; function $(id) { return elements[id]; } -function makeElement() { +function makeElement(initialValue = '') { return { - value: '', - style: {}, + value: initialValue, + style: { display: '' }, listeners: {}, addEventListener(type, handler) { (this.listeners[type] || (this.listeners[type] = [])).push(handler); @@ -135,64 +151,310 @@ function t(key) { } function esc(value) { return value == null ? '' : String(value); } -elements.cronFormSchedule = makeElement(); -elements.cronFormSchedulePreset = makeElement(); -elements.cronFormScheduleOnceWarning = { style: {} }; +[ + 'cronFormSchedule', + 'cronFormSchedulePreset', + 'cronFormSchedulePresetParams', + 'cronFormScheduleHourField', + 'cronFormScheduleMinuteField', + 'cronFormScheduleWeekdayField', + 'cronFormScheduleMonthDayField', + 'cronFormScheduleHour', + 'cronFormScheduleMinute', + 'cronFormScheduleWeekday', + 'cronFormScheduleMonthDay', +].forEach((id) => { + elements[id] = makeElement(); +}); +elements.cronFormScheduleOnceWarning = { style: { display: 'none' } }; +elements.cronFormSchedulePresetParams.style.display = 'none'; _initCronSchedulePresetControls(); -elements.cronFormSchedule.value = "0 9 * * *"; -elements.cronFormSchedule.dispatchEvent('input'); -const selectAfterDaily = elements.cronFormSchedulePreset.value; -const warningAfterDaily = elements.cronFormScheduleOnceWarning.style.display; +function snapshot() { + return { + preset: elements.cronFormSchedulePreset.value, + schedule: elements.cronFormSchedule.value, + paramsDisplay: elements.cronFormSchedulePresetParams.style.display, + hourFieldDisplay: elements.cronFormScheduleHourField.style.display, + minuteFieldDisplay: elements.cronFormScheduleMinuteField.style.display, + weekdayFieldDisplay: elements.cronFormScheduleWeekdayField.style.display, + monthDayFieldDisplay: elements.cronFormScheduleMonthDayField.style.display, + hour: elements.cronFormScheduleHour.value, + minute: elements.cronFormScheduleMinute.value, + weekday: elements.cronFormScheduleWeekday.value, + monthDay: elements.cronFormScheduleMonthDay.value, + warning: elements.cronFormScheduleOnceWarning.style.display, + kind: _cronScheduleKindForInput(elements.cronFormSchedule.value), + }; +} -elements.cronFormSchedule.value = "30m"; +elements.cronFormSchedule.value = '15 * * * *'; elements.cronFormSchedule.dispatchEvent('input'); -const selectAfterOnce = elements.cronFormSchedulePreset.value; -const warningAfterOnce = elements.cronFormScheduleOnceWarning.style.display; +const hourlySync = snapshot(); elements.cronFormSchedulePreset.value = 'hourly'; elements.cronFormSchedulePreset.dispatchEvent('change'); -const presetWriteValue = elements.cronFormSchedule.value; -const selectAfterPresetWrite = elements.cronFormSchedulePreset.value; +elements.cronFormScheduleMinute.value = '15'; +elements.cronFormScheduleMinute.dispatchEvent('change'); +const hourlyWrite = snapshot(); -elements.cronFormSchedulePreset.value = "custom"; -elements.cronFormSchedulePreset.dispatchEvent('change'); -const preservedExactPresetValue = elements.cronFormSchedule.value; -const selectAfterExactPresetCustom = elements.cronFormSchedulePreset.value; - -elements.cronFormSchedule.value = "advanced: cron expression"; +elements.cronFormSchedule.value = '30 7 * * *'; elements.cronFormSchedule.dispatchEvent('input'); -elements.cronFormSchedulePreset.value = "custom"; +const dailySync = snapshot(); +elements.cronFormScheduleHour.value = '7'; +elements.cronFormScheduleHour.dispatchEvent('change'); +elements.cronFormScheduleMinute.value = '30'; +elements.cronFormScheduleMinute.dispatchEvent('change'); +const dailyWrite = snapshot(); + +elements.cronFormSchedule.value = '5 22 * * 1-5'; +elements.cronFormSchedule.dispatchEvent('input'); +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'; +elements.cronFormScheduleHour.dispatchEvent('change'); +elements.cronFormScheduleMinute.value = '45'; +elements.cronFormScheduleMinute.dispatchEvent('change'); +const weeklyWrite = snapshot(); + +elements.cronFormSchedule.value = '10 6 15 * *'; +elements.cronFormSchedule.dispatchEvent('input'); +const monthlySync = snapshot(); +elements.cronFormScheduleMonthDay.value = '15'; +elements.cronFormScheduleMonthDay.dispatchEvent('change'); +elements.cronFormScheduleHour.value = '6'; +elements.cronFormScheduleHour.dispatchEvent('change'); +elements.cronFormScheduleMinute.value = '10'; +elements.cronFormScheduleMinute.dispatchEvent('change'); +const monthlyWrite = snapshot(); + +elements.cronFormSchedule.value = '30 7 * * *'; +elements.cronFormSchedule.dispatchEvent('input'); +const dailyAfterMonthlySync = snapshot(); + +elements.cronFormSchedulePreset.value = 'monthly'; elements.cronFormSchedulePreset.dispatchEvent('change'); -const preservedCustomValue = elements.cronFormSchedule.value; -const selectAfterCustom = elements.cronFormSchedulePreset.value; +elements.cronFormScheduleHour.value = '99'; +elements.cronFormScheduleMinute.value = '-8'; +elements.cronFormScheduleMonthDay.value = '99'; +elements.cronFormScheduleMonthDay.dispatchEvent('change'); +const normalizedMonthlyWrite = snapshot(); + +elements.cronFormSchedule.value = '@daily'; +elements.cronFormSchedule.dispatchEvent('input'); +const shorthandCustom = snapshot(); + +elements.cronFormSchedule.value = 'advanced: cron expression'; +elements.cronFormSchedule.dispatchEvent('input'); +const unsupportedCustom = snapshot(); + +elements.cronFormSchedulePreset.value = 'custom'; +elements.cronFormSchedulePreset.dispatchEvent('change'); +const customSelectionPreserved = snapshot(); console.log(JSON.stringify({ - selectAfterDaily, - warningAfterDaily, - selectAfterOnce, - warningAfterOnce, - presetWriteValue, - selectAfterPresetWrite, - preservedExactPresetValue, - selectAfterExactPresetCustom, - preservedCustomValue, - selectAfterCustom, + hourlySync, + hourlyWrite, + dailySync, + dailyWrite, + weekdaysSync, + weeklySync, + weeklyRawSundaySync, + weeklyWrite, + monthlySync, + monthlyWrite, + dailyAfterMonthlySync, + normalizedMonthlyWrite, + shorthandCustom, + unsupportedCustom, + customSelectionPreserved, })); """ result = json.loads(_run_node(script)) - assert result["selectAfterDaily"] == "daily" - assert result["warningAfterDaily"] == "none" - assert result["selectAfterOnce"] == "custom" - assert result["warningAfterOnce"] == "" - assert result["presetWriteValue"] == "every 1h" - assert result["selectAfterPresetWrite"] == "hourly" - assert result["preservedExactPresetValue"] == "every 1h" - assert result["selectAfterExactPresetCustom"] == "custom" - assert result["preservedCustomValue"] == "advanced: cron expression" - assert result["selectAfterCustom"] == "custom" + assert result["hourlySync"]["preset"] == "hourly" + assert result["hourlySync"]["schedule"] == "15 * * * *" + assert result["hourlySync"]["paramsDisplay"] == "" + assert result["hourlySync"]["hourFieldDisplay"] == "none" + assert result["hourlySync"]["minuteFieldDisplay"] == "" + assert result["hourlySync"]["kind"] == "cron" + assert result["hourlyWrite"]["schedule"] == "15 * * * *" + assert result["hourlyWrite"]["kind"] == "cron" + + assert result["dailySync"]["preset"] == "daily" + assert result["dailySync"]["schedule"] == "30 7 * * *" + assert result["dailySync"]["hourFieldDisplay"] == "" + assert result["dailySync"]["minuteFieldDisplay"] == "" + assert result["dailySync"]["weekdayFieldDisplay"] == "none" + assert result["dailySync"]["monthDayFieldDisplay"] == "none" + assert result["dailyWrite"]["schedule"] == "30 7 * * *" + assert result["dailyWrite"]["kind"] == "cron" + + assert result["weekdaysSync"]["preset"] == "weekdays" + assert result["weekdaysSync"]["schedule"] == "5 22 * * 1-5" + assert result["weekdaysSync"]["hourFieldDisplay"] == "" + assert result["weekdaysSync"]["minuteFieldDisplay"] == "" + assert result["weekdaysSync"]["weekdayFieldDisplay"] == "none" + assert result["weekdaysSync"]["monthDayFieldDisplay"] == "none" + + assert result["weeklySync"]["preset"] == "weekly" + 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" + + assert result["monthlySync"]["preset"] == "monthly" + assert result["monthlySync"]["schedule"] == "10 6 15 * *" + assert result["monthlySync"]["monthDayFieldDisplay"] == "" + assert result["monthlySync"]["weekdayFieldDisplay"] == "none" + assert result["monthlyWrite"]["schedule"] == "10 6 15 * *" + assert result["monthlyWrite"]["kind"] == "cron" + + assert result["dailyAfterMonthlySync"]["preset"] == "daily" + assert result["dailyAfterMonthlySync"]["schedule"] == "30 7 * * *" + assert result["dailyAfterMonthlySync"]["hour"] == "7" + assert result["dailyAfterMonthlySync"]["minute"] == "30" + assert result["dailyAfterMonthlySync"]["monthDayFieldDisplay"] == "none" + + assert result["normalizedMonthlyWrite"]["schedule"] == "0 23 31 * *" + assert result["normalizedMonthlyWrite"]["hour"] == "23" + assert result["normalizedMonthlyWrite"]["minute"] == "0" + assert result["normalizedMonthlyWrite"]["monthDay"] == "31" + assert result["normalizedMonthlyWrite"]["kind"] == "cron" + + assert result["shorthandCustom"]["preset"] == "custom" + assert result["shorthandCustom"]["schedule"] == "@daily" + assert result["shorthandCustom"]["paramsDisplay"] == "none" + + assert result["unsupportedCustom"]["preset"] == "custom" + assert result["unsupportedCustom"]["schedule"] == "advanced: cron expression" + assert result["unsupportedCustom"]["paramsDisplay"] == "none" + + assert result["customSelectionPreserved"]["preset"] == "custom" + assert result["customSelectionPreserved"]["schedule"] == "advanced: cron expression" + 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 = {}; +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 }); + }, + }; +} +function t(key) { + const dict = { + cron_schedule_preset_label: 'Preset', + cron_schedule_preset_hourly: 'Hourly', + cron_schedule_preset_daily: 'Daily', + cron_schedule_preset_weekdays: 'Weekdays', + cron_schedule_preset_weekly: 'Weekly', + cron_schedule_preset_monthly: 'Monthly', + cron_schedule_preset_custom: 'Custom', + }; + return dict[key]; +} +function esc(value) { return value == null ? '' : String(value); } + +[ + 'cronFormSchedule', + 'cronFormSchedulePreset', + 'cronFormSchedulePresetParams', + 'cronFormScheduleHourField', + 'cronFormScheduleMinuteField', + 'cronFormScheduleWeekdayField', + 'cronFormScheduleMonthDayField', + 'cronFormScheduleHour', + 'cronFormScheduleMinute', + 'cronFormScheduleWeekday', + 'cronFormScheduleMonthDay', +].forEach((id) => { + elements[id] = makeElement(); +}); +elements.cronFormScheduleOnceWarning = { style: { display: 'none' } }; +elements.cronFormSchedulePresetParams.style.display = 'none'; + +_initCronSchedulePresetControls(); + +elements.cronFormSchedule.value = 'advanced: cron expression'; +elements.cronFormSchedule.dispatchEvent('input'); +elements.cronFormSchedulePreset.value = 'custom'; +elements.cronFormSchedulePreset.dispatchEvent('change'); + +console.log(JSON.stringify({ + preset: elements.cronFormSchedulePreset.value, + schedule: elements.cronFormSchedule.value, + paramsDisplay: elements.cronFormSchedulePresetParams.style.display, +})); +""" + result = json.loads(_run_node(script)) + + assert result["preset"] == "custom" + assert result["schedule"] == "advanced: cron expression" + assert result["paramsDisplay"] == "none" def test_cron_form_surfaces_one_shot_warning_copy_markers_and_preset_markup(): @@ -202,6 +464,11 @@ def test_cron_form_surfaces_one_shot_warning_copy_markers_and_preset_markup(): assert "id=\"cronFormScheduleOnceWarning\"" in panels assert "id=\"cronFormSchedulePreset\"" in panels + assert "id=\"cronFormSchedulePresetParams\"" in panels + assert "id=\"cronFormScheduleHour\"" in panels + assert "id=\"cronFormScheduleMinute\"" in panels + assert "id=\"cronFormScheduleWeekday\"" in panels + assert "id=\"cronFormScheduleMonthDay\"" in panels assert "cron_schedule_once_warning" in panels assert "_cronSchedulePresetIdForValue" in panels assert "_cronSchedulePresetOptionHtml" in panels @@ -210,12 +477,15 @@ def test_cron_form_surfaces_one_shot_warning_copy_markers_and_preset_markup(): assert "addEventListener('change', _syncCronSchedulePresetAndWarning" in panels assert "addEventListener('change', _applyCronSchedulePresetSelection" in panels assert ".cron-once-warning" in style - assert "id: 'hourly'" in panels - assert "id: 'daily'" in panels - assert "id: 'weekdays'" in panels - assert "id: 'weekly'" in panels - assert "id: 'monthly'" in panels - assert "id: 'custom'" in panels + assert ".cron-schedule-preset-shell" in style + assert ".cron-schedule-preset-params" in style + assert ".cron-schedule-preset-field" in style + assert ".cron-schedule-preset-time-hint" in style + 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 + assert "fields: ['hour', 'minute', 'monthDay']" in panels assert "Duration forms like '30m' run once" in i18n