mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 12:40:18 +00:00
Merge pull request #5654 from nesquena/review-5554
Release: redesign cron preset schedule builder — sentence UI (#5554)
This commit is contained in:
@@ -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)
|
||||
|
||||
+241
-3
@@ -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',
|
||||
|
||||
+296
-23
@@ -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
|
||||
<input type="text" id="cronFormName" value="${esc(name || '')}" placeholder="${esc(t('cron_name_placeholder') || 'Optional')}" autocomplete="off">
|
||||
</div>
|
||||
<div class="detail-form-row">
|
||||
<label for="cronFormSchedulePreset">${esc(t('cron_schedule_preset_label') || 'Preset')}</label>
|
||||
<select id="cronFormSchedulePreset">
|
||||
${_cronSchedulePresetOptionHtml()}
|
||||
</select>
|
||||
<label for="cronFormSchedulePreset">${esc(t('cron_schedule_preset_label') || 'Schedule')}</label>
|
||||
<div class="cron-schedule-preset-shell">
|
||||
<select id="cronFormSchedulePreset" class="cron-schedule-preset-select">
|
||||
${_cronSchedulePresetOptionHtml()}
|
||||
</select>
|
||||
<div id="cronFormSchedulePresetParams" class="cron-schedule-preset-params" style="display:none">
|
||||
<div class="cron-schedule-preset-field" id="cronFormScheduleWeekdayField">
|
||||
<span class="cron-schedule-preset-conj" aria-hidden="true">${esc(t('cron_schedule_conj_on') || 'on')}</span>
|
||||
<select id="cronFormScheduleWeekday" aria-label="${esc(t('cron_schedule_weekday_label') || 'Day of week')}">
|
||||
<option value="0">${esc(t('cron_weekday_sun') || 'Sunday')}</option>
|
||||
<option value="1" selected>${esc(t('cron_weekday_mon') || 'Monday')}</option>
|
||||
<option value="2">${esc(t('cron_weekday_tue') || 'Tuesday')}</option>
|
||||
<option value="3">${esc(t('cron_weekday_wed') || 'Wednesday')}</option>
|
||||
<option value="4">${esc(t('cron_weekday_thu') || 'Thursday')}</option>
|
||||
<option value="5">${esc(t('cron_weekday_fri') || 'Friday')}</option>
|
||||
<option value="6">${esc(t('cron_weekday_sat') || 'Saturday')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cron-schedule-preset-field" id="cronFormScheduleMonthDayField">
|
||||
<span class="cron-schedule-preset-conj" aria-hidden="true">${esc(t('cron_schedule_conj_on_day') || 'on day')}</span>
|
||||
<select id="cronFormScheduleMonthDay" aria-label="${esc(t('cron_schedule_month_day_label') || 'Day of month')}">
|
||||
${Array.from({length:31},(_,i)=>`<option value="${i+1}">${i+1}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cron-schedule-preset-field" id="cronFormScheduleTimeField">
|
||||
<span class="cron-schedule-preset-conj" aria-hidden="true">${esc(t('cron_schedule_conj_at') || 'at')}</span>
|
||||
<input type="time" id="cronFormScheduleTime" value="09:00" step="60" autocomplete="off" aria-label="${esc(t('cron_schedule_time_label') || 'Time')}">
|
||||
</div>
|
||||
<div class="cron-schedule-preset-field" id="cronFormScheduleMinuteField">
|
||||
<span class="cron-schedule-preset-conj" aria-hidden="true">${esc(t('cron_schedule_conj_at_minute') || 'at minute')}</span>
|
||||
<input type="number" id="cronFormScheduleMinute" min="0" max="59" step="1" value="0" autocomplete="off" aria-label="${esc(t('cron_schedule_minute_label') || 'Minute')}">
|
||||
</div>
|
||||
<div class="detail-form-hint cron-schedule-preset-time-hint"><span id="cronFormSchedulePreview" class="cron-schedule-preview"></span>${esc(t('cron_schedule_time_hint') || 'Time is server time; cron runs server-side.')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-form-row">
|
||||
<label for="cronFormSchedule">${esc(t('cron_schedule_label') || 'Schedule')}</label>
|
||||
<div class="detail-form-row" id="cronFormScheduleCustomRow" style="display:none">
|
||||
<label for="cronFormSchedule">${esc(t('cron_schedule_label') || 'Cron expression')}</label>
|
||||
<input type="text" id="cronFormSchedule" value="${esc(schedule || '')}" placeholder="0 9 * * * — every 1h — @daily" autocomplete="off" required>
|
||||
<div class="detail-form-hint">${esc(t('cron_schedule_hint') || "Cron expression or shorthand like 'every 1h'.")}</div>
|
||||
<div id="cronFormScheduleOnceWarning" class="detail-form-warning cron-once-warning" style="display:none">${esc(t('cron_schedule_once_warning') || "Duration forms like '30m' run once and are removed after running. Use 'every 30m' to keep a recurring job.")}</div>
|
||||
|
||||
@@ -6318,6 +6318,8 @@ 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[type="time"],
|
||||
.detail-form-row input:not([type]),
|
||||
.detail-form-row select,
|
||||
.detail-form-row textarea{
|
||||
@@ -6329,10 +6331,23 @@ 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:10px;align-items:center;}
|
||||
.cron-schedule-preset-shell > select.cron-schedule-preset-select{width:auto;min-width:150px;flex:0 0 auto;}
|
||||
.cron-schedule-preset-params{display:flex;flex-wrap:wrap;gap:8px 10px;align-items:center;min-width:0;}
|
||||
.cron-schedule-preset-field{display:inline-flex;flex-direction:row;align-items:center;gap:6px;}
|
||||
.cron-schedule-preset-conj{font-size:13px;color:var(--muted);white-space:nowrap;}
|
||||
.cron-schedule-preset-field select,.cron-schedule-preset-field input[type="time"]{width:auto;min-width:0;}
|
||||
.cron-schedule-preset-field select{min-width:120px;}
|
||||
#cronFormScheduleMonthDay{min-width:72px;}
|
||||
.cron-schedule-preset-field input[type="time"]{min-width:120px;}
|
||||
.cron-schedule-preset-field input[type="number"]{width:64px;}
|
||||
.cron-schedule-preset-time-hint{flex-basis:100%;margin-top:2px;}
|
||||
.cron-schedule-preview{font-family:'SF Mono',ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text);opacity:.85;}
|
||||
.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;}
|
||||
|
||||
@@ -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,306 @@ 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);
|
||||
},
|
||||
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: 'Schedule',
|
||||
cron_schedule_preset_hourly: 'Hourly',
|
||||
cron_schedule_preset_daily: 'Daily',
|
||||
cron_schedule_preset_weekdays: 'Weekdays (Mon-Fri)',
|
||||
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',
|
||||
'cronFormScheduleCustomRow',
|
||||
'cronFormScheduleTimeField',
|
||||
'cronFormScheduleMinuteField',
|
||||
'cronFormScheduleWeekdayField',
|
||||
'cronFormScheduleMonthDayField',
|
||||
'cronFormScheduleTime',
|
||||
'cronFormScheduleMinute',
|
||||
'cronFormScheduleWeekday',
|
||||
'cronFormScheduleMonthDay',
|
||||
'cronFormSchedulePreview',
|
||||
].forEach((id) => {
|
||||
elements[id] = makeElement();
|
||||
});
|
||||
elements.cronFormScheduleOnceWarning = { style: { display: 'none' } };
|
||||
elements.cronFormSchedulePresetParams.style.display = 'none';
|
||||
|
||||
_initCronSchedulePresetControls();
|
||||
|
||||
// Split "HH:MM" from the time picker so assertions can read hour/minute separately.
|
||||
function timeHour() { const v = String(elements.cronFormScheduleTime.value || ''); return v.includes(':') ? v.split(':')[0].replace(/^0/, '') || '0' : ''; }
|
||||
function timeMin() { const v = String(elements.cronFormScheduleTime.value || ''); return v.includes(':') ? String(parseInt(v.split(':')[1], 10)) : ''; }
|
||||
|
||||
function snapshot() {
|
||||
return {
|
||||
preset: elements.cronFormSchedulePreset.value,
|
||||
schedule: elements.cronFormSchedule.value,
|
||||
paramsDisplay: elements.cronFormSchedulePresetParams.style.display,
|
||||
customRowDisplay: elements.cronFormScheduleCustomRow.style.display,
|
||||
timeFieldDisplay: elements.cronFormScheduleTimeField.style.display,
|
||||
minuteFieldDisplay: elements.cronFormScheduleMinuteField.style.display,
|
||||
weekdayFieldDisplay: elements.cronFormScheduleWeekdayField.style.display,
|
||||
monthDayFieldDisplay: elements.cronFormScheduleMonthDayField.style.display,
|
||||
time: elements.cronFormScheduleTime.value,
|
||||
hour: timeHour(),
|
||||
minute: timeMin(),
|
||||
minuteBox: elements.cronFormScheduleMinute.value,
|
||||
weekday: elements.cronFormScheduleWeekday.value,
|
||||
monthDay: elements.cronFormScheduleMonthDay.value,
|
||||
warning: elements.cronFormScheduleOnceWarning.style.display,
|
||||
kind: _cronScheduleKindForInput(elements.cronFormSchedule.value),
|
||||
};
|
||||
}
|
||||
|
||||
// Hourly: raw sync + editing the standalone minute box.
|
||||
elements.cronFormSchedule.value = '15 * * * *';
|
||||
elements.cronFormSchedule.dispatchEvent('change');
|
||||
const hourlySync = snapshot();
|
||||
elements.cronFormSchedulePreset.value = 'hourly';
|
||||
elements.cronFormSchedulePreset.dispatchEvent('change');
|
||||
elements.cronFormScheduleMinute.value = '15';
|
||||
elements.cronFormScheduleMinute.dispatchEvent('change');
|
||||
const hourlyWrite = snapshot();
|
||||
|
||||
// Daily: raw sync populates the time picker; editing the time regenerates.
|
||||
elements.cronFormSchedule.value = '30 7 * * *';
|
||||
elements.cronFormSchedule.dispatchEvent('change');
|
||||
const dailySync = snapshot();
|
||||
elements.cronFormScheduleTime.value = '07:30';
|
||||
elements.cronFormScheduleTime.dispatchEvent('change');
|
||||
const dailyWrite = snapshot();
|
||||
|
||||
elements.cronFormSchedule.value = '5 22 * * 1-5';
|
||||
elements.cronFormSchedule.dispatchEvent('change');
|
||||
const weekdaysSync = snapshot();
|
||||
|
||||
elements.cronFormSchedule.value = '45 8 * * 3';
|
||||
elements.cronFormSchedule.dispatchEvent('change');
|
||||
const weeklySync = snapshot();
|
||||
elements.cronFormSchedule.value = '0 9 * * 7';
|
||||
elements.cronFormSchedule.dispatchEvent('change');
|
||||
const weeklyRawSundaySync = snapshot();
|
||||
elements.cronFormScheduleWeekday.value = '3';
|
||||
elements.cronFormScheduleWeekday.dispatchEvent('change');
|
||||
elements.cronFormScheduleTime.value = '08:45';
|
||||
elements.cronFormScheduleTime.dispatchEvent('change');
|
||||
const weeklyWrite = snapshot();
|
||||
|
||||
elements.cronFormSchedule.value = '10 6 15 * *';
|
||||
elements.cronFormSchedule.dispatchEvent('change');
|
||||
const monthlySync = snapshot();
|
||||
elements.cronFormScheduleMonthDay.value = '15';
|
||||
elements.cronFormScheduleMonthDay.dispatchEvent('change');
|
||||
elements.cronFormScheduleTime.value = '06:10';
|
||||
elements.cronFormScheduleTime.dispatchEvent('change');
|
||||
const monthlyWrite = snapshot();
|
||||
|
||||
elements.cronFormSchedule.value = '30 7 * * *';
|
||||
elements.cronFormSchedule.dispatchEvent('change');
|
||||
const dailyAfterMonthlySync = snapshot();
|
||||
|
||||
// Monthly clamp: out-of-range monthDay clamps to 31 on change.
|
||||
elements.cronFormSchedulePreset.value = 'monthly';
|
||||
elements.cronFormSchedulePreset.dispatchEvent('change');
|
||||
elements.cronFormScheduleTime.value = '23:00';
|
||||
elements.cronFormScheduleMonthDay.value = '99';
|
||||
elements.cronFormScheduleMonthDay.dispatchEvent('change');
|
||||
const normalizedMonthlyWrite = snapshot();
|
||||
|
||||
elements.cronFormSchedule.value = '@daily';
|
||||
elements.cronFormSchedule.dispatchEvent('change');
|
||||
const shorthandCustom = snapshot();
|
||||
|
||||
elements.cronFormSchedule.value = 'advanced: cron expression';
|
||||
elements.cronFormSchedule.dispatchEvent('change');
|
||||
const unsupportedCustom = snapshot();
|
||||
|
||||
elements.cronFormSchedulePreset.value = 'custom';
|
||||
elements.cronFormSchedulePreset.dispatchEvent('change');
|
||||
const customSelectionPreserved = snapshot();
|
||||
|
||||
console.log(JSON.stringify({
|
||||
hourlySync,
|
||||
hourlyWrite,
|
||||
dailySync,
|
||||
dailyWrite,
|
||||
weekdaysSync,
|
||||
weeklySync,
|
||||
weeklyRawSundaySync,
|
||||
weeklyWrite,
|
||||
monthlySync,
|
||||
monthlyWrite,
|
||||
dailyAfterMonthlySync,
|
||||
normalizedMonthlyWrite,
|
||||
shorthandCustom,
|
||||
unsupportedCustom,
|
||||
customSelectionPreserved,
|
||||
}));
|
||||
"""
|
||||
result = json.loads(_run_node(script))
|
||||
|
||||
assert result["hourlySync"]["preset"] == "hourly"
|
||||
assert result["hourlySync"]["schedule"] == "15 * * * *"
|
||||
assert result["hourlySync"]["paramsDisplay"] == ""
|
||||
assert result["hourlySync"]["customRowDisplay"] == "none"
|
||||
assert result["hourlySync"]["timeFieldDisplay"] == "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"]["timeFieldDisplay"] == ""
|
||||
assert result["dailySync"]["minuteFieldDisplay"] == "none"
|
||||
assert result["dailySync"]["weekdayFieldDisplay"] == "none"
|
||||
assert result["dailySync"]["monthDayFieldDisplay"] == "none"
|
||||
assert result["dailySync"]["customRowDisplay"] == "none"
|
||||
assert result["dailySync"]["time"] == "07:30"
|
||||
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"]["timeFieldDisplay"] == ""
|
||||
assert result["weekdaysSync"]["minuteFieldDisplay"] == "none"
|
||||
assert result["weekdaysSync"]["weekdayFieldDisplay"] == "none"
|
||||
assert result["weekdaysSync"]["monthDayFieldDisplay"] == "none"
|
||||
assert result["weekdaysSync"]["time"] == "22:05"
|
||||
|
||||
assert result["weeklySync"]["preset"] == "weekly"
|
||||
assert result["weeklySync"]["schedule"] == "45 8 * * 3"
|
||||
assert result["weeklySync"]["weekdayFieldDisplay"] == ""
|
||||
assert result["weeklySync"]["timeFieldDisplay"] == ""
|
||||
assert result["weeklySync"]["monthDayFieldDisplay"] == "none"
|
||||
assert result["weeklySync"]["weekday"] == "3"
|
||||
assert result["weeklySync"]["time"] == "08:45"
|
||||
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"]["timeFieldDisplay"] == ""
|
||||
assert result["monthlySync"]["weekdayFieldDisplay"] == "none"
|
||||
assert result["monthlySync"]["monthDay"] == "15"
|
||||
assert result["monthlySync"]["time"] == "06:10"
|
||||
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"]["time"] == "07:30"
|
||||
assert result["dailyAfterMonthlySync"]["monthDayFieldDisplay"] == "none"
|
||||
|
||||
assert result["normalizedMonthlyWrite"]["schedule"] == "0 23 31 * *"
|
||||
assert result["normalizedMonthlyWrite"]["time"] == "23:00"
|
||||
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["shorthandCustom"]["customRowDisplay"] == ""
|
||||
|
||||
assert result["unsupportedCustom"]["preset"] == "custom"
|
||||
assert result["unsupportedCustom"]["schedule"] == "advanced: cron expression"
|
||||
assert result["unsupportedCustom"]["paramsDisplay"] == "none"
|
||||
assert result["unsupportedCustom"]["customRowDisplay"] == ""
|
||||
|
||||
assert result["customSelectionPreserved"]["preset"] == "custom"
|
||||
assert result["customSelectionPreserved"]["schedule"] == "advanced: cron expression"
|
||||
assert result["customSelectionPreserved"]["paramsDisplay"] == "none"
|
||||
assert result["customSelectionPreserved"]["customRowDisplay"] == ""
|
||||
|
||||
|
||||
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);
|
||||
@@ -135,64 +434,42 @@ 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.value = 'advanced: cron expression';
|
||||
elements.cronFormSchedule.dispatchEvent('input');
|
||||
const selectAfterDaily = elements.cronFormSchedulePreset.value;
|
||||
const warningAfterDaily = elements.cronFormScheduleOnceWarning.style.display;
|
||||
|
||||
elements.cronFormSchedule.value = "30m";
|
||||
elements.cronFormSchedule.dispatchEvent('input');
|
||||
const selectAfterOnce = elements.cronFormSchedulePreset.value;
|
||||
const warningAfterOnce = elements.cronFormScheduleOnceWarning.style.display;
|
||||
|
||||
elements.cronFormSchedulePreset.value = 'hourly';
|
||||
elements.cronFormSchedulePreset.value = 'custom';
|
||||
elements.cronFormSchedulePreset.dispatchEvent('change');
|
||||
const presetWriteValue = elements.cronFormSchedule.value;
|
||||
const selectAfterPresetWrite = elements.cronFormSchedulePreset.value;
|
||||
|
||||
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.dispatchEvent('input');
|
||||
elements.cronFormSchedulePreset.value = "custom";
|
||||
elements.cronFormSchedulePreset.dispatchEvent('change');
|
||||
const preservedCustomValue = elements.cronFormSchedule.value;
|
||||
const selectAfterCustom = elements.cronFormSchedulePreset.value;
|
||||
|
||||
console.log(JSON.stringify({
|
||||
selectAfterDaily,
|
||||
warningAfterDaily,
|
||||
selectAfterOnce,
|
||||
warningAfterOnce,
|
||||
presetWriteValue,
|
||||
selectAfterPresetWrite,
|
||||
preservedExactPresetValue,
|
||||
selectAfterExactPresetCustom,
|
||||
preservedCustomValue,
|
||||
selectAfterCustom,
|
||||
preset: elements.cronFormSchedulePreset.value,
|
||||
schedule: elements.cronFormSchedule.value,
|
||||
paramsDisplay: elements.cronFormSchedulePresetParams.style.display,
|
||||
}));
|
||||
"""
|
||||
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["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,20 +479,44 @@ 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=\"cronFormScheduleCustomRow\"" in panels
|
||||
assert "id=\"cronFormScheduleTime\"" in panels
|
||||
assert "type=\"time\"" in panels
|
||||
assert "id=\"cronFormScheduleMinute\"" in panels
|
||||
assert "id=\"cronFormScheduleWeekday\"" in panels
|
||||
assert "id=\"cronFormScheduleMonthDay\"" in panels
|
||||
assert "id=\"cronFormSchedulePreview\"" in panels
|
||||
assert "cron_schedule_once_warning" in panels
|
||||
assert "_cronSchedulePresetIdForValue" in panels
|
||||
assert "_cronSchedulePresetOptionHtml" in panels
|
||||
assert "_initCronSchedulePresetControls" in panels
|
||||
assert "addEventListener('input', _syncCronSchedulePresetAndWarning" in panels
|
||||
# Raw-cron `input` only updates the warning/preview (NOT preset re-detection),
|
||||
# so a partial custom value that transiently matches a preset can't hide the
|
||||
# focused raw field mid-typing (#5554). Preset re-sync runs on change/init.
|
||||
assert "addEventListener('input', _syncCronScheduleWarning" in panels
|
||||
assert "addEventListener('change', _syncCronSchedulePresetAndWarning" in panels
|
||||
assert "addEventListener('change', _applyCronSchedulePresetSelection" in panels
|
||||
# UX fix: field edits regenerate the expression on `input` WITHOUT writing the
|
||||
# clamped value back into the field being typed (only clamp on `change`).
|
||||
assert "addEventListener('input', _regenCronScheduleFromFields" 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-preview" in style
|
||||
# Time input is themed to match the other form controls (not bare native chrome).
|
||||
assert "input[type=\"time\"]" in style
|
||||
assert "cron_schedule_time_hint" in panels
|
||||
assert "fields: ['minute']" in panels
|
||||
assert "fields: ['time']" in panels
|
||||
assert "fields: ['weekday', 'time']" in panels
|
||||
assert "fields: ['monthDay', 'time']" in panels
|
||||
# Custom is last in the preset order (after the common frequencies).
|
||||
custom_idx = panels.index("id: 'custom'")
|
||||
monthly_idx = panels.index("id: 'monthly'")
|
||||
assert monthly_idx < custom_idx
|
||||
assert "Duration forms like '30m' run once" in i18n
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user