diff --git a/CHANGELOG.md b/CHANGELOG.md index 7725e0c05..9b361d8a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added +- **Pick the exact time and day for scheduled jobs — no cron syntax needed.** The Tasks "New job" form now builds the schedule as a plain sentence: choose a frequency (Hourly / Daily / Weekdays / Weekly / Monthly / Custom) and it shows just the controls that matter — a time picker, a day-of-week dropdown, or a day-of-month dropdown — e.g. "Weekly on Monday at 09:00." The generated cron expression is previewed inline, and the raw cron field now appears only under "Custom." Thanks @rodboev. (#5554, #5552) - **Transparent Stream fades newly streamed words in.** In Transparent Stream mode, newly arriving assistant words now fade in at the prose level as they stream — without animating (or re-flickering) the thinking rows, tool rows, or the transparent event rows. It respects your OS "reduce motion" setting (no fade when reduced motion is on) and is part of the Transparent Stream experience regardless of the "Fade text effect" toggle. Thanks @rodboev. (#5506, #5367) - **The Preferences default-model setting now uses the rich model picker.** The "Default Model" control in Settings → Preferences was a plain dropdown; it now uses the same searchable, provider-grouped picker as the composer (with a "Custom Model ID" field), so choosing a default model is consistent everywhere. On touch devices the picker no longer pops the keyboard the instant you open it (it still focuses search on desktop and while you're typing), and its label now activates the picker directly. Thanks @rodboev. (#5502, #5497) - **Scheduled jobs get a preset schedule builder.** The cron job form now has a "Preset" dropdown (Hourly / Daily / Weekdays / Weekly / Monthly, plus Custom) above the Schedule field — pick one and it fills the schedule for you (e.g. Daily → `0 9 * * *`, Hourly → `every 1h`) instead of hand-writing a cron expression. "Custom" keeps the free-text field, and editing an existing job maps its schedule back to the matching preset. Thanks @rodboev. (#5438, #5427) diff --git a/static/i18n.js b/static/i18n.js index bfb694a03..88832e471 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1584,14 +1584,31 @@ const LOCALES = { // cron form cron_name_label: 'Name', cron_name_placeholder: 'Optional', - cron_schedule_label: 'Schedule', - cron_schedule_preset_label: 'Preset', + cron_schedule_label: 'Cron expression', + cron_schedule_preset_label: 'Schedule', cron_schedule_preset_hourly: 'Hourly', cron_schedule_preset_daily: 'Daily', - cron_schedule_preset_weekdays: 'Weekdays', + cron_schedule_preset_weekdays: 'Weekdays (Mon–Fri)', 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: 'Day of week', + cron_schedule_month_day_label: 'Day of month', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', + 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 +3275,23 @@ const LOCALES = { cron_schedule_preset_weekly: 'Ogni settimana', cron_schedule_preset_monthly: 'Ogni mese', cron_schedule_preset_custom: 'Personalizzato', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', + 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 +4966,23 @@ const LOCALES = { cron_schedule_preset_weekly: '毎週', cron_schedule_preset_monthly: '毎月', cron_schedule_preset_custom: 'カスタム', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', + 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 +6299,23 @@ const LOCALES = { cron_schedule_preset_weekly: 'Еженедельно', cron_schedule_preset_monthly: 'Ежемесячно', cron_schedule_preset_custom: 'Пользовательский', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', + 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 +7909,23 @@ const LOCALES = { cron_schedule_preset_weekly: 'Semanal', cron_schedule_preset_monthly: 'Mensual', cron_schedule_preset_custom: 'Personalizado', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', + 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 +9201,23 @@ const LOCALES = { cron_schedule_preset_weekly: 'Wöchentlich', cron_schedule_preset_monthly: 'Monatlich', cron_schedule_preset_custom: 'Benutzerdefiniert', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', + 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 +11165,23 @@ const LOCALES = { cron_schedule_preset_weekly: '每周', cron_schedule_preset_monthly: '每月', cron_schedule_preset_custom: '自定义', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', + 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 +13150,23 @@ const LOCALES = { cron_schedule_preset_weekly: '每週', cron_schedule_preset_monthly: '每月', cron_schedule_preset_custom: '自訂', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', + 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 +14574,23 @@ const LOCALES = { cron_schedule_preset_weekly: 'Semanal', cron_schedule_preset_monthly: 'Mensal', cron_schedule_preset_custom: 'Personalizado', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', + 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 +16241,23 @@ const LOCALES = { cron_schedule_preset_weekly: 'Weekly', cron_schedule_preset_monthly: 'Monthly', cron_schedule_preset_custom: 'Custom', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', + 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 +18000,23 @@ const LOCALES = { cron_schedule_preset_weekly: 'Hebdomadaire', cron_schedule_preset_monthly: 'Mensuel', cron_schedule_preset_custom: 'Personnalisé', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', + 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 +19591,23 @@ const LOCALES = { cron_schedule_preset_weekly: 'Haftalık', cron_schedule_preset_monthly: 'Aylık', cron_schedule_preset_custom: 'Özel', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', + 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 +21362,23 @@ const LOCALES = { cron_schedule_preset_weekly: 'Co tydzień', cron_schedule_preset_monthly: 'Co miesiąc', cron_schedule_preset_custom: 'Niestandardowe', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', + 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 +22887,23 @@ 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_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', + 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 c841a4ef1..13ba281e8 100644 --- a/static/panels.js +++ b/static/panels.js @@ -464,15 +464,29 @@ function _syncCronScheduleWarning() { const warning = $('cronFormScheduleOnceWarning'); if (!input || !warning) return; warning.style.display = _cronScheduleKindForInput(input.value) === 'once' ? '' : 'none'; + _syncCronSchedulePreview(); +} + +// Live preview of the generated cron expression in the preset hint line (the +// cron-job.org / GitHub-schedule-editor convention) so a cron-literate user sees +// exactly what the friendly controls produce. Empty on Custom (the raw field is shown). +function _syncCronSchedulePreview() { + const preview = $('cronFormSchedulePreview'); + const presetEl = $('cronFormSchedulePreset'); + const scheduleEl = $('cronFormSchedule'); + if (!preview) return; + const presetId = presetEl ? presetEl.value : 'custom'; + const expr = String((scheduleEl && scheduleEl.value) || '').trim(); + preview.textContent = (presetId !== 'custom' && expr) ? `${expr} · ` : ''; } 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: 'hourly', label: 'cron_schedule_preset_hourly', fallback: 'Hourly', fields: ['minute'], defaults: { minute: 0 } }, + { id: 'daily', label: 'cron_schedule_preset_daily', fallback: 'Daily', fields: ['time'], defaults: { hour: 9, minute: 0 } }, + { id: 'weekdays', label: 'cron_schedule_preset_weekdays', fallback: 'Weekdays (Mon–Fri)', fields: ['time'], defaults: { hour: 9, minute: 0 } }, + { id: 'weekly', label: 'cron_schedule_preset_weekly', fallback: 'Weekly', fields: ['weekday', 'time'], defaults: { hour: 9, minute: 0, weekday: 1 } }, + { id: 'monthly', label: 'cron_schedule_preset_monthly', fallback: 'Monthly', fields: ['monthDay', 'time'], defaults: { hour: 9, minute: 0, monthDay: 1 } }, + { id: 'custom', label: 'cron_schedule_preset_custom', fallback: 'Custom', fields: [] }, ]; function _cronSchedulePresetOptionHtml() { @@ -481,18 +495,211 @@ function _cronSchedulePresetOptionHtml() { .join(''); } -function _cronSchedulePresetIdForValue(value) { +function _cronSchedulePresetForId(presetId) { + return CRON_SCHEDULE_PRESETS.find((entry) => entry.id === presetId) || null; +} + +function _cronSchedulePresetControlIds() { + return { + time: 'cronFormScheduleTime', + minute: 'cronFormScheduleMinute', + weekday: 'cronFormScheduleWeekday', + monthDay: 'cronFormScheduleMonthDay', + }; +} + +// Which visible control wrapper each logical field lives in. `hour`+`minute` for +// time-based presets share the single #cronFormScheduleTime picker (in the Time +// field); `minute` alone (Hourly) uses the standalone Minute field. +function _cronSchedulePresetFieldWrapId(field) { + if (field === 'time' || field === 'hour') return 'cronFormScheduleTimeField'; + if (field === 'minute') return 'cronFormScheduleMinuteField'; + if (field === 'weekday') return 'cronFormScheduleWeekdayField'; + if (field === 'monthDay') return 'cronFormScheduleMonthDayField'; + return ''; +} + +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) { + // hour/minute for time-based presets come from the single #cronFormScheduleTime + // picker ("HH:MM"); the standalone Minute box (Hourly) still reads directly. + if (field === 'hour' || field === 'minute') { + const timeEl = $('cronFormScheduleTime'); + const minuteBox = $('cronFormScheduleMinute'); + // Hourly uses the standalone minute box; if the Time picker isn't the visible + // source for minute, prefer the minute box when it's the shown control. + if (field === 'minute' && minuteBox && (!timeEl || _cronScheduleMinuteBoxIsActive())) { + return _cronSchedulePresetNormalizeValue('minute', minuteBox.value, fallback); + } + if (timeEl && /^\d{1,2}:\d{2}$/.test(String(timeEl.value || '').trim())) { + const [h, m] = String(timeEl.value).trim().split(':'); + return _cronSchedulePresetNormalizeValue(field, field === 'hour' ? h : m, fallback); + } + return _cronSchedulePresetNormalizeValue(field, fallback, fallback); + } + const el = _cronSchedulePresetFieldEl(field); + const raw = el ? String(el.value || '').trim() : ''; + return _cronSchedulePresetNormalizeValue(field, raw, fallback); +} + +// The standalone Minute box is the active minute source only for the Hourly preset +// (the only preset whose visible fields include a bare 'minute'). +function _cronScheduleMinuteBoxIsActive() { + const presetEl = $('cronFormSchedulePreset'); + return !!(presetEl && presetEl.value === 'hourly'); +} + +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) { + // Write hour/minute into the single time picker as zero-padded HH:MM. + if (values.hour != null || values.minute != null) { + const timeEl = $('cronFormScheduleTime'); + if (timeEl) { + const h = _cronSchedulePresetNormalizeValue('hour', values.hour, values.hour ?? 9); + const m = _cronSchedulePresetNormalizeValue('minute', values.minute, values.minute ?? 0); + timeEl.value = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; + } + } + if (values.minute != null) { + const minuteBox = $('cronFormScheduleMinute'); + if (minuteBox) minuteBox.value = _cronSchedulePresetNormalizeValue('minute', values.minute, values.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 customRow = $('cronFormScheduleCustomRow'); + const preset = _cronSchedulePresetForId(presetId); + const showFields = preset && Array.isArray(preset.fields) ? preset.fields : []; + const isCustom = presetId === 'custom'; + // Preset param controls hide entirely on Custom; the raw cron expression row + // shows ONLY on Custom (kept in the DOM as a hidden field otherwise). + if (wrapper) wrapper.style.display = isCustom ? 'none' : ''; + if (customRow) customRow.style.display = isCustom ? '' : 'none'; + ['time', 'minute', 'weekday', 'monthDay'].forEach((field) => { + const fieldWrap = $(_cronSchedulePresetFieldWrapId(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 +712,29 @@ 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(); +} + +// Regenerate the cron expression from the current field values WITHOUT writing the +// clamped values back into the field the user is editing — so typing into the Minute +// box (or the time picker) doesn't snap a half-entered value to the default/clamp +// mid-keystroke. Value clamping still happens on `change`/blur via the full apply. +function _regenCronScheduleFromFields() { + const presetEl = $('cronFormSchedulePreset'); + const scheduleEl = $('cronFormSchedule'); + if (!presetEl || !scheduleEl) return; + const presetId = presetEl.value; + if (presetId === 'custom') return; + scheduleEl.value = _cronSchedulePresetValueForSelection(presetId); _syncCronScheduleWarning(); } @@ -519,7 +743,25 @@ function _initCronSchedulePresetControls() { const scheduleEl = $('cronFormSchedule'); if (!presetEl || !scheduleEl) return; presetEl.addEventListener('change', _applyCronSchedulePresetSelection); - scheduleEl.addEventListener('input', _syncCronSchedulePresetAndWarning); + if ($('cronFormSchedulePresetParams')) { + ['cronFormScheduleTime', 'cronFormScheduleMinute', 'cronFormScheduleWeekday', 'cronFormScheduleMonthDay'].forEach((id) => { + const el = $(id); + if (!el) return; + // On `change`/blur, clamp + normalize (writes values back). On `input` + // (per-keystroke), only regenerate the expression — never rewrite the field + // being typed into, so partial input like clearing Hour to type "14" isn't + // snapped to the default (#5554 UX fix). + el.addEventListener('change', _applyCronSchedulePresetSelection); + el.addEventListener('input', _regenCronScheduleFromFields); + }); + } + // On raw-cron `input` (only reachable on Custom, where the raw row is shown), + // update ONLY the warning + preview — do NOT re-detect the preset or sync + // visibility, or a partial value momentarily matching a preset (e.g. typing + // "0 9 * * 1,3" transiently equals Weekly's "0 9 * * 1") would switch the preset + // and hide the focused raw field mid-keystroke (#5554). Preset re-detection runs + // on initial render and on `change`/blur. + scheduleEl.addEventListener('input', _syncCronScheduleWarning); scheduleEl.addEventListener('change', _syncCronSchedulePresetAndWarning); _syncCronSchedulePresetAndWarning(); } @@ -1299,7 +1541,7 @@ function openCronCreate(){ _cronMode = 'create'; _cronIsDuplicate = false; _cronSelectedSkills = []; - _renderCronForm({ name:'', schedule:'', prompt:'', deliver:'local', profile:'', toast_notifications:true, model:'', provider:'', isEdit:false }); + _renderCronForm({ name:'', schedule:'0 9 * * *', prompt:'', deliver:'local', profile:'', toast_notifications:true, model:'', provider:'', isEdit:false }); _cronSkillsCache = null; api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[]; _bindCronSkillPicker();}).catch(()=>{}); loadCronProfiles().then(()=>_refreshCronProfileSelect('')).catch(()=>{}); @@ -1370,13 +1612,44 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notif