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) => ( - - ))} - - )} + + );