diff --git a/config-overrides.js b/config-overrides.js
index 18d8c8e..b56cc4c 100644
--- a/config-overrides.js
+++ b/config-overrides.js
@@ -94,6 +94,9 @@ const userscriptWebpack = (config, env) => {
// @connect api.openai.com
// @connect openai.azure.com
// @connect workers.dev
+// @connect github.io
+// @connect githubusercontent.com
+// @connect kiss-translator.rayjar.com
// @run-at document-end
// ==/UserScript==
diff --git a/src/background.js b/src/background.js
index e5d371e..1f01c89 100644
--- a/src/background.js
+++ b/src/background.js
@@ -24,6 +24,7 @@ browser.runtime.onInstalled.addListener(() => {
storage.trySetObj(STOKEY_SETTING, DEFAULT_SETTING);
storage.trySetObj(STOKEY_RULES, DEFAULT_RULES);
storage.trySetObj(STOKEY_SYNC, DEFAULT_SYNC);
+ // todo:缓存内置rules
});
/**
diff --git a/src/config/i18n.js b/src/config/i18n.js
index 368e52d..0aa01d4 100644
--- a/src/config/i18n.js
+++ b/src/config/i18n.js
@@ -105,8 +105,20 @@ export const I18N = {
en: `Add`,
},
inject_rules: {
- zh: `注入内置规则`,
- en: `Inject Built-in Rules`,
+ zh: `注入订阅规则`,
+ en: `Inject Subscribe Rules`,
+ },
+ edit_rules: {
+ zh: `编辑规则`,
+ en: `Edit Rules`,
+ },
+ subscribe_rules: {
+ zh: `订阅规则`,
+ en: `Subscribe Rules`,
+ },
+ subscribe_url: {
+ zh: `订阅地址`,
+ en: `Subscribe URL`,
},
sync_warn: {
zh: `如果服务器存在其他客户端同步的数据,第一次同步将直接覆盖本地配置,后面则根据修改时间,新的覆盖旧的。`,
@@ -196,6 +208,10 @@ export const I18N = {
zh: `错误的文件类型`,
en: `Wrong file type`,
},
+ error_fetch_url: {
+ zh: `请检查url地址是否正确或稍后再试。`,
+ en: `Please check if the url address is correct or try again later.`,
+ },
openai_api: {
zh: `OpenAI 接口`,
en: `OpenAI API`,
diff --git a/src/config/index.js b/src/config/index.js
index 013975c..99367db 100644
--- a/src/config/index.js
+++ b/src/config/index.js
@@ -16,6 +16,7 @@ export const STOKEY_SETTING = `${APP_NAME}_setting`;
export const STOKEY_RULES = `${APP_NAME}_rules`;
export const STOKEY_SYNC = `${APP_NAME}_sync`;
export const STOKEY_FAB = `${APP_NAME}_fab`;
+export const STOKEY_RULESCACHE_PREFIX = `${APP_NAME}_rulescache_`;
export const CLIENT_WEB = "web";
export const CLIENT_CHROME = "chrome";
@@ -147,13 +148,25 @@ export const GLOBLA_RULE = {
bgColor: "",
};
+// 订阅列表
+export const DEFAULT_SUBRULES_LIST = [
+ {
+ url: "https://kiss-translator.rayjar.com/kiss-translator-rules.json",
+ selected: true,
+ },
+ {
+ url: "https://fishjar.github.io/kiss-translator/kiss-translator-rules.json",
+ },
+];
+
export const DEFAULT_SETTING = {
darkMode: false, // 深色模式
uiLang: "en", // 界面语言
fetchLimit: DEFAULT_FETCH_LIMIT, // 最大任务数量
fetchInterval: DEFAULT_FETCH_INTERVAL, // 任务间隔时间
clearCache: false, // 是否在浏览器下次启动时清除缓存
- injectRules: true, // 是否注入内置规则
+ injectRules: true, // 是否注入订阅规则
+ subrulesList: DEFAULT_SUBRULES_LIST, // 订阅列表
googleUrl: "https://translate.googleapis.com/translate_a/single", // 谷歌翻译接口
openaiUrl: "https://api.openai.com/v1/chat/completions",
openaiKey: "",
diff --git a/src/content.js b/src/content.js
index 9f53d30..00c30cf 100644
--- a/src/content.js
+++ b/src/content.js
@@ -13,7 +13,7 @@ import { Translator } from "./libs/translator";
(async () => {
const setting = await getSetting();
const rules = await getRules();
- const rule = matchRule(rules, document.location.href, setting);
+ const rule = await matchRule(rules, document.location.href, setting);
const translator = new Translator(rule, setting);
// 监听消息
diff --git a/src/hooks/Rules.js b/src/hooks/Rules.js
index 72f571c..5cda528 100644
--- a/src/hooks/Rules.js
+++ b/src/hooks/Rules.js
@@ -1,16 +1,10 @@
-import {
- STOKEY_RULES,
- OPT_TRANS_ALL,
- OPT_STYLE_ALL,
- OPT_LANGS_FROM,
- OPT_LANGS_TO,
- GLOBAL_KEY,
-} from "../config";
+import { STOKEY_RULES, DEFAULT_SUBRULES_LIST } from "../config";
import storage from "../libs/storage";
import { useStorages } from "./Storage";
-import { matchValue } from "../libs/utils";
import { syncRules } from "../libs/sync";
import { useSync } from "./Sync";
+import { useSetting, useSettingUpdate } from "./Setting";
+import { checkRules } from "../libs/rules";
/**
* 匹配规则增删改查 hook
@@ -61,43 +55,53 @@ export function useRules() {
const merge = async (newRules) => {
const rules = [...list];
- const fromLangs = OPT_LANGS_FROM.map((item) => item[0]);
- const toLangs = OPT_LANGS_TO.map((item) => item[0]);
- newRules
- .filter(({ pattern }) => pattern && typeof pattern === "string")
- .map(
- ({
- pattern,
- selector,
- translator,
- fromLang,
- toLang,
- textStyle,
- transOpen,
- bgColor,
- }) => ({
- pattern,
- selector: typeof selector === "string" ? selector : "",
- bgColor: typeof bgColor === "string" ? bgColor : "",
- translator: matchValue([GLOBAL_KEY, ...OPT_TRANS_ALL], translator),
- fromLang: matchValue([GLOBAL_KEY, ...fromLangs], fromLang),
- toLang: matchValue([GLOBAL_KEY, ...toLangs], toLang),
- textStyle: matchValue([GLOBAL_KEY, ...OPT_STYLE_ALL], textStyle),
- transOpen: matchValue([GLOBAL_KEY, "true", "false"], transOpen),
- })
- )
- .forEach((newRule) => {
- const rule = rules.find(
- (oldRule) => oldRule.pattern === newRule.pattern
- );
- if (rule) {
- Object.assign(rule, newRule);
- } else {
- rules.unshift(newRule);
- }
- });
+ newRules = checkRules(newRules);
+ newRules.forEach((newRule) => {
+ const rule = rules.find((oldRule) => oldRule.pattern === newRule.pattern);
+ if (rule) {
+ Object.assign(rule, newRule);
+ } else {
+ rules.unshift(newRule);
+ }
+ });
await update(rules);
};
return { list, add, del, put, merge };
}
+
+/**
+ * 订阅规则
+ * @returns
+ */
+export function useSubrules() {
+ const setting = useSetting();
+ const updateSetting = useSettingUpdate();
+ const list = setting?.subrulesList || DEFAULT_SUBRULES_LIST;
+
+ const select = async (url) => {
+ const subrulesList = [...list];
+ subrulesList.forEach((item) => {
+ if (item.url === url) {
+ item.selected = true;
+ } else {
+ item.selected = false;
+ }
+ });
+ await updateSetting({ subrulesList });
+ };
+
+ const add = async (url) => {
+ const subrulesList = [...list];
+ subrulesList.push({ url });
+ await updateSetting({ subrulesList });
+ };
+
+ const del = async (url) => {
+ let subrulesList = [...list];
+ subrulesList = subrulesList.filter((item) => item.url !== url);
+ await updateSetting({ subrulesList });
+ };
+
+ return { list, select, add, del };
+}
diff --git a/src/libs/index.js b/src/libs/index.js
index 92b4c97..7f56962 100644
--- a/src/libs/index.js
+++ b/src/libs/index.js
@@ -6,10 +6,11 @@ import {
STOKEY_FAB,
GLOBLA_RULE,
GLOBAL_KEY,
- BUILTIN_RULES,
+ DEFAULT_SUBRULES_LIST,
} from "../config";
import { browser } from "./browser";
import { isMatch } from "./utils";
+import { tryLoadRules } from "./rules";
/**
* 获取节点列表并转为数组
@@ -53,9 +54,21 @@ export const setFab = async (obj) => await storage.setObj(STOKEY_FAB, obj);
* @param {string} href
* @returns
*/
-export const matchRule = (rules, href, { injectRules }) => {
+export const matchRule = async (
+ rules,
+ href,
+ { injectRules, subrulesList = DEFAULT_SUBRULES_LIST }
+) => {
if (injectRules) {
- rules.splice(-1, 0, ...BUILTIN_RULES);
+ try {
+ const selectedSub = subrulesList.find((item) => item.selected);
+ if (selectedSub?.url) {
+ const subRules = await tryLoadRules(selectedSub.url);
+ rules.splice(-1, 0, ...subRules);
+ }
+ } catch (err) {
+ console.log("[load injectRules]", err);
+ }
}
const rule = rules.find((rule) =>
diff --git a/src/libs/pool.js b/src/libs/pool.js
index b46c8fe..2cc2797 100644
--- a/src/libs/pool.js
+++ b/src/libs/pool.js
@@ -1,3 +1,11 @@
+/**
+ * 任务池
+ * @param {*} fn
+ * @param {*} preFn
+ * @param {*} _interval
+ * @param {*} _limit
+ * @returns
+ */
export const taskPool = (fn, preFn, _interval = 100, _limit = 100) => {
const pool = [];
const maxRetry = 2; // 最大重试次数
@@ -6,11 +14,6 @@ export const taskPool = (fn, preFn, _interval = 100, _limit = 100) => {
let interval = _interval; // 间隔时间
let timer = null;
- /**
- * 任务池
- * @param {*} item
- * @param {*} preArgs
- */
const handleTask = async (item, preArgs) => {
curCount++;
const { args, resolve, reject, retry } = item;
diff --git a/src/libs/rules.js b/src/libs/rules.js
new file mode 100644
index 0000000..c3611bf
--- /dev/null
+++ b/src/libs/rules.js
@@ -0,0 +1,99 @@
+import storage from "./storage";
+import { fetchPolyfill } from "./fetch";
+import { matchValue, type } from "./utils";
+import {
+ STOKEY_RULESCACHE_PREFIX,
+ GLOBAL_KEY,
+ OPT_TRANS_ALL,
+ OPT_STYLE_ALL,
+ OPT_LANGS_FROM,
+ OPT_LANGS_TO,
+} from "../config";
+
+const fromLangs = OPT_LANGS_FROM.map((item) => item[0]);
+const toLangs = OPT_LANGS_TO.map((item) => item[0]);
+
+/**
+ * 检查过滤rules
+ * @param {*} rules
+ * @returns
+ */
+export const checkRules = (rules) => {
+ if (type(rules) === "string") {
+ rules = JSON.parse(rules);
+ }
+ if (type(rules) !== "array") {
+ throw new Error("data error");
+ }
+
+ const patternSet = new Set();
+ rules = rules
+ .filter((rule) => type(rule) === "object")
+ .filter(({ pattern }) => {
+ if (type(pattern) !== "string" || patternSet.has(pattern.trim())) {
+ return false;
+ }
+ patternSet.add(pattern.trim());
+ return true;
+ })
+ .map(
+ ({
+ pattern,
+ selector,
+ translator,
+ fromLang,
+ toLang,
+ textStyle,
+ transOpen,
+ bgColor,
+ }) => ({
+ pattern: pattern.trim(),
+ selector: type(selector) === "string" ? selector : "",
+ bgColor: type(bgColor) === "string" ? bgColor : "",
+ translator: matchValue([GLOBAL_KEY, ...OPT_TRANS_ALL], translator),
+ fromLang: matchValue([GLOBAL_KEY, ...fromLangs], fromLang),
+ toLang: matchValue([GLOBAL_KEY, ...toLangs], toLang),
+ textStyle: matchValue([GLOBAL_KEY, ...OPT_STYLE_ALL], textStyle),
+ transOpen: matchValue([GLOBAL_KEY, "true", "false"], transOpen),
+ })
+ );
+
+ return rules;
+};
+
+/**
+ * 本地rules缓存
+ */
+export const rulesCache = {
+ fetch: async (url) => {
+ const res = await fetchPolyfill(url);
+ const rules = checkRules(res).filter(
+ (rule) => rule.pattern.replaceAll(GLOBAL_KEY, "") !== ""
+ );
+ return rules;
+ },
+ set: async (url, rules) => {
+ await storage.setObj(`${STOKEY_RULESCACHE_PREFIX}${url}`, rules);
+ },
+ get: async (url) => {
+ return await storage.getObj(`${STOKEY_RULESCACHE_PREFIX}${url}`);
+ },
+ del: async (url) => {
+ await storage.del(`${STOKEY_RULESCACHE_PREFIX}${url}`);
+ },
+};
+
+/**
+ * 从缓存或远程加载订阅的rules
+ * @param {*} url
+ * @returns
+ */
+export const tryLoadRules = async (url) => {
+ let rules = await rulesCache.get(url);
+ if (rules?.length) {
+ return rules;
+ }
+ rules = await rulesCache.fetch(url);
+ await rulesCache.set(url, rules);
+ return rules;
+};
diff --git a/src/libs/utils.js b/src/libs/utils.js
index 93264b7..6f80d93 100644
--- a/src/libs/utils.js
+++ b/src/libs/utils.js
@@ -88,3 +88,13 @@ export const isMatch = (s, p) => {
return p.slice(pIndex).replaceAll("*", "") === "";
};
+
+/**
+ * 类型检查
+ * @param {*} o
+ * @returns
+ */
+export const type = (o) => {
+ const s = Object.prototype.toString.call(o);
+ return s.match(/\[object (.*?)\]/)[1].toLowerCase();
+};
diff --git a/src/userscript.js b/src/userscript.js
index dadbfa8..f103393 100644
--- a/src/userscript.js
+++ b/src/userscript.js
@@ -29,7 +29,7 @@ import { Translator } from "./libs/translator";
// 翻译页面
const setting = await getSetting();
const rules = await getRules();
- const rule = matchRule(rules, document.location.href, setting);
+ const rule = await matchRule(rules, document.location.href, setting);
const translator = new Translator(rule, setting);
// 浮球按钮
diff --git a/src/views/Options/Rules.js b/src/views/Options/Rules.js
index 7394bb6..9980573 100644
--- a/src/views/Options/Rules.js
+++ b/src/views/Options/Rules.js
@@ -2,6 +2,7 @@ import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
+import CircularProgress from "@mui/material/CircularProgress";
import {
GLOBAL_KEY,
DEFAULT_RULE,
@@ -9,9 +10,8 @@ import {
OPT_LANGS_TO,
OPT_TRANS_ALL,
OPT_STYLE_ALL,
- BUILTIN_RULES,
} from "../../config";
-import { useState, useRef } from "react";
+import { useState, useRef, useEffect } from "react";
import { useI18n } from "../../hooks/I18n";
import Typography from "@mui/material/Typography";
import Accordion from "@mui/material/Accordion";
@@ -26,6 +26,15 @@ import FileUploadIcon from "@mui/icons-material/FileUpload";
import { useSetting, useSettingUpdate } from "../../hooks/Setting";
import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
+import Tabs from "@mui/material/Tabs";
+import Tab from "@mui/material/Tab";
+import Radio from "@mui/material/Radio";
+import RadioGroup from "@mui/material/RadioGroup";
+import DeleteIcon from "@mui/icons-material/Delete";
+import IconButton from "@mui/material/IconButton";
+import SyncIcon from "@mui/icons-material/Sync";
+import { useSubrules } from "../../hooks/Rules";
+import { rulesCache, tryLoadRules } from "../../libs/rules";
function RuleFields({ rule, rules, setShow }) {
const initFormValues = rule || { ...DEFAULT_RULE, transOpen: "true" };
@@ -384,12 +393,13 @@ function UploadButton({ onChange, text }) {
);
}
-export default function Rules() {
+function UserRules() {
const i18n = useI18n();
const rules = useRules();
const [showAdd, setShowAdd] = useState(false);
const setting = useSetting();
const updateSetting = useSettingUpdate();
+
const injectRules = !!setting?.injectRules;
const handleImport = (e) => {
@@ -420,55 +430,269 @@ export default function Rules() {
});
};
+ return (
+
+
+
+
+
+
+
+
+ }
+ label={i18n("inject_rules")}
+ />
+
+
+ {showAdd && }
+
+
+ {rules.list.map((rule) => (
+
+ ))}
+
+
+ );
+}
+
+function SubRulesItem({ index, url, selectedUrl, subrules, setRules }) {
+ const [loading, setLoading] = useState(false);
+
+ const handleDel = async () => {
+ try {
+ await subrules.del(url);
+ await rulesCache.del(url);
+ } catch (err) {
+ console.log("[del subrules]", err);
+ }
+ };
+
+ const handleSync = async () => {
+ try {
+ setLoading(true);
+ const rules = await rulesCache.fetch(url);
+ await rulesCache.set(url, rules);
+ if (url === selectedUrl) {
+ setRules(rules);
+ }
+ } catch (err) {
+ console.log("[sync rules]", err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ } label={url} />
+
+ {loading ? (
+
+ ) : (
+
+
+
+ )}
+
+ {index !== 0 && selectedUrl !== url && (
+
+
+
+ )}
+
+ );
+}
+
+function SubRulesEdit({ subrules }) {
+ const i18n = useI18n();
+ const [inputText, setInputText] = useState("");
+ const [inputError, setInputError] = useState("");
+ const [showInput, setShowInput] = useState(false);
+
+ const handleCancel = (e) => {
+ e.preventDefault();
+ setShowInput(false);
+ setInputText("");
+ setInputError("");
+ };
+
+ const handleSave = async (e) => {
+ e.preventDefault();
+ const url = inputText.trim();
+
+ if (!url) {
+ setInputError(i18n("error_cant_be_blank"));
+ return;
+ }
+
+ if (subrules.list.find((item) => item.url === url)) {
+ setInputError(i18n("error_duplicate_values"));
+ return;
+ }
+
+ try {
+ const rules = await rulesCache.fetch(url);
+ if (rules.length === 0) {
+ throw new Error("empty rules");
+ }
+ await rulesCache.set(url, rules);
+ await subrules.add(url);
+ setShowInput(false);
+ setInputText("");
+ } catch (err) {
+ console.log("[fetch rules]", err);
+ setInputError(i18n("error_fetch_url"));
+ }
+ };
+
+ const handleInput = (e) => {
+ e.preventDefault();
+ setInputText(e.target.value);
+ };
+
+ const handleFocus = (e) => {
+ e.preventDefault();
+ setInputError("");
+ };
+
+ return (
+ <>
+
+
+
+
+ {showInput && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+ >
+ );
+}
+
+function SubRules() {
+ const [loading, setLoading] = useState(false);
+ const [rules, setRules] = useState([]);
+ const subrules = useSubrules();
+ const selectedSub = subrules.list.find((item) => item.selected);
+
+ const handleSelect = (e) => {
+ const url = e.target.value;
+ subrules.select(url);
+ };
+
+ useEffect(() => {
+ (async () => {
+ if (selectedSub?.url) {
+ try {
+ setLoading(true);
+
+ const rules = await tryLoadRules(selectedSub?.url);
+ setRules(rules);
+ } catch (err) {
+ console.log("[load rules]", err);
+ } finally {
+ setLoading(false);
+ }
+ }
+ })();
+ }, [selectedSub?.url]);
+
+ return (
+
+
+
+
+ {subrules.list.map((item, index) => (
+
+ ))}
+
+
+
+ {loading ? (
+
+
+
+ ) : (
+ rules.map((rule) => )
+ )}
+
+
+ );
+}
+
+export default function Rules() {
+ const i18n = useI18n();
+ const [activeTab, setActiveTab] = useState(0);
+
+ const handleTabChange = (e, newValue) => {
+ setActiveTab(newValue);
+ };
+
return (
-
-
-
-
-
-
-
- }
- label={i18n("inject_rules")}
- />
-
-
- {showAdd && }
-
-
- {rules.list.map((rule) => (
-
- ))}
+
+
+
+
+
-
- {injectRules && (
-
- {BUILTIN_RULES.map((rule) => (
-
- ))}
-
- )}
+ {activeTab === 0 && }
+ {activeTab === 1 && }
);