Subscribe Rules

This commit is contained in:
Gabe Yuan
2023-08-20 19:27:29 +08:00
parent a8caa34bbe
commit 7ec43a1d3f
12 changed files with 491 additions and 105 deletions

View File

@@ -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==

View File

@@ -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
});
/**

View File

@@ -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`,

View File

@@ -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: "",

View File

@@ -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);
// 监听消息

View File

@@ -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,35 +55,9 @@ 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
);
newRules = checkRules(newRules);
newRules.forEach((newRule) => {
const rule = rules.find((oldRule) => oldRule.pattern === newRule.pattern);
if (rule) {
Object.assign(rule, newRule);
} else {
@@ -101,3 +69,39 @@ export function useRules() {
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 };
}

View File

@@ -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) =>

View File

@@ -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;

99
src/libs/rules.js Normal file
View File

@@ -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;
};

View File

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

View File

@@ -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);
// 浮球按钮

View File

@@ -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) => {
@@ -421,7 +431,6 @@ export default function Rules() {
};
return (
<Box>
<Stack spacing={3}>
<Stack direction="row" spacing={2}>
<Button
@@ -461,14 +470,229 @@ export default function Rules() {
<RuleAccordion key={rule.pattern} rule={rule} rules={rules} />
))}
</Box>
</Stack>
);
}
{injectRules && (
<Box>
{BUILTIN_RULES.map((rule) => (
<RuleAccordion key={rule.pattern} rule={rule} />
))}
</Box>
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 (
<Stack direction="row" alignItems="center" spacing={2}>
<FormControlLabel value={url} control={<Radio />} label={url} />
{loading ? (
<CircularProgress size={16} />
) : (
<IconButton size="small" onClick={handleSync}>
<SyncIcon fontSize="small" />
</IconButton>
)}
{index !== 0 && selectedUrl !== url && (
<IconButton size="small" onClick={handleDel}>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</Stack>
);
}
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 (
<>
<Stack direction="row" alignItems="center" spacing={2}>
<Button
size="small"
variant="contained"
disabled={showInput}
onClick={(e) => {
e.preventDefault();
setShowInput(true);
}}
>
{i18n("add")}
</Button>
</Stack>
{showInput && (
<>
<TextField
size="small"
value={inputText}
error={!!inputError}
helperText={inputError}
onChange={handleInput}
onFocus={handleFocus}
label={i18n("subscribe_url")}
/>
<Stack direction="row" alignItems="center" spacing={2}>
<Button size="small" variant="contained" onClick={handleSave}>
{i18n("save")}
</Button>
<Button size="small" variant="outlined" onClick={handleCancel}>
{i18n("cancel")}
</Button>
</Stack>
</>
)}
</>
);
}
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 (
<Stack spacing={3}>
<SubRulesEdit subrules={subrules} />
<RadioGroup value={selectedSub?.url} onChange={handleSelect}>
{subrules.list.map((item, index) => (
<SubRulesItem
key={item.url}
url={item.url}
index={index}
selectedUrl={selectedSub?.url}
subrules={subrules}
setRules={setRules}
/>
))}
</RadioGroup>
<Box>
{loading ? (
<center>
<CircularProgress />
</center>
) : (
rules.map((rule) => <RuleAccordion key={rule.pattern} rule={rule} />)
)}
</Box>
</Stack>
);
}
export default function Rules() {
const i18n = useI18n();
const [activeTab, setActiveTab] = useState(0);
const handleTabChange = (e, newValue) => {
setActiveTab(newValue);
};
return (
<Box>
<Stack spacing={3}>
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs value={activeTab} onChange={handleTabChange}>
<Tab label={i18n("edit_rules")} />
<Tab label={i18n("subscribe_rules")} />
</Tabs>
</Box>
<div hidden={activeTab !== 0}>{activeTab === 0 && <UserRules />}</div>
<div hidden={activeTab !== 1}>{activeTab === 1 && <SubRules />}</div>
</Stack>
</Box>
);