diff --git a/static/i18n.js b/static/i18n.js index 2d95a48d..65dd33e6 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1583,6 +1583,13 @@ const LOCALES = { cron_name_label: 'Name', cron_name_placeholder: 'Optional', cron_schedule_label: 'Schedule', + cron_schedule_preset_label: 'Preset', + cron_schedule_preset_hourly: 'Hourly', + cron_schedule_preset_daily: 'Daily', + cron_schedule_preset_weekdays: 'Weekdays', + cron_schedule_preset_weekly: 'Weekly', + cron_schedule_preset_monthly: 'Monthly', + cron_schedule_preset_custom: 'Custom', 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', @@ -3240,6 +3247,13 @@ const LOCALES = { cron_name_label: 'Nome', cron_name_placeholder: 'Opzionale', cron_schedule_label: 'Pianificazione', + cron_schedule_preset_label: 'Preset', + cron_schedule_preset_hourly: 'Hourly', + cron_schedule_preset_daily: 'Daily', + cron_schedule_preset_weekdays: 'Weekdays', + cron_schedule_preset_weekly: 'Weekly', + cron_schedule_preset_monthly: 'Monthly', + cron_schedule_preset_custom: 'Custom', 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', @@ -4905,6 +4919,13 @@ const LOCALES = { cron_name_label: '名前', cron_name_placeholder: '任意', cron_schedule_label: 'スケジュール', + cron_schedule_preset_label: 'Preset', + cron_schedule_preset_hourly: 'Hourly', + cron_schedule_preset_daily: 'Daily', + cron_schedule_preset_weekdays: 'Weekdays', + cron_schedule_preset_weekly: 'Weekly', + cron_schedule_preset_monthly: 'Monthly', + cron_schedule_preset_custom: 'Custom', cron_schedule_hint: "繰り返し実行には 'every 1h' または Cron 式を使います。'30m' のような期間だけの指定は 1 回だけ実行されます。", cron_schedule_once_warning: "'30m' のような期間指定は 1 回だけ実行され、実行後に削除されます。繰り返すには 'every 30m' を使ってください。", cron_prompt_label: 'プロンプト', @@ -6212,6 +6233,13 @@ const LOCALES = { cron_name_label: 'Имя', cron_name_placeholder: 'Необязательно', cron_schedule_label: 'Расписание', + cron_schedule_preset_label: 'Preset', + cron_schedule_preset_hourly: 'Hourly', + cron_schedule_preset_daily: 'Daily', + cron_schedule_preset_weekdays: 'Weekdays', + cron_schedule_preset_weekly: 'Weekly', + cron_schedule_preset_monthly: 'Monthly', + cron_schedule_preset_custom: 'Custom', cron_schedule_hint: "Для повторяющихся заданий используйте 'every 1h' или cron-выражение. Простые интервалы вроде '30m' выполняются один раз.", cron_schedule_once_warning: "Интервалы вроде '30m' выполняются один раз и удаляются после запуска. Используйте 'every 30m' для повторяющегося задания.", cron_prompt_label: 'Запрос', @@ -7796,6 +7824,13 @@ const LOCALES = { cron_name_label: 'Nombre', cron_name_placeholder: 'Opcional', cron_schedule_label: 'Programación', + cron_schedule_preset_label: 'Preset', + cron_schedule_preset_hourly: 'Hourly', + cron_schedule_preset_daily: 'Daily', + cron_schedule_preset_weekdays: 'Weekdays', + cron_schedule_preset_weekly: 'Weekly', + cron_schedule_preset_monthly: 'Monthly', + cron_schedule_preset_custom: 'Custom', 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', @@ -9062,6 +9097,13 @@ const LOCALES = { cron_duplicated: 'Aufgabe dupliziert (pausiert)', cron_name_placeholder: 'Optional', cron_schedule_label: 'Zeitplan', + cron_schedule_preset_label: 'Preset', + cron_schedule_preset_hourly: 'Hourly', + cron_schedule_preset_daily: 'Daily', + cron_schedule_preset_weekdays: 'Weekdays', + cron_schedule_preset_weekly: 'Weekly', + cron_schedule_preset_monthly: 'Monthly', + cron_schedule_preset_custom: 'Custom', 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', @@ -11000,6 +11042,13 @@ const LOCALES = { cron_name_label: '名称', cron_name_placeholder: '可选', cron_schedule_label: '计划', + cron_schedule_preset_label: 'Preset', + cron_schedule_preset_hourly: 'Hourly', + cron_schedule_preset_daily: 'Daily', + cron_schedule_preset_weekdays: 'Weekdays', + cron_schedule_preset_weekly: 'Weekly', + cron_schedule_preset_monthly: 'Monthly', + cron_schedule_preset_custom: 'Custom', cron_schedule_hint: "循环任务请用 'every 1h' 或 Cron 表达式。像 '30m' 这样的裸时长只会运行一次。", cron_schedule_once_warning: "像 '30m' 这样的时长写法只会运行一次,并在运行后移除。要保留循环任务,请使用 'every 30m'。", cron_prompt_label: '提示词', @@ -12959,6 +13008,13 @@ const LOCALES = { cron_name_label: '任務名稱', cron_name_placeholder: '選填', cron_schedule_label: '排程', + cron_schedule_preset_label: 'Preset', + cron_schedule_preset_hourly: 'Hourly', + cron_schedule_preset_daily: 'Daily', + cron_schedule_preset_weekdays: 'Weekdays', + cron_schedule_preset_weekly: 'Weekly', + cron_schedule_preset_monthly: 'Monthly', + cron_schedule_preset_custom: 'Custom', cron_schedule_hint: '週期性任務請用「every 1h」或 Cron 表示式。像「30m」這樣的裸時長只會執行一次。', cron_schedule_once_warning: '像「30m」這樣的時長寫法只會執行一次,並在執行後移除。要保留週期性任務,請使用「every 30m」。', cron_prompt_label: '提示', @@ -14357,6 +14413,13 @@ const LOCALES = { cron_name_label: 'Nome', cron_name_placeholder: 'Opcional', cron_schedule_label: 'Agendamento', + cron_schedule_preset_label: 'Preset', + cron_schedule_preset_hourly: 'Hourly', + cron_schedule_preset_daily: 'Daily', + cron_schedule_preset_weekdays: 'Weekdays', + cron_schedule_preset_weekly: 'Weekly', + cron_schedule_preset_monthly: 'Monthly', + cron_schedule_preset_custom: 'Custom', 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', @@ -15998,6 +16061,13 @@ const LOCALES = { cron_name_label: 'Name', cron_name_placeholder: 'Optional', cron_schedule_label: 'Schedule', + cron_schedule_preset_label: 'Preset', + cron_schedule_preset_hourly: 'Hourly', + cron_schedule_preset_daily: 'Daily', + cron_schedule_preset_weekdays: 'Weekdays', + cron_schedule_preset_weekly: 'Weekly', + cron_schedule_preset_monthly: 'Monthly', + cron_schedule_preset_custom: 'Custom', 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', @@ -17731,6 +17801,13 @@ const LOCALES = { cron_name_label: 'Nom', cron_name_placeholder: 'Facultatif', cron_schedule_label: 'Planification', + cron_schedule_preset_label: 'Preset', + cron_schedule_preset_hourly: 'Hourly', + cron_schedule_preset_daily: 'Daily', + cron_schedule_preset_weekdays: 'Weekdays', + cron_schedule_preset_weekly: 'Weekly', + cron_schedule_preset_monthly: 'Monthly', + cron_schedule_preset_custom: 'Custom', 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', @@ -19296,6 +19373,13 @@ const LOCALES = { cron_name_label: 'İsim', cron_name_placeholder: 'İsteğe bağlı', cron_schedule_label: 'Takvim', + cron_schedule_preset_label: 'Preset', + cron_schedule_preset_hourly: 'Hourly', + cron_schedule_preset_daily: 'Daily', + cron_schedule_preset_weekdays: 'Weekdays', + cron_schedule_preset_weekly: 'Weekly', + cron_schedule_preset_monthly: 'Monthly', + cron_schedule_preset_custom: 'Custom', 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', @@ -21041,6 +21125,13 @@ const LOCALES = { cron_name_label: 'Nazwa', cron_name_placeholder: 'Opcjonalnie', cron_schedule_label: 'Harmonogram', + cron_schedule_preset_label: 'Preset', + cron_schedule_preset_hourly: 'Hourly', + cron_schedule_preset_daily: 'Daily', + cron_schedule_preset_weekdays: 'Weekdays', + cron_schedule_preset_weekly: 'Weekly', + cron_schedule_preset_monthly: 'Monthly', + cron_schedule_preset_custom: 'Custom', 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', @@ -22540,6 +22631,13 @@ const LOCALES = { cron_name_label: 'Tên', cron_name_placeholder: 'Tùy chọn', cron_schedule_label: 'Lịch chạy', + cron_schedule_preset_label: 'Preset', + cron_schedule_preset_hourly: 'Hourly', + cron_schedule_preset_daily: 'Daily', + cron_schedule_preset_weekdays: 'Weekdays', + cron_schedule_preset_weekly: 'Weekly', + cron_schedule_preset_monthly: 'Monthly', + cron_schedule_preset_custom: 'Custom', 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 bf083460..88868313 100644 --- a/static/panels.js +++ b/static/panels.js @@ -466,6 +466,60 @@ function _syncCronScheduleWarning() { warning.style.display = _cronScheduleKindForInput(input.value) === 'once' ? '' : 'none'; } +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' }, +]; + +function _cronSchedulePresetOptionHtml() { + return CRON_SCHEDULE_PRESETS + .map((preset) => ``) + .join(''); +} + +function _cronSchedulePresetIdForValue(value) { + const schedule = String(value || '').trim(); + const preset = CRON_SCHEDULE_PRESETS.find((entry) => entry.value && entry.value === schedule); + return preset ? preset.id : 'custom'; +} + +function _syncCronSchedulePresetFromInput() { + const presetEl = $('cronFormSchedulePreset'); + const scheduleEl = $('cronFormSchedule'); + if (!presetEl || !scheduleEl) return; + const presetId = _cronSchedulePresetIdForValue(scheduleEl.value); + presetEl.value = presetId; +} + +function _syncCronSchedulePresetAndWarning() { + _syncCronSchedulePresetFromInput(); + _syncCronScheduleWarning(); +} + +function _applyCronSchedulePresetSelection() { + const presetEl = $('cronFormSchedulePreset'); + 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(); +} + +function _initCronSchedulePresetControls() { + const presetEl = $('cronFormSchedulePreset'); + const scheduleEl = $('cronFormSchedule'); + if (!presetEl || !scheduleEl) return; + presetEl.addEventListener('change', _applyCronSchedulePresetSelection); + scheduleEl.addEventListener('input', _syncCronSchedulePresetAndWarning); + scheduleEl.addEventListener('change', _syncCronSchedulePresetAndWarning); + _syncCronSchedulePresetAndWarning(); +} + function _hasUnlimitedRepeat(job) { return !!(job && job.repeat && job.repeat.times == null); } @@ -1311,6 +1365,12 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notif +
+ + +
@@ -1356,12 +1416,7 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notif _populateCronDeliverOptions(deliver, isEdit); _populateCronFormModelSelect(model, provider, isNoAgent); if (!isNoAgent) _renderCronSkillTags(); - const scheduleEl = $('cronFormSchedule'); - if (scheduleEl) { - scheduleEl.addEventListener('input', _syncCronScheduleWarning); - scheduleEl.addEventListener('change', _syncCronScheduleWarning); - _syncCronScheduleWarning(); - } + _initCronSchedulePresetControls(); const focusEl = $('cronFormName'); if (focusEl) focusEl.focus(); } diff --git a/tests/test_issue2031_cron_once_visibility.py b/tests/test_issue2031_cron_once_visibility.py index 9bc00aea..f6614a92 100644 --- a/tests/test_issue2031_cron_once_visibility.py +++ b/tests/test_issue2031_cron_once_visibility.py @@ -28,6 +28,17 @@ def _cron_schedule_source() -> str: return src[start:end] +def _cron_schedule_save_source() -> str: + src = PANELS_JS.read_text(encoding="utf-8") + start = src.find("async function saveCronForm()") + if start < 0: + pytest.fail("saveCronForm is missing") + end = src.find("// Back-compat aliases for any stale callers", start) + if end < 0: + end = len(src) + return src[start:end] + + def _run_node(script: str) -> str: proc = subprocess.run( [NODE, "-e", script], @@ -66,17 +77,159 @@ console.log(JSON.stringify(cases)); assert kinds["not_a_schedule"] == "" -def test_cron_form_surfaces_one_shot_warning_copy_and_styles(): +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 * *"), + empty: _cronSchedulePresetIdForValue(""), + trimMatch: _cronSchedulePresetIdForValue(" 0 9 * * * "), + custom: _cronSchedulePresetIdForValue("0 9 * * * 0"), +}; +console.log(JSON.stringify(cases)); +""" + cases = json.loads(_run_node(script)) + + assert cases["hourly"] == "hourly" + assert cases["daily"] == "daily" + assert cases["weekdays"] == "weekdays" + assert cases["weekly"] == "weekly" + assert cases["monthly"] == "monthly" + assert cases["empty"] == "custom" + assert cases["trimMatch"] == "daily" + assert cases["custom"] == "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() { + return { + value: '', + style: {}, + listeners: {}, + addEventListener(type, handler) { + (this.listeners[type] || (this.listeners[type] = [])).push(handler); + }, + dispatchEvent(eventType) { + const handlers = this.listeners[eventType] || []; + for (const handler of handlers) handler({ type: eventType, target: this }); + }, + }; +} +function t(key) { + const dict = { + cron_schedule_preset_label: 'Preset', + cron_schedule_preset_hourly: 'Hourly', + cron_schedule_preset_daily: 'Daily', + cron_schedule_preset_weekdays: 'Weekdays', + cron_schedule_preset_weekly: 'Weekly', + cron_schedule_preset_monthly: 'Monthly', + cron_schedule_preset_custom: 'Custom', + }; + return dict[key]; +} +function esc(value) { return value == null ? '' : String(value); } + +elements.cronFormSchedule = makeElement(); +elements.cronFormSchedulePreset = makeElement(); +elements.cronFormScheduleOnceWarning = { style: {} }; + +_initCronSchedulePresetControls(); + +elements.cronFormSchedule.value = "0 9 * * *"; +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.dispatchEvent('change'); +const presetWriteValue = elements.cronFormSchedule.value; +const selectAfterPresetWrite = 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, + preservedCustomValue, + selectAfterCustom, +})); +""" + 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["preservedCustomValue"] == "advanced: cron expression" + assert result["selectAfterCustom"] == "custom" + + +def test_cron_form_surfaces_one_shot_warning_copy_markers_and_preset_markup(): panels = PANELS_JS.read_text(encoding="utf-8") style = STYLE_CSS.read_text(encoding="utf-8") i18n = I18N_JS.read_text(encoding="utf-8") assert "id=\"cronFormScheduleOnceWarning\"" in panels + assert "id=\"cronFormSchedulePreset\"" in panels assert "cron_schedule_once_warning" in panels - assert "_syncCronScheduleWarning" in panels - assert "addEventListener('input', _syncCronScheduleWarning" in panels - assert "addEventListener('change', _syncCronScheduleWarning" in panels - + assert "_cronSchedulePresetIdForValue" in panels + assert "_cronSchedulePresetOptionHtml" in panels + assert "_initCronSchedulePresetControls" in panels + assert "addEventListener('input', _syncCronSchedulePresetAndWarning" in panels + assert "addEventListener('change', _syncCronSchedulePresetAndWarning" in panels + assert "addEventListener('change', _applyCronSchedulePresetSelection" in panels assert ".cron-once-warning" in style - assert i18n.count("cron_schedule_once_warning") >= 9 + 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 "Duration forms like '30m' run once" in i18n + + +def test_cron_form_save_payload_still_uses_visible_raw_schedule_only(): + save_block = _cron_schedule_save_source() + panels = PANELS_JS.read_text(encoding="utf-8") + + assert "cronFormSchedulePreset" not in save_block + assert "const schedule=schEl.value.trim();" in save_block + assert "const updates = {job_id: _editingCronId, schedule, profile: profile, toast_notifications: toastNotifications}" in panels + + +def test_cron_form_i18n_has_preset_keys(): + i18n = I18N_JS.read_text(encoding="utf-8") + required_keys = [ + "cron_schedule_preset_label", + "cron_schedule_preset_hourly", + "cron_schedule_preset_daily", + "cron_schedule_preset_weekdays", + "cron_schedule_preset_weekly", + "cron_schedule_preset_monthly", + "cron_schedule_preset_custom", + ] + + for key in required_keys: + assert i18n.count(key) >= 14