From 161f26682983334e38eb75e47229e3f37b193abf Mon Sep 17 00:00:00 2001 From: wuxu Date: Sat, 20 Jun 2026 11:46:50 +0800 Subject: [PATCH] =?UTF-8?q?add:=20=E6=B7=BB=E5=8A=A0=E6=AF=8F=E6=9C=88?= =?UTF-8?q?=E6=9C=80=E5=90=8E=E4=B8=80=E5=A4=A9=E8=B0=83=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- webgui/api/server.py | 11 +++++++---- webgui/api/server_test.py | 14 ++++++++++++++ webgui/web/assets/js/app.js | 12 ++++++------ webgui/web/assets/js/i18n.js | 4 ++++ webgui/web/assets/js/views/browser.js | 4 ++-- webgui/web/assets/js/views/configure.js | 4 ++-- webgui/web/assets/js/views/jobs.js | 15 ++++++++++----- webgui/web/assets/js/views/remotes.js | 4 ++-- webgui/web/index.html | 4 ++-- 9 files changed, 49 insertions(+), 23 deletions(-) diff --git a/webgui/api/server.py b/webgui/api/server.py index 7181c7a..dfc0f0f 100644 --- a/webgui/api/server.py +++ b/webgui/api/server.py @@ -38,6 +38,7 @@ ALLOWED_ORIGINS = { } VALID_ACTIONS = {"copy", "sync", "move"} VALID_SCHEDULE_KINDS = {"daily", "weekly", "monthly"} +MONTHDAY_LAST = 0 db_lock = threading.RLock() scheduler_lock = threading.RLock() @@ -142,13 +143,15 @@ def compute_next_run_at( return iso(local_to_utc(candidate, offset_minutes)) if kind == "monthly": - if monthday is None or monthday < 1 or monthday > 31: - raise ValueError("scheduleMonthday must be 1-31") + if monthday is None or monthday < MONTHDAY_LAST or monthday > 31: + raise ValueError("scheduleMonthday must be 0-31") for offset_months in range(0, 36): year, month = add_months(local_after.year, local_after.month, offset_months) - if monthday > monthrange(year, month)[1]: + days_in_month = monthrange(year, month)[1] + candidate_day = days_in_month if monthday == MONTHDAY_LAST else monthday + if candidate_day > days_in_month: continue - candidate = datetime(year, month, monthday, hour, minute) + candidate = datetime(year, month, candidate_day, hour, minute) if candidate > local_after: return iso(local_to_utc(candidate, offset_minutes)) raise ValueError("could not compute next monthly run") diff --git a/webgui/api/server_test.py b/webgui/api/server_test.py index a69a44d..4b0f84b 100644 --- a/webgui/api/server_test.py +++ b/webgui/api/server_test.py @@ -48,6 +48,20 @@ class ComputeNextRunAtTest(unittest.TestCase): "2026-03-31T23:00:00Z", ) + def test_monthly_last_day_uses_current_month_end(self): + after = datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc) + self.assertEqual( + compute_next_run_at("monthly", "23:00", 0, after, monthday=0), + "2026-02-28T23:00:00Z", + ) + + def test_monthly_last_day_rolls_forward_when_current_month_end_passed(self): + after = datetime(2026, 2, 28, 23, 0, tzinfo=timezone.utc) + self.assertEqual( + compute_next_run_at("monthly", "23:00", 0, after, monthday=0), + "2026-03-31T23:00:00Z", + ) + def test_timezone_offset_is_applied_from_local_schedule_time(self): after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc) self.assertEqual( diff --git a/webgui/web/assets/js/app.js b/webgui/web/assets/js/app.js index dd7bfbe..546844f 100644 --- a/webgui/web/assets/js/app.js +++ b/webgui/web/assets/js/app.js @@ -2,15 +2,15 @@ // the top-nav active state in sync, and re-renders chrome strings when // the locale changes. -import { onRoute } from "./state.js?v=recurring-jobs-5"; -import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=recurring-jobs-5"; -import { renderRemotes } from "./views/remotes.js?v=recurring-jobs-5"; -import { renderBrowse } from "./views/browser.js?v=recurring-jobs-5"; -import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=recurring-jobs-5"; +import { onRoute } from "./state.js?v=monthly-last-day-1"; +import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=monthly-last-day-1"; +import { renderRemotes } from "./views/remotes.js?v=monthly-last-day-1"; +import { renderBrowse } from "./views/browser.js?v=monthly-last-day-1"; +import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=monthly-last-day-1"; import { renderConfigureNew, renderConfigureEdit, -} from "./views/configure.js?v=recurring-jobs-5"; +} from "./views/configure.js?v=monthly-last-day-1"; const views = { remotes: renderRemotes, diff --git a/webgui/web/assets/js/i18n.js b/webgui/web/assets/js/i18n.js index 977e394..acc270d 100644 --- a/webgui/web/assets/js/i18n.js +++ b/webgui/web/assets/js/i18n.js @@ -184,9 +184,11 @@ const STRINGS = { "jobs.weekday_saturday": "Saturday", "jobs.weekday_sunday": "Sunday", "jobs.monthday": (n) => `Day ${n}`, + "jobs.monthday_last": "Last day", "jobs.schedule_label_daily": (time) => `Every day at ${time}`, "jobs.schedule_label_weekly": (day, time) => `Every ${day} at ${time}`, "jobs.schedule_label_monthly": (day, time) => `Day ${day} of every month at ${time}`, + "jobs.schedule_label_monthly_last": (time) => `Last day of every month at ${time}`, "jobs.local_option": "Local filesystem", "jobs.local_path_help": "For Local filesystem, enter a path visible inside the rclone process, such as /data/source in this Docker setup. For remotes, enter a path inside the selected remote.", "jobs.source_remote": "Source location", @@ -398,9 +400,11 @@ const STRINGS = { "jobs.weekday_saturday": "星期六", "jobs.weekday_sunday": "星期日", "jobs.monthday": (n) => `${n} 号`, + "jobs.monthday_last": "最后一天", "jobs.schedule_label_daily": (time) => `每天 ${time}`, "jobs.schedule_label_weekly": (day, time) => `每周${day.replace(/^星期/, "")} ${time}`, "jobs.schedule_label_monthly": (day, time) => `每月 ${day} 号 ${time}`, + "jobs.schedule_label_monthly_last": (time) => `每月最后一天 ${time}`, "jobs.local_option": "本地文件系统", "jobs.local_path_help": "选择本地文件系统时填写 rclone 进程内可见的路径;当前 Docker 编排里通常是 /data/source。选择远程存储时填写该远程内的路径。", "jobs.source_remote": "源位置", diff --git a/webgui/web/assets/js/views/browser.js b/webgui/web/assets/js/views/browser.js index 308628a..65e20ac 100644 --- a/webgui/web/assets/js/views/browser.js +++ b/webgui/web/assets/js/views/browser.js @@ -1,8 +1,8 @@ // views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename. import { post, uploadFile, downloadURL } from "../rc.js"; -import { toast, formatBytes, formatTime } from "../state.js?v=recurring-jobs-5"; -import { t } from "../i18n.js?v=recurring-jobs-5"; +import { toast, formatBytes, formatTime } from "../state.js?v=monthly-last-day-1"; +import { t } from "../i18n.js?v=monthly-last-day-1"; export async function renderBrowse({ remote, path }) { const app = document.getElementById("app"); diff --git a/webgui/web/assets/js/views/configure.js b/webgui/web/assets/js/views/configure.js index fc4b7e5..9961e71 100644 --- a/webgui/web/assets/js/views/configure.js +++ b/webgui/web/assets/js/views/configure.js @@ -10,8 +10,8 @@ // and disabled submit — user must run `rclone config` in a terminal. import { post } from "../rc.js"; -import { getState, setState, toast } from "../state.js?v=recurring-jobs-5"; -import { t } from "../i18n.js?v=recurring-jobs-5"; +import { getState, setState, toast } from "../state.js?v=monthly-last-day-1"; +import { t } from "../i18n.js?v=monthly-last-day-1"; // --- Route entrypoints --- diff --git a/webgui/web/assets/js/views/jobs.js b/webgui/web/assets/js/views/jobs.js index 20962ee..4a15fc1 100644 --- a/webgui/web/assets/js/views/jobs.js +++ b/webgui/web/assets/js/views/jobs.js @@ -6,8 +6,8 @@ import { formatBytes, formatSpeed, formatDuration, -} from "../state.js?v=recurring-jobs-5"; -import { t } from "../i18n.js?v=recurring-jobs-5"; +} from "../state.js?v=monthly-last-day-1"; +import { t } from "../i18n.js?v=monthly-last-day-1"; import { createRecurringJob, deleteRecurringJob, @@ -15,7 +15,7 @@ import { listRecurringJobs, runRecurringJobNow, stopRecurringJob, -} from "../jobs_api.js?v=recurring-jobs-5"; +} from "../jobs_api.js?v=monthly-last-day-1"; let pollTimer = null; const WEBGUI_JOB_GROUP_PREFIX = "webgui/transfer"; @@ -276,10 +276,12 @@ function renderWeekdayOptions() { } function renderMonthdayOptions() { - return Array.from({ length: 31 }, (_, index) => { + const lastDay = ``; + const numberedDays = Array.from({ length: 31 }, (_, index) => { const day = index + 1; return ``; }).join(""); + return lastDay + numberedDays; } function buildTransferBody(action, src, dst) { @@ -322,7 +324,7 @@ function readRecurringSchedule(form) { } if (scheduleKind === "monthly") { schedule.scheduleMonthday = Number(form.elements.scheduleMonthday.value); - if (!Number.isInteger(schedule.scheduleMonthday) || schedule.scheduleMonthday < 1 || schedule.scheduleMonthday > 31) { + if (!Number.isInteger(schedule.scheduleMonthday) || schedule.scheduleMonthday < 0 || schedule.scheduleMonthday > 31) { throw new Error(t("error.no_schedule_selected")); } } @@ -764,6 +766,9 @@ function formatSchedule(job) { case "weekly": return t("jobs.schedule_label_weekly", weekdayName(job.scheduleWeekday), time); case "monthly": + if (Number(job.scheduleMonthday) === 0) { + return t("jobs.schedule_label_monthly_last", time); + } return t("jobs.schedule_label_monthly", job.scheduleMonthday, time); default: return ""; diff --git a/webgui/web/assets/js/views/remotes.js b/webgui/web/assets/js/views/remotes.js index 710e097..72fdaa3 100644 --- a/webgui/web/assets/js/views/remotes.js +++ b/webgui/web/assets/js/views/remotes.js @@ -1,8 +1,8 @@ // views/remotes.js — connector-tile grid of configured remotes with CRUD. import { post } from "../rc.js"; -import { getState, setState, toast } from "../state.js?v=recurring-jobs-5"; -import { t } from "../i18n.js?v=recurring-jobs-5"; +import { getState, setState, toast } from "../state.js?v=monthly-last-day-1"; +import { t } from "../i18n.js?v=monthly-last-day-1"; export async function renderRemotes() { const app = document.getElementById("app"); diff --git a/webgui/web/index.html b/webgui/web/index.html index 6ccfccc..46bc8c3 100644 --- a/webgui/web/index.html +++ b/webgui/web/index.html @@ -7,7 +7,7 @@ - +
@@ -80,6 +80,6 @@
- +