diff --git a/package.json b/package.json index e7f801d19..3794b9b44 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "cron": "^3.2.1", + "cron": "^4.4.0", "crypto-js": "^4.2.0", "dayjs": "^1.11.13", "dexie": "^4.0.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 356299cba..fd9b867f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: ^3.2.2 version: 3.2.2(react@18.3.1) cron: - specifier: ^3.2.1 - version: 3.2.1 + specifier: ^4.4.0 + version: 4.4.0 crypto-js: specifier: ^4.2.0 version: 4.2.0 @@ -1146,8 +1146,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/luxon@3.4.2': - resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} @@ -1839,8 +1839,9 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} - cron@3.2.1: - resolution: {integrity: sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==} + cron@4.4.0: + resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} + engines: {node: '>=18.x'} cross-env@10.1.0: resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} @@ -2894,8 +2895,8 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - luxon@3.5.0: - resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} lz-string@1.5.0: @@ -5174,7 +5175,7 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/luxon@3.4.2': {} + '@types/luxon@3.7.1': {} '@types/mime@1.3.5': {} @@ -6078,10 +6079,10 @@ snapshots: create-require@1.1.1: {} - cron@3.2.1: + cron@4.4.0: dependencies: - '@types/luxon': 3.4.2 - luxon: 3.5.0 + '@types/luxon': 3.7.1 + luxon: 3.7.2 cross-env@10.1.0: dependencies: @@ -7282,7 +7283,7 @@ snapshots: lru-cache@10.4.3: {} - luxon@3.5.0: {} + luxon@3.7.2: {} lz-string@1.5.0: {} diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index b8d0f0ce7..98ff8d4b3 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -706,7 +706,7 @@ export default class GMApi extends GM_Base { parentNodeId = id; } else { parentNodeId = null; - attrs = tagName as Record; + attrs = (tagName || {}) as Record; tagName = parentNode as string; } if (typeof tagName !== "string") throw new Error("The parameter 'tagName' of GM_addElement shall be a string."); diff --git a/src/app/service/offscreen/script.ts b/src/app/service/offscreen/script.ts index 2d94aaaaa..04c52fd1e 100644 --- a/src/app/service/offscreen/script.ts +++ b/src/app/service/offscreen/script.ts @@ -10,7 +10,7 @@ import { SCRIPT_TYPE_CRONTAB, SCRIPT_TYPE_NORMAL, } from "@App/app/repo/scripts"; -import { disableScript, enableScript, runScript, stopScript } from "../sandbox/client"; +import { disableScript, enableScript, runScript, setSandboxLanguage, stopScript } from "../sandbox/client"; import { type Group } from "@Packages/message/server"; import type { MessageSend } from "@Packages/message/types"; import type { TDeleteScript, TInstallScript, TEnableScript } from "../queue"; @@ -40,6 +40,9 @@ export class ScriptService { } async init() { + this.messageQueue.subscribe("setSandboxLanguage", async (lang) => { + setSandboxLanguage(this.windowMessage, lang); + }); this.messageQueue.subscribe("enableScripts", async (data) => { for (const { uuid, enable } of data) { const script = await this.scriptClient.info(uuid); diff --git a/src/app/service/sandbox/client.ts b/src/app/service/sandbox/client.ts index e11d3686a..bd5ea13b6 100644 --- a/src/app/service/sandbox/client.ts +++ b/src/app/service/sandbox/client.ts @@ -2,6 +2,10 @@ import { type ScriptRunResource } from "@App/app/repo/scripts"; import { sendMessage } from "@Packages/message/client"; import { type WindowMessage } from "@Packages/message/window_message"; +export function setSandboxLanguage(msg: WindowMessage, lang: string) { + return sendMessage(msg, "sandbox/setSandboxLanguage", lang); +} + export function enableScript(msg: WindowMessage, data: ScriptRunResource) { return sendMessage(msg, "sandbox/enableScript", data); } diff --git a/src/app/service/sandbox/runtime.ts b/src/app/service/sandbox/runtime.ts index 222fe2e66..010c690e7 100644 --- a/src/app/service/sandbox/runtime.ts +++ b/src/app/service/sandbox/runtime.ts @@ -14,11 +14,17 @@ import { proxyUpdateRunStatus } from "../offscreen/client"; import { BgExecScriptWarp } from "../content/exec_warp"; import type ExecScript from "../content/exec_script"; import type { ValueUpdateDataEncoded } from "../content/types"; -import { getStorageName, getMetadataStr, getUserConfigStr } from "@App/pkg/utils/utils"; +import { getStorageName, getMetadataStr, getUserConfigStr, getISOWeek } from "@App/pkg/utils/utils"; import type { EmitEventRequest, ScriptLoadInfo } from "../service_worker/types"; import { CATRetryError } from "../content/exec_warp"; import { parseUserConfig } from "@App/pkg/utils/yaml"; import { decodeRValue } from "@App/pkg/utils/message_value"; +import { extraCronExpr } from "@App/pkg/utils/cron"; +import { changeLanguage, initLanguage, t } from "@App/locales/locales"; + +const utime_1min = 60 * 1000; +const utime_1hr = 60 * 60 * 1000; +const utime_1day = 24 * 60 * 60 * 1000; export class Runtime { cronJob: Map> = new Map(); @@ -181,7 +187,7 @@ export class Runtime { crontabScript(script: ScriptLoadInfo) { // 执行定时脚本 运行表达式 if (!script.metadata.crontab) { - throw new Error(script.name + " - 错误的crontab表达式"); + throw new Error(script.name + " - " + t("cron_invalid_expr")); } // 如果有nextruntime,则加入重试队列 this.joinRetryList(script); @@ -189,22 +195,9 @@ export class Runtime { let flag = false; const cronJobList: Array = []; script.metadata.crontab.forEach((val) => { - let oncePos = 0; - let crontab = val; - if (crontab.includes("once")) { - const vals = crontab.split(" "); - vals.forEach((item, index) => { - if (item === "once") { - oncePos = index; - } - }); - if (vals.length === 5) { - oncePos += 1; - } - crontab = crontab.replace(/once/g, "*"); - } + const { cronExpr, oncePos } = extraCronExpr(val); try { - const cron = new CronJob(crontab, this.crontabExec(script, oncePos)); + const cron = new CronJob(cronExpr, this.crontabExec(script, oncePos)); cron.start(); cronJobList.push(cron); } catch (e) { @@ -231,38 +224,34 @@ export class Runtime { } crontabExec(script: ScriptLoadInfo, oncePos: number) { - if (oncePos) { + if (oncePos >= 1) { return () => { // 没有最后一次执行时间表示之前都没执行过,直接执行 - if (!script.lastruntime) { - this.execScript(script); - return; - } - const now = new Date(); - const last = new Date(script.lastruntime); - let flag = false; - // 根据once所在的位置去判断执行 - switch (oncePos) { - case 1: // 每分钟 - flag = last.getMinutes() !== now.getMinutes(); - break; - case 2: // 每小时 - flag = last.getHours() !== now.getHours(); - break; - case 3: // 每天 - flag = last.getDay() !== now.getDay(); - break; - case 4: // 每月 - flag = last.getMonth() !== now.getMonth(); - break; - case 5: // 每周 - flag = this.getWeek(last) !== this.getWeek(now); - break; - default: - } - if (flag) { - this.execScript(script); + if (script.lastruntime) { + const now = new Date(); + const last = new Date(script.lastruntime); + // 根据once所在的位置去判断执行 + const timeDiff = now.getTime() - last.getTime(); + switch (oncePos) { + case 1: // 每分钟 + if (timeDiff < 2 * utime_1min && last.getMinutes() === now.getMinutes()) return; + break; + case 2: // 每小时 + if (timeDiff < 2 * utime_1hr && last.getHours() === now.getHours()) return; + break; + case 3: // 每天 + if (timeDiff < 2 * utime_1day && last.getDay() === now.getDay()) return; + break; + case 4: // 每月 + if (timeDiff < 62 * utime_1day && last.getMonth() === now.getMonth()) return; + break; + case 5: // 每周 + if (timeDiff < 14 * utime_1day && getISOWeek(last) === getISOWeek(now)) return; + break; + default: + } } + this.execScript(script); }; } return () => { @@ -270,17 +259,6 @@ export class Runtime { }; } - // 获取本周是第几周 - getWeek(date: Date) { - const nowDate = new Date(date); - const firstDay = new Date(date); - firstDay.setMonth(0); // 设置1月 - firstDay.setDate(1); // 设置1号 - const diffDays = Math.ceil((nowDate.getTime() - firstDay.getTime()) / (24 * 60 * 60 * 1000)); - const week = Math.ceil(diffDays / 7); - return week === 0 ? 1 : week; - } - // 停止计时器 stopCronJob(uuid: string) { const list = this.cronJob.get(uuid); @@ -350,6 +328,10 @@ export class Runtime { } } + setSandboxLanguage(lang: string) { + changeLanguage(lang); + } + init() { this.api.on("enableScript", this.enableScript.bind(this)); this.api.on("disableScript", this.disableScript.bind(this)); @@ -358,5 +340,7 @@ export class Runtime { this.api.on("runtime/valueUpdate", this.valueUpdate.bind(this)); this.api.on("runtime/emitEvent", this.emitEvent.bind(this)); + this.api.on("setSandboxLanguage", this.setSandboxLanguage.bind(this)); + initLanguage(); } } diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index a918ff726..ec4e41d23 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -455,6 +455,12 @@ export class RuntimeService { this.mq.publish("enableScripts", res); } }); + this.systemConfig.getLanguage().then((lng: string) => { + this.mq.publish("setSandboxLanguage", lng); + }); + this.systemConfig.addListener("language", (lng) => { + this.mq.publish("setSandboxLanguage", lng); + }); }); // 监听脚本值变更 diff --git a/src/locales/de-DE/translation.json b/src/locales/de-DE/translation.json index 0e6a0f11a..fa79e4afa 100644 --- a/src/locales/de-DE/translation.json +++ b/src/locales/de-DE/translation.json @@ -280,6 +280,14 @@ "script_has_full_access_to": "Skript erhält vollständigen Zugriff auf die folgenden Adressen", "script_requires": "Skript referenziert die folgenden externen Ressourcen", "cookie_warning": "Achtung: Dieses Skript beantragt Cookie-Operationsberechtigung. Dies ist eine gefährliche Berechtigung, bitte stellen Sie die Sicherheit des Skripts sicher.", + "cron_oncetype": { + "minute": "{{next}} (jede Minute ausgeführt)", + "hour": "{{next}} (jede Stunde ausgeführt)", + "day": "{{next}} (jeden Tag ausgeführt)", + "month": "{{next}} (jeden Monat ausgeführt)", + "week": "{{next}} (jede Woche ausgeführt)" + }, + "cron_invalid_expr": "Ungültiger Cron-Ausdruck", "scheduled_script_description_title": "Dies ist ein geplantes Skript. Wenn aktiviert, wird es zu bestimmten Zeiten automatisch ausgeführt und kann im Panel manuell gesteuert werden.", "scheduled_script_description_description_expr": "Geplante Aufgaben-Ausdruck", "scheduled_script_description_description_next": "Letzte Ausführungszeit:", diff --git a/src/locales/en-US/translation.json b/src/locales/en-US/translation.json index 4375b315d..170104a46 100644 --- a/src/locales/en-US/translation.json +++ b/src/locales/en-US/translation.json @@ -280,6 +280,14 @@ "script_has_full_access_to": "Script will have full access to the following URLs", "script_requires": "Script requires the following external resources", "cookie_warning": "Please note, this script requests access to Cookie permissions, which is a dangerous permission. Please verify the security of the script.", + "cron_oncetype": { + "minute": "{{next}} (runs every minute)", + "hour": "{{next}} (runs every hour)", + "day": "{{next}} (runs every day)", + "month": "{{next}} (runs every month)", + "week": "{{next}} (runs every week)" + }, + "cron_invalid_expr": "Invalid cron expression", "scheduled_script_description_title": "This is a scheduled script, which will automatically run at a specific time once enabled and can be manually controlled in the panel.", "scheduled_script_description_description_expr": "Scheduled task expression:", "scheduled_script_description_description_next": "Most recent run time:", diff --git a/src/locales/ja-JP/translation.json b/src/locales/ja-JP/translation.json index 9b0513a7c..5954e4b7a 100644 --- a/src/locales/ja-JP/translation.json +++ b/src/locales/ja-JP/translation.json @@ -280,6 +280,14 @@ "script_has_full_access_to": "スクリプトは以下のアドレスへの完全なアクセス権限を取得します", "script_requires": "スクリプトは以下の外部リソースを参照しています", "cookie_warning": "注意:このスクリプトはCookieの操作権限をリクエストします。これは危険な権限ですので、スクリプトの安全性を確認してください。", + "cron_oncetype": { + "minute": "{{next}}(毎分実行)", + "hour": "{{next}}(毎時間実行)", + "day": "{{next}}(毎日実行)", + "month": "{{next}}(毎月実行)", + "week": "{{next}}(毎週実行)" + }, + "cron_invalid_expr": "不正な cron 式です", "scheduled_script_description_title": "これはスケジュールスクリプトです。有効にすると特定の時間に自動実行され、手動操作も可能です。", "scheduled_script_description_description_expr": "スケジュールタスク表現:", "scheduled_script_description_description_next": "最近の実行時間:", diff --git a/src/locales/locales.ts b/src/locales/locales.ts index 49c62da46..10eeb2fe6 100644 --- a/src/locales/locales.ts +++ b/src/locales/locales.ts @@ -37,12 +37,10 @@ export const initLocalesPromise = new Promise((resolve) => { initLocalesResolve = resolve; }); -export function initLocales(systemConfig: SystemConfig) { - const uiLanguage = chrome.i18n.getUILanguage(); - const defaultLanguage = globalThis.localStorage ? localStorage["language"] || uiLanguage : uiLanguage; +export function initLanguage(lng: string = "en-US"): void { i18n.use(initReactI18next).init({ fallbackLng: "en-US", - lng: defaultLanguage, // 优先使用localStorage中的语言设置 + lng: lng, // 优先使用localStorage中的语言设置 interpolation: { escapeValue: false, // react already safes from xss => https://www.i18next.com/translation-function/interpolation#unescape }, @@ -59,9 +57,16 @@ export function initLocales(systemConfig: SystemConfig) { }); // 先根据默认语言设置路径 - if (!defaultLanguage.startsWith("zh-")) { + if (!lng.startsWith("zh-")) { localePath = "/en"; } +} + +export function initLocales(systemConfig: SystemConfig) { + const uiLanguage = chrome.i18n.getUILanguage(); + const defaultLanguage = globalThis.localStorage ? localStorage["language"] || uiLanguage : uiLanguage; + + initLanguage(defaultLanguage); const changeLanguageCallback = (lng: string) => { if (!lng.startsWith("zh-")) { diff --git a/src/locales/ru-RU/translation.json b/src/locales/ru-RU/translation.json index 351d6e030..75ce52780 100644 --- a/src/locales/ru-RU/translation.json +++ b/src/locales/ru-RU/translation.json @@ -280,6 +280,14 @@ "script_has_full_access_to": "Скрипт получит полный доступ к следующим адресам", "script_requires": "Скрипт ссылается на следующие внешние ресурсы", "cookie_warning": "Обратите внимание, что этот скрипт запрашивает разрешения на операции с Cookie. Это опасное разрешение, пожалуйста, убедитесь в безопасности скрипта.", + "cron_oncetype": { + "minute": "{{next}} (выполняется каждую минуту)", + "hour": "{{next}} (выполняется каждый час)", + "day": "{{next}} (выполняется каждый день)", + "month": "{{next}} (выполняется каждый месяц)", + "week": "{{next}} (выполняется каждую неделю)" + }, + "cron_invalid_expr": "Неверное выражение cron", "scheduled_script_description_title": "Это запланированный скрипт. После включения он будет автоматически выполняться в определенное время и может управляться вручную с панели.", "scheduled_script_description_description_expr": "Выражение планировщика", "scheduled_script_description_description_next": "Последнее время выполнения:", diff --git a/src/locales/vi-VN/translation.json b/src/locales/vi-VN/translation.json index 2777c47ef..256b474ac 100644 --- a/src/locales/vi-VN/translation.json +++ b/src/locales/vi-VN/translation.json @@ -280,6 +280,14 @@ "script_has_full_access_to": "Script sẽ có toàn quyền truy cập vào các url sau", "script_requires": "Script yêu cầu các tài nguyên bên ngoài sau", "cookie_warning": "Xin lưu ý, script này yêu cầu quyền truy cập cookie, đây là một quyền nguy hiểm. Vui lòng xác minh tính bảo mật của script.", + "cron_oncetype": { + "minute": "{{next}} (chạy mỗi phút)", + "hour": "{{next}} (chạy mỗi giờ)", + "day": "{{next}} (chạy mỗi ngày)", + "month": "{{next}} (chạy mỗi tháng)", + "week": "{{next}} (chạy mỗi tuần)" + }, + "cron_invalid_expr": "Biểu thức cron không hợp lệ", "scheduled_script_description_title": "Đây là script hẹn giờ, sẽ tự động chạy vào một thời điểm cụ thể sau khi được bật và có thể được điều khiển thủ công trong bảng điều khiển.", "scheduled_script_description_description_expr": "Biểu thức tác vụ hẹn giờ:", "scheduled_script_description_description_next": "Thời gian chạy gần nhất:", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index edaa61b29..c69199c81 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -280,6 +280,14 @@ "script_has_full_access_to": "脚本将获得以下地址的完整访问权限", "script_requires": "脚本引用了下列外部资源", "cookie_warning": "请注意,本脚本会请求 Cookie 的操作权限,这是一个危险的权限,请确认脚本的安全性。", + "cron_oncetype": { + "minute": "{{next}} (每分钟运行一次)", + "hour": "{{next}} (每小时运行一次)", + "day": "{{next}} (每天运行一次)", + "month": "{{next}} (每月运行一次)", + "week": "{{next}} (每星期运行一次)" + }, + "cron_invalid_expr": "错误的定时表达式", "scheduled_script_description_title": "这是一个定时脚本,启用后将在特定时间自动运行,并可在面板中手动控制。", "scheduled_script_description_description_expr": "定时任务表达式:", "scheduled_script_description_description_next": "最近一次运行时间:", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index bdf475128..9d58702ba 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -280,6 +280,14 @@ "script_has_full_access_to": "腳本將獲得以下網址的完整存取權限", "script_requires": "腳本引用了以下外部資源", "cookie_warning": "請注意,此腳本會要求 Cookie 的操作權限,這是一項危險的權限,請確認腳本的安全性。", + "cron_oncetype": { + "minute": "{{next}} (每分鐘執行一次)", + "hour": "{{next}} (每小時執行一次)", + "day": "{{next}} (每天執行一次)", + "month": "{{next}} (每月執行一次)", + "week": "{{next}} (每星期執行一次)" + }, + "cron_invalid_expr": "錯誤的排程表達式", "scheduled_script_description_title": "這是一個排程腳本,啟用後將在特定時間自動執行,並可在控制面板中手動控制。", "scheduled_script_description_description_expr": "排程任務表達式:", "scheduled_script_description_description_next": "最近一次執行時間:", diff --git a/src/pages/install/App.tsx b/src/pages/install/App.tsx index 5770bf2b8..31d6aba05 100644 --- a/src/pages/install/App.tsx +++ b/src/pages/install/App.tsx @@ -22,7 +22,7 @@ import { i18nDescription, i18nName } from "@App/locales/locales"; import { useTranslation } from "react-i18next"; import { createScriptInfo, type ScriptInfo } from "@App/pkg/utils/scriptInstall"; import { parseMetadata, prepareScriptByCode, prepareSubscribeByCode } from "@App/pkg/utils/script"; -import { nextTime } from "@App/pkg/utils/cron"; +import { nextTimeDisplay } from "@App/pkg/utils/cron"; import { scriptClient, subscribeClient } from "../store/features/script"; import { type FTInfo, startFileTrack, unmountFileTrack } from "@App/pkg/utils/file-tracker"; import { cleanupOldHandles, loadHandle, saveHandle } from "@App/pkg/utils/filehandle-db"; @@ -382,7 +382,7 @@ function App() { {t("scheduled_script_description_description_expr")} {metadataLive.crontab[0]} {t("scheduled_script_description_description_next")} - {nextTime(metadataLive.crontab[0])} + {nextTimeDisplay(metadataLive.crontab[0])} ); } else if (metadataLive.background) { diff --git a/src/pages/options/routes/ScriptList/ScriptCard.tsx b/src/pages/options/routes/ScriptList/ScriptCard.tsx index c79675706..65d7a54f6 100644 --- a/src/pages/options/routes/ScriptList/ScriptCard.tsx +++ b/src/pages/options/routes/ScriptList/ScriptCard.tsx @@ -13,7 +13,7 @@ import { import type { Script, UserConfig } from "@App/app/repo/scripts"; import { SCRIPT_RUN_STATUS_RUNNING, SCRIPT_TYPE_BACKGROUND, SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts"; import { requestEnableScript } from "@App/pages/store/features/script"; -import { nextTime } from "@App/pkg/utils/cron"; +import { nextTimeDisplay } from "@App/pkg/utils/cron"; import { i18nName } from "@App/locales/locales"; import { hashColor, ScriptIcons } from "../utils"; import { getCombinedMeta } from "@App/app/service/service_worker/utils"; @@ -207,7 +207,7 @@ export const ScriptCardItem = React.memo( content={ item.type === SCRIPT_TYPE_BACKGROUND ? t("background_script_tooltip") - : `${t("scheduled_script_tooltip")} ${nextTime(item.metadata!.crontab![0])}` + : `${t("scheduled_script_tooltip")} ${nextTimeDisplay(item.metadata!.crontab![0])}` } > diff --git a/src/pkg/utils/cron.ts b/src/pkg/utils/cron.ts index a757d516e..4cf8cc5c3 100644 --- a/src/pkg/utils/cron.ts +++ b/src/pkg/utils/cron.ts @@ -1,45 +1,200 @@ import { CronTime } from "cron"; -import dayjs from "dayjs"; - -// 计算下次执行时间,支持 once 关键字表示每分钟/每小时/每天/每月/每星期执行一次 -export function nextTime(crontab: string, date?: Date): string { - let oncePos = 0; - if (crontab.includes("once")) { - const vals = crontab.split(" "); - vals.forEach((val, index) => { - if (val === "once") { - oncePos = index; - } - }); - if (vals.length === 5) { - oncePos++; +import { t } from "@App/locales/locales"; + +// ===================================== Cron 工具库说明 ===================================== +// +// 本模块用于解析 cron 表达式并计算下一次执行时间, +// 在标准 cron 语法基础上扩展支持 `once` 关键字。 +// +// 参考文档: +// https://github.com/kelektiv/node-cron +// https://docs.scriptcat.org/docs/dev/background/#%E5%AE%9A%E6%97%B6%E8%84%9A%E6%9C%AC +// +// 在线工具测试 cron 表达式: +// https://crontab.guru/ (英文,标准5位格式) +// https://tool.lu/crontab/ (中文,标准5位及扩展6位格式) +// +// ────────────────────────────────── Cron 表达式格式 ────────────────────────────────── +// +// 支持以下两种 cron 表达式: +// - 标准 5 位格式:分 时 日 月 周 +// - 扩展 6 位格式:秒 分 时 日 月 周 +// +// 注:6位扩展格式会使脚本每秒执行,浏览器JavaScript环境无法精准每秒执行,而且对CPU负担大,并不推荐 +// +// ────────────────────────────────── 字段取值规则 ────────────────────────────────── +// +// 支持以下取值写法: +// - `*` :任意值 +// - `1-3,5` :范围或离散值 +// - `*/2` :步长(每隔 N 个单位) +// - `once` +// - `once(*)` +// - `once(...)`: +// 表示在某个周期内仅执行一次(ScriptCat 扩展语法) +// +// ────────────────────────────────── 字段取值范围 ────────────────────────────────── +// +// 字段 | 允许值 +// ------ | ------------------------------------------ +// 秒 | 0 - 59 +// 分 | 0 - 59 +// 时 | 0 - 23 +// 日 | 1 - 31 +// 月 | 1 - 12(或英文月份名,详见 cron 文档) +// 周 | 0 - 7(0 或 7 表示星期日,也可使用英文名称) +// +// ============================================================================================ + +// 使用 cron 内部的 DateTime 构造函数 +// 等价于:import { DateTime } from "luxon" +const DateTime = new CronTime("* * * * *").sendAt().constructor; +type LuxonDate = ReturnType[0]; + +/** + * once 在不同 cron 位置上的语义映射表。 + * + * key 表示 once 所在的 cron 位(1 ~ 5,忽略秒位)。 + * + * 示例: + * - "* once * * *" → 每小时执行一次 + * - "* * once * *" → 每天执行一次 + */ +const ONCE_MAP = { + 1: { unit: "minute", format: "yyyy-MM-dd HH:mm:ss", label: "minute" }, + 2: { unit: "hour", format: "yyyy-MM-dd HH:mm:ss", label: "hour" }, + 3: { unit: "day", format: "yyyy-MM-dd", label: "day" }, + 4: { unit: "month", format: "yyyy-MM", label: "month" }, + 5: { unit: "week", format: "yyyy-MM-dd", label: "week" }, +} as const; + +type NextTimeResult = { + /** 下一次触发时间 */ + next: LuxonDate; + /** 时间格式 */ + format: string; + /** once 类型标识,用于国际化展示 */ + once: string; +}; + +/** + * 对外展示用方法。 + * + * - 若为 once cron,返回「下次在 xx 执行一次」的国际化文案 + * - 否则直接返回下一次执行时间字符串 + */ +export const nextTimeDisplay = (crontab: string, date = new Date()): string => { + const res = nextTimeInfo(crontab, date); + const nextTimeFormatted = res.next.toFormat(res.format); + return res.once ? t(`cron_oncetype.${res.once}`, { next: nextTimeFormatted }) : nextTimeFormatted; +}; + +/** + * 解析 cron 表达式,提取 once 信息并转换为标准 cron 表达式。 + * + * @returns + * - oncePos :once 在 6 位 cron 表达式中的实际位置(不存在则为 -1) + * - cronExpr:用于标准 cron 解析的表达式 + */ +export const extraCronExpr = ( + crontab: string +): { + oncePos: number; + cronExpr: string; +} => { + const parts = crontab.trim().split(" "); + + /** + * 兼容 5 位 / 6 位 cron 表达式: + * - 5 位:分 时 日 月 周 + * - 6 位:秒 分 时 日 月 周 + */ + const lenOffset = parts.length === 5 ? 1 : 0; + + // 长度不合法,直接判定为非法表达式 + if (parts.length + lenOffset !== 6) { + throw new Error(t("cron_invalid_expr")); + } + + let oncePos = -1; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (part.startsWith("once")) { + // once 在 6 位 cron 中的真实位置 + // 5 位 cron 需要整体向后偏移一位 + oncePos = i + lenOffset; + parts[i] = part.slice(5, -1) || "*"; + break; } } + + return { cronExpr: parts.join(" "), oncePos }; +}; + +/** + * 解析 cron 表达式并计算下一次执行时间。 + * + * 支持自定义 once 关键字,用于表示在对应周期内仅执行一次: + * - minute:每分钟一次 + * - hour :每小时一次 + * - day :每天一次 + * - month :每月一次 + * - week :每周一次 + */ +export const nextTimeInfo = (crontab: string, date = new Date()): NextTimeResult => { + const { cronExpr, oncePos } = extraCronExpr(crontab); + let cron: CronTime; try { - cron = new CronTime(crontab.replace(/once/g, "*")); + // 使用标准 cron 表达式进行解析 + cron = new CronTime(cronExpr); } catch { - throw new Error("错误的定时表达式"); + /** + * 不支持多个 once + * 示例:"* once once * *" + */ + throw new Error(t("cron_invalid_expr")); } - let datetime = dayjs(date || new Date()); - if (oncePos === 2) { - datetime = datetime.set("minute", 0).subtract(1, "minute").set("second", 0); - } - const nextdate = cron.getNextDateFrom(datetime.toDate()); - if (oncePos) { - switch (oncePos) { - case 1: // 每分钟 - return nextdate.toFormat("yyyy-MM-dd HH:mm:ss 每分钟运行一次"); - case 2: // 每小时 - return nextdate.plus({ hour: 1 }).toFormat("yyyy-MM-dd HH:mm:ss 每小时运行一次"); - case 3: // 每天 - return nextdate.plus({ day: 1 }).toFormat("yyyy-MM-dd 每天运行一次"); - case 4: // 每月 - return nextdate.plus({ month: 1 }).toFormat("yyyy-MM 每月运行一次"); - case 5: // 每星期 - return nextdate.plus({ week: 1 }).toFormat("yyyy-MM-dd 每星期运行一次"); - } - throw new Error("错误表达式"); + + let luxonDate = (DateTime as any).fromJSDate(date) as LuxonDate; + let format = "yyyy-MM-dd HH:mm:ss"; + let onceLabel = ""; + + /** + * 若存在 once: + * + * 处理思路: + * 1. 先跳转到下一个周期的起始时间 + * 2. 再从该时间点开始计算 cron 的下一次命中 + */ + if (oncePos >= 1 && oncePos <= 5) { + const cfg = ONCE_MAP[oncePos as keyof typeof ONCE_MAP]; + onceLabel = cfg.label; + format = cfg.format; + + /** + * 示例: + * 当前时间:2026-01-02 10:23 + * once 位于 hour + * + * → 跳转到 11:00:00 + */ + luxonDate = luxonDate.plus({ [cfg.unit]: 1 }).startOf(cfg.unit as any); + + /** + * 再回退 1 毫秒, + * 以确保 getNextDateFrom 能命中 + * 「等于周期起点」的 cron 时间 + */ + luxonDate = luxonDate.minus({ milliseconds: 1 }); } - return nextdate.toFormat("yyyy-MM-dd HH:mm:ss"); -} + + const next = cron.getNextDateFrom(luxonDate); + + return { + next: next, + format: format, + once: onceLabel, + }; +}; diff --git a/src/pkg/utils/script.ts b/src/pkg/utils/script.ts index b498976e0..671411987 100644 --- a/src/pkg/utils/script.ts +++ b/src/pkg/utils/script.ts @@ -12,7 +12,7 @@ import { } from "@App/app/repo/scripts"; import type { Subscribe } from "@App/app/repo/subscribe"; import { SUBSCRIBE_STATUS_ENABLE, SubscribeDAO } from "@App/app/repo/subscribe"; -import { nextTime } from "./cron"; +import { nextTimeDisplay } from "./cron"; import { parseUserConfig } from "./yaml"; import { t as i18n_t } from "@App/locales/locales"; @@ -95,7 +95,7 @@ export async function prepareScriptByCode( if (metadata.crontab !== undefined) { type = SCRIPT_TYPE_CRONTAB; try { - nextTime(metadata.crontab[0]); + nextTimeDisplay(metadata.crontab[0]); } catch { throw new Error(i18n_t("error_cron_invalid", { expr: metadata.crontab[0] })); } diff --git a/src/pkg/utils/utils.test.ts b/src/pkg/utils/utils.test.ts index 8ecd3c7f1..0fb5d0cf7 100644 --- a/src/pkg/utils/utils.test.ts +++ b/src/pkg/utils/utils.test.ts @@ -9,8 +9,7 @@ import { toCamelCase, } from "./utils"; import { ltever, versionCompare } from "@App/pkg/utils/semver"; -import { nextTime } from "./cron"; -import dayjs from "dayjs"; +import { nextTimeDisplay, nextTimeInfo } from "./cron"; describe.concurrent("aNow", () => { // aNow >= Date.now(); @@ -36,40 +35,179 @@ describe.concurrent("aNow", () => { }); }); -describe.concurrent("nextTime", () => { - const date = new Date(1737275111000); +const assertNextTimeInfo = (expr: string, date: Date, expected: any) => { + const actual = nextTimeInfo(expr, date); + const result = { + next: actual.next.toFormat(actual.format), + once: actual.once, + }; + + // 1) 失败时讯息包含 expr / expected / actual + // 2) 用 soft,方便一次看到多笔失败(可选) + expect + .soft( + result, + [ + "", + "", + `expr: ${expr}`, + `date: ${date.toISOString()}`, + `expected: ${JSON.stringify(expected)}`, + `actual: ${JSON.stringify(result)}`, + "", + "", + ].join("\n") + ) + .toEqual(expected); +}; + +describe.concurrent("nextTimeInfo1", () => { + const date = new Date("2025-12-17T11:47:17.629"); // 2025-12-17 11:47:17.629 (本地时区) + + // 让程序先执行一下,避免超时问题 + beforeAll(() => { + nextTimeDisplay("* * * * *"); + }); + + it.concurrent.each([ + ["* * * * * *", { next: "2025-12-17 11:47:18", once: "" }], + ["* * * * *", { next: "2025-12-17 11:48:00", once: "" }], + ["* 1-3,5 * * *", { next: "2025-12-18 01:00:00", once: "" }], + ["* 3-8/2 * * *", { next: "2025-12-18 03:00:00", once: "" }], + ])("标准Cron表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["once * * * *", { next: "2025-12-17 11:48:00", once: "minute" }], + ["* once * * *", { next: "2025-12-17 12:00:00", once: "hour" }], + ["* * once * *", { next: "2025-12-18", once: "day" }], + ["* * * once *", { next: "2026-01", once: "month" }], + ["* * * * once", { next: "2025-12-22", once: "week" }], + + ["once(*) * * * *", { next: "2025-12-17 11:48:00", once: "minute" }], + ["* once(*) * * *", { next: "2025-12-17 12:00:00", once: "hour" }], + ["* * once(*) * *", { next: "2025-12-18", once: "day" }], + ["* * * once(*) *", { next: "2026-01", once: "month" }], + ["* * * * once(*)", { next: "2025-12-22", once: "week" }], + + ["once(5-7) * * * *", { next: "2025-12-17 12:05:00", once: "minute" }], + ["* once(5-7) * * *", { next: "2025-12-18 05:00:00", once: "hour" }], + ["* * once(5-7) * *", { next: "2026-01-05", once: "day" }], + ["* * * once(5-7) *", { next: "2026-05", once: "month" }], + ["* * * * once(5-7)", { next: "2025-12-26", once: "week" }], + ])("once表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["once * * * *", { next: "2025-12-17 11:48:00", once: "minute" }], + ["* once * * * *", { next: "2025-12-17 11:48:00", once: "minute" }], + ["45 once * * * *", { next: "2025-12-17 11:48:45", once: "minute" }], + ["once 1-3,5 * * *", { next: "2025-12-18 01:00:00", once: "minute" }], + ["once 3-8/2 * * *", { next: "2025-12-18 03:00:00", once: "minute" }], + ])("每分钟一次表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["* once * * *", { next: "2025-12-17 12:00:00", once: "hour" }], + ["* * once * * *", { next: "2025-12-17 12:00:00", once: "hour" }], + ["10 once * * *", { next: "2025-12-17 12:10:00", once: "hour" }], + ["* 10 once * * *", { next: "2025-12-17 12:10:00", once: "hour" }], + ["45 10 once * * *", { next: "2025-12-17 12:10:45", once: "hour" }], + ["1-3,5 once * * *", { next: "2025-12-17 12:01:00", once: "hour" }], + ["3-8/2 once * * *", { next: "2025-12-17 12:03:00", once: "hour" }], + ])("每小时一次表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["* * once * *", { next: "2025-12-18", once: "day" }], + ["* * * once * *", { next: "2025-12-18", once: "day" }], + ["45 * * once * *", { next: "2025-12-18", once: "day" }], + ["33,44 */7 * once * *", { next: "2025-12-18", once: "day" }], + ["* * once * 3,6", { next: "2025-12-20", once: "day" }], + ])("每天一次表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["* * * once *", { next: "2026-01", once: "month" }], + ["* * * * once *", { next: "2026-01", once: "month" }], + ["45 * * * once *", { next: "2026-01", once: "month" }], + ["33,44 */7 * * once *", { next: "2026-01", once: "month" }], + ["* * * once 3,6", { next: "2026-01", once: "month" }], + ])("每月一次表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["* * * * once", { next: "2025-12-22", once: "week" }], + ["* * * * * once", { next: "2025-12-22", once: "week" }], + ["45 * * * * once", { next: "2025-12-22", once: "week" }], + ["33,44 */7 * * * once", { next: "2025-12-22", once: "week" }], + ["* * 5 * once", { next: "2026-01-05", once: "week" }], + ])("每星期一次表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); +}); + +describe.concurrent("nextTimeInfo2", () => { + const date = new Date("2025-12-31T23:59:59.999"); // 2025-12-31 23:59:59.999(本地时区) + // 让程序先执行一下,避免超时问题 beforeAll(() => { - nextTime("* * * * *"); - dayjs(date); - }); - it.sequential("每分钟表达式", () => { - expect(nextTime("* * * * *", date)).toEqual(dayjs(date).add(1, "minute").format("YYYY-MM-DD HH:mm:00")); - }); - it.sequential("每分钟一次表达式", () => { - expect(nextTime("once * * * *", date)).toEqual( - dayjs(date).add(1, "minute").format("YYYY-MM-DD HH:mm:00 每分钟运行一次") - ); - expect(nextTime("10 once * * * *", date)).toEqual( - dayjs(date).add(1, "minute").format("YYYY-MM-DD HH:mm:10 每分钟运行一次") - ); - }); - it.sequential("每小时一次表达式", () => { - expect(nextTime("* once * * *", date)).toEqual( - dayjs(date).add(1, "hour").format("YYYY-MM-DD HH:00:00 每小时运行一次") - ); - expect(nextTime("10 once * * *", date)).toEqual( - dayjs(date).add(1, "hour").format("YYYY-MM-DD HH:10:00 每小时运行一次") - ); - }); - it.sequential("每天一次表达式", () => { - expect(nextTime("* * once * *", date)).toEqual(dayjs(date).add(1, "day").format("YYYY-MM-DD 每天运行一次")); - }); - it.sequential("每月一次表达式", () => { - expect(nextTime("* * * once *", date)).toEqual(dayjs(date).add(1, "month").format("YYYY-MM 每月运行一次")); - }); - it.sequential("每星期一次表达式", () => { - expect(nextTime("* * * * once", date)).toEqual(dayjs(date).add(1, "week").format("YYYY-MM-DD 每星期运行一次")); + nextTimeDisplay("* * * * *"); + }); + + it.concurrent.each([ + ["* * * * * *", { next: "2026-01-01 00:00:00", once: "" }], + ["* * * * *", { next: "2026-01-01 00:00:00", once: "" }], + ])("标准 Cron 表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["once * * * *", { next: "2026-01-01 00:00:00", once: "minute" }], + ["* once * * * *", { next: "2026-01-01 00:00:00", once: "minute" }], + ["45 once * * * *", { next: "2026-01-01 00:00:45", once: "minute" }], + ])("每分钟一次表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["* once * * *", { next: "2026-01-01 00:00:00", once: "hour" }], + ["* * once * * *", { next: "2026-01-01 00:00:00", once: "hour" }], + ["10 once * * *", { next: "2026-01-01 00:10:00", once: "hour" }], + ["* 10 once * * *", { next: "2026-01-01 00:10:00", once: "hour" }], + ["45 10 once * * *", { next: "2026-01-01 00:10:45", once: "hour" }], + ])("每小时一次表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["* * once * *", { next: "2026-01-01", once: "day" }], + ["* * * once * *", { next: "2026-01-01", once: "day" }], + ["45 * * once * *", { next: "2026-01-01", once: "day" }], + ])("每天一次表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["* * * once *", { next: "2026-01", once: "month" }], + ["* * * * once *", { next: "2026-01", once: "month" }], + ["45 * * * once *", { next: "2026-01", once: "month" }], + ])("每月一次表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); + }); + + it.concurrent.each([ + ["* * * * once", { next: "2026-01-05", once: "week" }], + ["* * * * * once", { next: "2026-01-05", once: "week" }], + ["45 * * * * once", { next: "2026-01-05", once: "week" }], + ])("每星期一次表达式: %s", (expr, expected) => { + assertNextTimeInfo(expr, date, expected); }); }); diff --git a/src/pkg/utils/utils.ts b/src/pkg/utils/utils.ts index 1980ba0f0..8e72be47d 100644 --- a/src/pkg/utils/utils.ts +++ b/src/pkg/utils/utils.ts @@ -517,3 +517,24 @@ export const normalizeResponseHeaders = (headersString: string) => { }); return out.substring(0, out.length - 2); // 去掉最后的 \r\n }; + +// 获取本周是第几周 +// 遵循 ISO 8601, 一月四日为Week 1,星期一为新一周 +// 能应对每年开始和结束(不会因为踏入新一年而重新计算) +// 见 https://wikipedia.org/wiki/ISO_week_date +// 中文說明 https://juejin.cn/post/6921245139855736846 +export const getISOWeek = (date: Date): number => { + // 使用传入日期的年月日创建 UTC 日期对象,忽略本地时间部分,避免时区影响 + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + + // 将日期调整到本周的星期四(ISO 8601 规定:周数以星期四所在周为准) + // 计算方式:当前日期 + 4 − 当前星期几(星期一 = 1,星期日 = 7) + d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); + + // 获取该星期四所在年份的第一天(UTC) + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + + // 计算从年初到该星期四的天数差 + // 再换算为周数,并向上取整,得到 ISO 周数 + return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); +};