diff --git a/webgui/web/assets/js/app.js b/webgui/web/assets/js/app.js index bd36697..dd7bfbe 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"; -import { t, currentLocale, setLocale, onLocale } from "./i18n.js"; -import { renderRemotes } from "./views/remotes.js"; -import { renderBrowse } from "./views/browser.js"; -import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=jobs-picker-restore-1"; +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 { renderConfigureNew, renderConfigureEdit, -} from "./views/configure.js"; +} from "./views/configure.js?v=recurring-jobs-5"; const views = { remotes: renderRemotes, diff --git a/webgui/web/assets/js/i18n.js b/webgui/web/assets/js/i18n.js index dc3e4d5..977e394 100644 --- a/webgui/web/assets/js/i18n.js +++ b/webgui/web/assets/js/i18n.js @@ -48,6 +48,8 @@ const STRINGS = { "error.couldnt_load_jobs": "Couldn't load jobs", "error.no_remotes_selected": "Pick source and destination remotes", "error.no_paths_selected": "Enter source and destination paths", + "error.no_schedule_selected": "Pick a fixed recurring schedule", + "error.invalid_schedule_time": "Pick a valid run time", "error.not_connected": "Couldn't connect to rclone", // footer @@ -153,7 +155,7 @@ const STRINGS = { // jobs view "jobs.title": "Jobs", - "jobs.subtitle": "Running and recently completed transfers.", + "jobs.subtitle": "Saved recurring transfers and their latest runs.", "jobs.new_btn": "New Job", "jobs.cancel": "Cancel", "jobs.new_title": "New Job", @@ -162,6 +164,29 @@ const STRINGS = { "jobs.action_copy": "copy (mirror src → dst, keep both)", "jobs.action_sync": "sync (mirror src → dst, delete extras on dst)", "jobs.action_move": "move (mirror src → dst, delete src after)", + "jobs.schedule_type": "Job type", + "jobs.schedule_once": "One-time", + "jobs.schedule_recurring": "Fixed recurring", + "jobs.schedule_help": "One-time jobs are submitted to rclone and not saved. Fixed recurring jobs are stored in SQLite.", + "jobs.recurrence_kind": "Recurring plan", + "jobs.recurrence_daily": "Every day", + "jobs.recurrence_weekly": "Every week", + "jobs.recurrence_monthly": "Every month", + "jobs.schedule_time": "Run time", + "jobs.schedule_weekday": "Weekday", + "jobs.schedule_monthday": "Day of month", + "jobs.fixed_schedule_help": "Runs at the selected fixed local time. If the previous run is still active, the scheduler will not start an overlapping run.", + "jobs.weekday_monday": "Monday", + "jobs.weekday_tuesday": "Tuesday", + "jobs.weekday_wednesday": "Wednesday", + "jobs.weekday_thursday": "Thursday", + "jobs.weekday_friday": "Friday", + "jobs.weekday_saturday": "Saturday", + "jobs.weekday_sunday": "Sunday", + "jobs.monthday": (n) => `Day ${n}`, + "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.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", @@ -179,20 +204,29 @@ const STRINGS = { "jobs.empty_title": "No jobs yet", "jobs.empty_body": "Use New Job to start a copy, sync, or move.", "jobs.started": (a, id) => `Started ${a} job #${id}`, + "jobs.started_once": (a, id) => `Started one-time ${a} job #${id}. It was not saved.`, + "jobs.recurring_saved": (id) => `Saved recurring job #${id}`, "jobs.start_failed": "Job start failed", "jobs.stop_confirm": (id) => `Stop job #${id}?`, "jobs.stopped": (id) => `Stopped job #${id}`, "jobs.stop_failed": "Stop failed", + "jobs.start_again": "Start again", + "jobs.restarted": (id) => `Started recurring job #${id}`, + "jobs.restart_failed": "Start again failed", "jobs.details": "Details", "jobs.hide_details": "Hide", "jobs.delete_record": "Delete", "jobs.delete_confirm": (id) => `Delete job #${id} from this list? This won't cancel a running transfer.`, + "jobs.delete_failed": "Delete failed", + "jobs.detail_schedule_id": "Schedule ID", "jobs.detail_id": "Job ID", "jobs.detail_status": "Status", "jobs.detail_error": "Error", "jobs.detail_started": "Started", "jobs.detail_finished": "Finished", "jobs.detail_duration": "Duration", + "jobs.detail_next_run": "Next run", + "jobs.detail_schedule": "Schedule", "jobs.detail_group": "Group", "jobs.detail_execute_id": "Execution", "jobs.detail_output": "Output", @@ -200,6 +234,7 @@ const STRINGS = { "jobs.col.id": "#", "jobs.col.job": "Job", "jobs.col.status": "Status", + "jobs.col.next_run": "Next run", "jobs.col.progress": "Progress", "jobs.col.speed": "Speed", "jobs.col.eta": "ETA", @@ -210,6 +245,8 @@ const STRINGS = { "jobs.status.failed": "failed", "jobs.status.done": "done", "jobs.status.finished": "finished", + "jobs.status.scheduled": "scheduled", + "jobs.status.stopped": "stopped", "jobs.stop": "Stop", "jobs.submitted_cli": "— submitted via CLI —", }, @@ -233,6 +270,8 @@ const STRINGS = { "error.couldnt_load_jobs": "无法加载任务列表", "error.no_remotes_selected": "请选择源和目标位置", "error.no_paths_selected": "请输入源路径和目标路径", + "error.no_schedule_selected": "请选择固定循环计划", + "error.invalid_schedule_time": "请选择有效的运行时间", "error.not_connected": "无法连接到 rclone", "footer.product": "产品", @@ -330,7 +369,7 @@ const STRINGS = { "configure.example_custom_placeholder": "自定义值", "jobs.title": "任务", - "jobs.subtitle": "正在运行和最近完成的传输。", + "jobs.subtitle": "已保存的循环传输任务及其最近运行状态。", "jobs.new_btn": "新建任务", "jobs.cancel": "取消", "jobs.new_title": "新建任务", @@ -339,6 +378,29 @@ const STRINGS = { "jobs.action_copy": "copy(镜像 源 → 目标,两者都保留)", "jobs.action_sync": "sync(镜像 源 → 目标,删除目标的额外文件)", "jobs.action_move": "move(镜像 源 → 目标,完成后删除源)", + "jobs.schedule_type": "任务类型", + "jobs.schedule_once": "单次任务", + "jobs.schedule_recurring": "固定循环", + "jobs.schedule_help": "单次任务只提交给 rclone,不保存记录。固定循环任务会保存到 SQLite。", + "jobs.recurrence_kind": "循环计划", + "jobs.recurrence_daily": "每天", + "jobs.recurrence_weekly": "每周", + "jobs.recurrence_monthly": "每月", + "jobs.schedule_time": "运行时间", + "jobs.schedule_weekday": "星期", + "jobs.schedule_monthday": "每月几号", + "jobs.fixed_schedule_help": "按所选本地固定时间运行;如果上一轮仍在运行,不会重叠启动。", + "jobs.weekday_monday": "星期一", + "jobs.weekday_tuesday": "星期二", + "jobs.weekday_wednesday": "星期三", + "jobs.weekday_thursday": "星期四", + "jobs.weekday_friday": "星期五", + "jobs.weekday_saturday": "星期六", + "jobs.weekday_sunday": "星期日", + "jobs.monthday": (n) => `${n} 号`, + "jobs.schedule_label_daily": (time) => `每天 ${time}`, + "jobs.schedule_label_weekly": (day, time) => `每周${day.replace(/^星期/, "")} ${time}`, + "jobs.schedule_label_monthly": (day, time) => `每月 ${day} 号 ${time}`, "jobs.local_option": "本地文件系统", "jobs.local_path_help": "选择本地文件系统时填写 rclone 进程内可见的路径;当前 Docker 编排里通常是 /data/source。选择远程存储时填写该远程内的路径。", "jobs.source_remote": "源位置", @@ -356,20 +418,29 @@ const STRINGS = { "jobs.empty_title": "尚无任务", "jobs.empty_body": "用「新建任务」开始一个 copy、sync 或 move。", "jobs.started": (a, id) => `已启动 ${a} 任务 #${id}`, + "jobs.started_once": (a, id) => `已启动单次 ${a} 任务 #${id},不会保存记录。`, + "jobs.recurring_saved": (id) => `已保存循环任务 #${id}`, "jobs.start_failed": "任务启动失败", "jobs.stop_confirm": (id) => `停止任务 #${id}?`, "jobs.stopped": (id) => `已停止任务 #${id}`, "jobs.stop_failed": "停止失败", + "jobs.start_again": "再次启动", + "jobs.restarted": (id) => `已启动循环任务 #${id}`, + "jobs.restart_failed": "再次启动失败", "jobs.details": "详情", "jobs.hide_details": "收起", "jobs.delete_record": "删除", "jobs.delete_confirm": (id) => `从列表中删除任务 #${id}? 这不会取消正在运行的传输。`, + "jobs.delete_failed": "删除失败", + "jobs.detail_schedule_id": "调度 ID", "jobs.detail_id": "任务 ID", "jobs.detail_status": "状态", "jobs.detail_error": "错误", "jobs.detail_started": "开始时间", "jobs.detail_finished": "结束时间", "jobs.detail_duration": "耗时", + "jobs.detail_next_run": "下次启动", + "jobs.detail_schedule": "固定计划", "jobs.detail_group": "分组", "jobs.detail_execute_id": "执行实例", "jobs.detail_output": "输出", @@ -377,6 +448,7 @@ const STRINGS = { "jobs.col.id": "#", "jobs.col.job": "任务", "jobs.col.status": "状态", + "jobs.col.next_run": "下次启动", "jobs.col.progress": "进度", "jobs.col.speed": "速度", "jobs.col.eta": "预计剩余", @@ -387,6 +459,8 @@ const STRINGS = { "jobs.status.failed": "失败", "jobs.status.done": "完成", "jobs.status.finished": "已结束", + "jobs.status.scheduled": "已调度", + "jobs.status.stopped": "已停止", "jobs.stop": "停止", "jobs.submitted_cli": "— 通过命令行提交 —", }, diff --git a/webgui/web/assets/js/state.js b/webgui/web/assets/js/state.js index 084022b..c4c70e1 100644 --- a/webgui/web/assets/js/state.js +++ b/webgui/web/assets/js/state.js @@ -1,49 +1,12 @@ -// state.js — minimal pub/sub store + hash router + localStorage-backed -// job metadata cache. Vanilla, no framework. - -const JOB_META_KEY = "webgui:jobMeta"; -const JOB_META_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +// state.js — minimal pub/sub store + hash router. Vanilla, no framework. const listeners = new Set(); const state = { remotes: [], // [{ name, type }] jobs: new Map(), // jobid -> { jobid, status, progress, action, src, dst } providers: null, // cached /config/providers response - // jobMeta is persisted to localStorage so the jobs view can show - // src/dst for jobs after a page reload. Keyed by jobid (number). - jobMeta: loadJobMeta(), }; -function loadJobMeta() { - try { - const raw = localStorage.getItem(JOB_META_KEY); - if (!raw) return new Map(); - const arr = JSON.parse(raw); - if (!Array.isArray(arr)) return new Map(); - const now = Date.now(); - const map = new Map(); - for (const [id, meta] of arr) { - if (meta && typeof meta.submittedAt === "number") { - if (now - meta.submittedAt < JOB_META_TTL_MS) { - map.set(Number(id), meta); - } - } - } - return map; - } catch { - return new Map(); - } -} - -function saveJobMeta() { - try { - const arr = Array.from(state.jobMeta.entries()); - localStorage.setItem(JOB_META_KEY, JSON.stringify(arr)); - } catch { - // localStorage might be unavailable (private mode, quota); ignore. - } -} - export function getState() { return state; } @@ -64,33 +27,6 @@ export function subscribe(fn) { return () => listeners.delete(fn); } -// --- Job metadata API --- - -export function rememberJob(jobid, { action, src, dst }) { - state.jobMeta.set(Number(jobid), { - action, - src, - dst, - submittedAt: Date.now(), - }); - saveJobMeta(); -} - -export function getJobMeta(jobid) { - return state.jobMeta.get(Number(jobid)); -} - -export function listJobMeta() { - return Array.from(state.jobMeta.entries()) - .map(([jobid, meta]) => ({ jobid: Number(jobid), ...meta })) - .sort((a, b) => b.jobid - a.jobid); -} - -export function forgetJob(jobid) { - state.jobMeta.delete(Number(jobid)); - saveJobMeta(); -} - // --- Router --- // Hash-based routes: // #/remotes diff --git a/webgui/web/assets/js/views/browser.js b/webgui/web/assets/js/views/browser.js index 7734d2c..308628a 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"; -import { t } from "../i18n.js"; +import { toast, formatBytes, formatTime } from "../state.js?v=recurring-jobs-5"; +import { t } from "../i18n.js?v=recurring-jobs-5"; 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 0a7c1cf..fc4b7e5 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"; -import { t } from "../i18n.js"; +import { getState, setState, toast } from "../state.js?v=recurring-jobs-5"; +import { t } from "../i18n.js?v=recurring-jobs-5"; // --- Route entrypoints --- diff --git a/webgui/web/assets/js/views/jobs.js b/webgui/web/assets/js/views/jobs.js index c2d9163..20962ee 100644 --- a/webgui/web/assets/js/views/jobs.js +++ b/webgui/web/assets/js/views/jobs.js @@ -1,19 +1,21 @@ -// views/jobs.js — submit sync/copy/move jobs and poll their progress. -// Job src/dst metadata is persisted to localStorage (via state.rememberJob) -// so the table can show what each job is doing even after a page reload. +// views/jobs.js — submit sync/copy/move jobs and manage recurring transfers. import { post, postAsync } from "../rc.js"; import { toast, - rememberJob, - getJobMeta, - listJobMeta, - forgetJob, formatBytes, formatSpeed, formatDuration, -} from "../state.js"; -import { t } from "../i18n.js"; +} from "../state.js?v=recurring-jobs-5"; +import { t } from "../i18n.js?v=recurring-jobs-5"; +import { + createRecurringJob, + deleteRecurringJob, + jobsApiURL, + listRecurringJobs, + runRecurringJobNow, + stopRecurringJob, +} from "../jobs_api.js?v=recurring-jobs-5"; let pollTimer = null; const WEBGUI_JOB_GROUP_PREFIX = "webgui/transfer"; @@ -78,6 +80,60 @@ export async function renderNewJob() { +
${escapeHtml(e.message)}
+${escapeHtml(jobsApiURL())}
+${t("jobs.empty_body").replace("New Job", `${t("jobs.new_btn")}`)}
-${escapeHtml(job.src)}
+ →
+ ${escapeHtml(job.dst)}
+
+ ${escapeHtml(meta.src)}
- →
- ${escapeHtml(meta.dst)}
-
-