// i18n.js — minimal i18n. No framework, no build step. // // Two locales: "en" (default) and "zh". Locale is auto-detected from // navigator.language on first load, then user-overridable via the // switcher in the top nav. Persisted to localStorage. // // Usage: // import { t, currentLocale, setLocale, onLocale } from "./i18n.js"; // t("remotes.title") // → "Remotes" or "Remotes 远程存储" // setLocale("zh") // switches + notifies subscribers // onLocale(() => renderApp()) // re-render on locale change const STORAGE_KEY = "webgui:locale"; const SUPPORTED = ["en", "zh"]; function detectInitial() { const saved = localStorage.getItem(STORAGE_KEY); if (saved && SUPPORTED.includes(saved)) return saved; if (navigator.language && navigator.language.toLowerCase().startsWith("zh")) { return "zh"; } return "en"; } let current = detectInitial(); const listeners = new Set(); // --- string catalog --- // Keep keys flat with dotted namespaces; English is the source. const STRINGS = { en: { // nav + chrome "nav.remotes": "Remotes", "nav.configure": "Configure", "nav.jobs": "Jobs", "nav.new_job": "New Job", "loading.title": "Loading", "loading.connecting": "Connecting to rclone RC…", "error.title": "Something went wrong", "error.unknown_remote": "Unknown backend", "error.no_such_provider": (p) => `No provider named "${p}".`, "error.no_such_remote": (r) => `No remote named "${r}".`, "error.couldnt_load": "Couldn't load", "error.couldnt_load_backends": "Couldn't load backends", "error.couldnt_reach": "Couldn't reach rclone", "error.couldnt_load_remotes": "Couldn't load remotes", "error.couldnt_list": "Couldn't list", "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 "footer.product": "Product", "footer.docs": "Documentation", "footer.community": "Community", "footer.about": "About", "footer.rclone_docs": "rclone Docs", "footer.rcd_command": "rcd Command", "footer.rc_api": "RC API", "footer.forum": "Forum", "footer.github": "GitHub", "footer.issues": "Issues", "footer.rclone_org": "rclone.org", "footer.changelog": "Changelog", "footer.faq": "FAQ", "footer.colophon": "rclone — rsync for cloud storage.", // language switcher "lang.label": "Language", // remotes view "remotes.title": "Remotes", "remotes.subtitle": "Configured cloud storage providers. Click a tile to browse, hover for edit/delete.", "remotes.new_remote": "New remote", "remotes.loading": "Loading remotes…", "remotes.empty_title": "No remotes configured", "remotes.empty_body": "Click New remote above to add one from this GUI.", "remotes.delete_confirm": (n) => `Delete remote ${n}? This cannot be undone.`, "remotes.deleted": (n) => `Deleted ${n}`, "remotes.delete_failed": "Delete failed", "remotes.load_failed": "Failed to load remotes", // browser view "browser.browse_failed": "List failed", "browser.empty_title": "Empty folder", "browser.empty_body": "No files here. Use Upload in the toolbar to add some.", "browser.no_remote_title": "No remote selected", "browser.no_remote_body": "Pick a remote to browse.", "browser.pick_remote": "Pick a remote", "browser.col.name": "Name", "browser.col.size": "Size", "browser.col.modified": "Modified", "browser.col.actions": "Actions", "browser.up": "../", "browser.mkdir": "New folder", "browser.upload": "Upload", "browser.rename": "Rename", "browser.delete": "Delete", "browser.mkdir_title": "New folder", "browser.mkdir_field": "Folder name", "browser.mkdir_placeholder": "new-folder", "browser.mkdir_created": (n) => `Created ${n}`, "browser.mkdir_failed": "mkdir failed", "browser.uploaded": (n) => `Uploaded ${n} file(s)`, "browser.upload_failed": "Upload failed", "browser.rename_title": (n) => `Rename ${n}`, "browser.rename_field": "New name", "browser.renamed": (n) => `Renamed to ${n}`, "browser.rename_failed": "Rename failed", "browser.delete_confirm": (p) => `Delete ${p}? This cannot be undone.`, "browser.deleted": (p) => `Deleted ${p}`, "browser.delete_file_failed": "Delete failed", "browser.modal.cancel": "Cancel", "browser.modal.ok": "OK", // configure view "configure.new_title": "New remote", "configure.new_subtitle": "Pick a storage backend to configure.", "configure.edit_title": (t) => `Edit ${t} remote`, "configure.create_title": (t) => `New ${t} remote`, "configure.edit_subtitle": "", "configure.cancel": "Cancel", "configure.filter_placeholder": "Filter backends (e.g. s3, sftp, local)…", "configure.no_match": "No backends match.", "configure.name": "Remote name", "configure.section.required": "Required", "configure.section.options": "Options", "configure.section.advanced": "Advanced", "configure.show_advanced": (n) => `Show advanced options (${n})`, "configure.hide_advanced": (n) => `Hide advanced options (${n})`, "configure.save": "Save changes", "configure.create": "Create remote", "configure.created": (n) => `Created ${n}`, "configure.updated": (n) => `Updated ${n}`, "configure.save_failed": "Save failed", "configure.oauth_title": "OAuth required", "configure.oauth_body": (p) => `${p} requires OAuth authorization, which this web GUI can't complete inside a container. Please configure it from a terminal first:`, "configure.oauth_hint": "Once the remote exists, you can edit its non-secret options here.", "configure.delete_section_title": "Delete this remote", "configure.delete_section_body": (r) => `Permanently remove ${r} from rclone.conf.`, "configure.delete_btn": "Delete remote", "configure.password_unchanged": "(unchanged)", "configure.password_secret": "secret", "configure.example_pick": "— pick —", "configure.example_custom": "Custom…", "configure.example_custom_placeholder": "custom value", // jobs view "jobs.title": "Jobs", "jobs.subtitle": "Saved transfers and their latest runs.", "jobs.new_btn": "New Job", "jobs.cancel": "Cancel", "jobs.new_title": "New Job", "jobs.new_subtitle": "Copy, sync, or move between remotes and local paths.", "jobs.action": "Action", "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 and 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.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", "jobs.source_path": "Source path", "jobs.dest_remote": "Destination location", "jobs.dest_path": "Destination path", "jobs.path_placeholder": "folder/subfolder or /local/path", "jobs.pick_folder": "Choose", "jobs.pick_source_title": "Choose source folder", "jobs.pick_dest_title": "Choose destination folder", "jobs.pick_current": "Use current folder", "jobs.pick_empty": "No folders here.", "jobs.start": "Start Job", "jobs.no_remotes_option": "(no remotes)", "jobs.view_filter": "Job view", "jobs.view_once": "One-time jobs", "jobs.view_recurring": "Recurring jobs", "jobs.empty_title": "No jobs yet", "jobs.empty_once_title": "No one-time jobs yet", "jobs.empty_recurring_title": "No recurring 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}`, "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.restarted_once": (id) => `Started one-time 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_record_id": "Record", "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", "jobs.detail_none": "None", "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", "jobs.col.files": "Files", "jobs.col.errors": "Errors", "jobs.col.action": "Action", "jobs.status.running": "running", "jobs.status.failed": "failed", "jobs.status.done": "done", "jobs.status.finished": "finished", "jobs.status.scheduled": "scheduled", "jobs.status.stopped": "stopped", "jobs.stop": "Stop", "jobs.once_id": (id) => `once:${id}`, "jobs.schedule_label_once": "One-time run", "jobs.submitted_cli": "— submitted via CLI —", }, zh: { "nav.remotes": "远程存储", "nav.configure": "配置", "nav.jobs": "任务", "nav.new_job": "新建任务", "loading.title": "加载中", "loading.connecting": "正在连接 rclone RC…", "error.title": "出错了", "error.unknown_remote": "未知的存储后端", "error.no_such_provider": (p) => `没有名为 "${p}" 的存储后端。`, "error.no_such_remote": (r) => `没有名为 "${r}" 的远程存储。`, "error.couldnt_load": "无法加载", "error.couldnt_load_backends": "无法加载存储后端列表", "error.couldnt_reach": "无法连接 rclone", "error.couldnt_load_remotes": "无法加载远程存储列表", "error.couldnt_list": "无法列出", "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": "产品", "footer.docs": "文档", "footer.community": "社区", "footer.about": "关于", "footer.rclone_docs": "rclone 文档", "footer.rcd_command": "rcd 命令", "footer.rc_api": "RC API", "footer.forum": "论坛", "footer.github": "GitHub", "footer.issues": "Issue 列表", "footer.rclone_org": "rclone.org", "footer.changelog": "更新日志", "footer.faq": "常见问题", "footer.colophon": "rclone — 云存储的 rsync。", "lang.label": "语言", "remotes.title": "远程存储", "remotes.subtitle": "已配置的云存储。点击卡片浏览文件,悬停可编辑/删除。", "remotes.new_remote": "新建远程存储", "remotes.loading": "正在加载远程存储…", "remotes.empty_title": "尚未配置任何远程存储", "remotes.empty_body": "点击上方的「新建远程存储」从 GUI 添加一个。", "remotes.delete_confirm": (n) => `删除远程存储 ${n}? 此操作不可撤销。`, "remotes.deleted": (n) => `已删除 ${n}`, "remotes.delete_failed": "删除失败", "remotes.load_failed": "加载远程存储失败", "browser.browse_failed": "列出失败", "browser.empty_title": "空文件夹", "browser.empty_body": "这里没有文件。用工具栏里的「上传」添加一些。", "browser.no_remote_title": "未选择远程存储", "browser.no_remote_body": "请选择一个远程存储来浏览。", "browser.pick_remote": "选择一个远程存储", "browser.col.name": "名称", "browser.col.size": "大小", "browser.col.modified": "修改时间", "browser.col.actions": "操作", "browser.up": "返回上级", "browser.mkdir": "新建文件夹", "browser.upload": "上传", "browser.rename": "重命名", "browser.delete": "删除", "browser.mkdir_title": "新建文件夹", "browser.mkdir_field": "文件夹名称", "browser.mkdir_placeholder": "新文件夹", "browser.mkdir_created": (n) => `已创建 ${n}`, "browser.mkdir_failed": "新建文件夹失败", "browser.uploaded": (n) => `已上传 ${n} 个文件`, "browser.upload_failed": "上传失败", "browser.rename_title": (n) => `重命名 ${n}`, "browser.rename_field": "新名称", "browser.renamed": (n) => `已重命名为 ${n}`, "browser.rename_failed": "重命名失败", "browser.delete_confirm": (p) => `删除 ${p}? 此操作不可撤销。`, "browser.deleted": (p) => `已删除 ${p}`, "browser.delete_file_failed": "删除失败", "browser.modal.cancel": "取消", "browser.modal.ok": "确定", "configure.new_title": "新建远程存储", "configure.new_subtitle": "选择一个存储后端进行配置。", "configure.edit_title": (t) => `编辑 ${t} 远程存储`, "configure.create_title": (t) => `新建 ${t} 远程存储`, "configure.edit_subtitle": "", "configure.cancel": "取消", "configure.filter_placeholder": "筛选后端(如 s3、sftp、local)…", "configure.no_match": "没有匹配的后端。", "configure.name": "远程存储名称", "configure.section.required": "必填项", "configure.section.options": "选项", "configure.section.advanced": "高级", "configure.show_advanced": (n) => `显示高级选项 (${n})`, "configure.hide_advanced": (n) => `隐藏高级选项 (${n})`, "configure.save": "保存修改", "configure.create": "创建远程存储", "configure.created": (n) => `已创建 ${n}`, "configure.updated": (n) => `已更新 ${n}`, "configure.save_failed": "保存失败", "configure.oauth_title": "需要 OAuth 授权", "configure.oauth_body": (p) => `${p} 需要 OAuth 授权,此 Web GUI 无法在容器内完成。请先在终端中配置:`, "configure.oauth_hint": "远程存储创建后,可以在这里编辑其非机密选项。", "configure.delete_section_title": "删除此远程存储", "configure.delete_section_body": (r) => `从 rclone.conf 永久移除 ${r}。`, "configure.delete_btn": "删除远程存储", "configure.password_unchanged": "(保持不变)", "configure.password_secret": "密钥", "configure.example_pick": "— 选择 —", "configure.example_custom": "自定义…", "configure.example_custom_placeholder": "自定义值", "jobs.title": "任务", "jobs.subtitle": "已保存的传输任务及其最近运行状态。", "jobs.new_btn": "新建任务", "jobs.cancel": "取消", "jobs.new_title": "新建任务", "jobs.new_subtitle": "在远程存储和本地路径之间复制、同步或移动。", "jobs.action": "操作", "jobs.action_copy": "copy(镜像 源 → 目标,两者都保留)", "jobs.action_sync": "sync(镜像 源 → 目标,删除目标的额外文件)", "jobs.action_move": "move(镜像 源 → 目标,完成后删除源)", "jobs.schedule_type": "任务类型", "jobs.schedule_once": "单次任务", "jobs.schedule_recurring": "固定循环", "jobs.schedule_help": "单次任务和固定循环任务都会保存到 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.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": "源位置", "jobs.source_path": "源路径", "jobs.dest_remote": "目标位置", "jobs.dest_path": "目标路径", "jobs.path_placeholder": "文件夹/子文件夹 或 /local/path", "jobs.pick_folder": "选择", "jobs.pick_source_title": "选择源文件夹", "jobs.pick_dest_title": "选择目标文件夹", "jobs.pick_current": "使用当前文件夹", "jobs.pick_empty": "这里没有文件夹。", "jobs.start": "开始任务", "jobs.no_remotes_option": "(无远程存储)", "jobs.view_filter": "任务视图", "jobs.view_once": "单次任务", "jobs.view_recurring": "循环任务", "jobs.empty_title": "尚无任务", "jobs.empty_once_title": "尚无单次任务", "jobs.empty_recurring_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.restarted_once": (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_record_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": "输出", "jobs.detail_none": "无", "jobs.col.id": "#", "jobs.col.job": "任务", "jobs.col.status": "状态", "jobs.col.next_run": "下次启动", "jobs.col.progress": "进度", "jobs.col.speed": "速度", "jobs.col.eta": "预计剩余", "jobs.col.files": "文件", "jobs.col.errors": "错误", "jobs.col.action": "操作", "jobs.status.running": "运行中", "jobs.status.failed": "失败", "jobs.status.done": "完成", "jobs.status.finished": "已结束", "jobs.status.scheduled": "已调度", "jobs.status.stopped": "已停止", "jobs.stop": "停止", "jobs.once_id": (id) => `单次:${id}`, "jobs.schedule_label_once": "单次运行", "jobs.submitted_cli": "— 通过命令行提交 —", }, }; export function t(key, ...args) { const table = STRINGS[current] || STRINGS.en; const val = table[key] ?? STRINGS.en[key] ?? key; if (typeof val === "function") return val(...args); return val; } export function currentLocale() { return current; } export function setLocale(locale) { if (!SUPPORTED.includes(locale)) return; if (locale === current) return; current = locale; localStorage.setItem(STORAGE_KEY, locale); document.documentElement.lang = locale === "zh" ? "zh-CN" : "en"; for (const fn of listeners) { try { fn(current); } catch (e) { console.error("i18n listener error", e); } } } export function onLocale(fn) { listeners.add(fn); return () => listeners.delete(fn); } export function supportedLocales() { return SUPPORTED; } // Initialize on first load. document.documentElement.lang = current === "zh" ? "zh-CN" : "en";