Compare commits

..

25 Commits

Author SHA1 Message Date
Gabe Yuan
f463f3ce08 v1.5.3 2023-08-21 23:50:32 +08:00
Gabe Yuan
c0872db98c auto use unsafe fetch 2023-08-21 23:50:14 +08:00
Gabe Yuan
d3a5d91f01 auto use unsafe fetch 2023-08-21 23:46:42 +08:00
Gabe Yuan
3e9338be0e v1.5.2 2023-08-21 22:24:42 +08:00
Gabe Yuan
ef7f1ad638 fetch subrules use unsafe fetch 2023-08-21 21:35:53 +08:00
Gabe Yuan
1f10ebe404 fetch subrules use unsafe fetch 2023-08-21 21:31:20 +08:00
Gabe Yuan
f4a8251c61 add shortcut: Toggle Style 2023-08-21 16:06:21 +08:00
Gabe Yuan
f585a43480 v1.5.1 2023-08-21 14:52:57 +08:00
Gabe Yuan
3a11465c24 fix stack useFlexGap 2023-08-21 14:43:22 +08:00
Gabe Yuan
3c3ebdf96c add command shortcuts & menu command 2023-08-21 14:03:39 +08:00
Gabe Yuan
6b30f443e1 v1.5.0 2023-08-20 23:32:06 +08:00
Gabe Yuan
232e9a47a2 share rules 2023-08-20 23:30:08 +08:00
Gabe Yuan
7ec43a1d3f Subscribe Rules 2023-08-20 19:27:29 +08:00
Gabe Yuan
a8caa34bbe v1.4.6 2023-08-19 15:16:33 +08:00
Gabe Yuan
c2fd1fe9e0 fix storage bug 2023-08-19 13:48:03 +08:00
Gabe Yuan
2773a76af8 yarn install 2023-08-18 23:50:06 +08:00
Gabe Yuan
1dc7026e8f add rules generate script 2023-08-18 16:48:44 +08:00
Gabe Yuan
b36ede7393 fix userscript grant 2023-08-18 13:19:40 +08:00
Gabe Yuan
b18721a4e5 wildcard is supported 2023-08-18 13:16:17 +08:00
Gabe Yuan
01676bc682 fix fab at left default 2023-08-17 16:22:04 +08:00
Gabe Yuan
53c32f2bd8 v1.4.5 2023-08-17 16:01:55 +08:00
Gabe Yuan
0b9fe65833 inject builtin rules 2023-08-17 15:55:44 +08:00
Gabe Yuan
bd45947d68 add version to index page 2023-08-17 13:39:57 +08:00
Gabe Yuan
5d2e767e74 optimize debounce form & sync data 2023-08-17 13:27:22 +08:00
Gabe Yuan
30af4c11d0 update rules 2023-08-16 23:03:03 +08:00
36 changed files with 3563 additions and 2571 deletions

5
.babelrc Normal file
View File

@@ -0,0 +1,5 @@
{
"presets": [
"@babel/preset-env"
]
}

2
.env
View File

@@ -2,7 +2,7 @@ GENERATE_SOURCEMAP=false
REACT_APP_NAME=KISS Translator
REACT_APP_NAME_CN=简约翻译
REACT_APP_VERSION=1.4.4
REACT_APP_VERSION=1.5.3
REACT_APP_HOMEPAGE=https://github.com/fishjar/kiss-translator
REACT_APP_OPTIONSPAGE=https://kiss-translator.rayjar.com/options
REACT_APP_OPTIONSPAGE2=https://fishjar.github.io/kiss-translator/options.html

View File

@@ -83,14 +83,12 @@ const userscriptWebpack = (config, env) => {
// @icon ${process.env.REACT_APP_LOGOURL}
// @downloadURL ${process.env.REACT_APP_USERSCRIPT_DOWNLOADURL}
// @updateURL ${process.env.REACT_APP_USERSCRIPT_DOWNLOADURL}
// @grant GM_xmlhttpRequest
// @grant GM.xmlhttpRequest
// @grant GM_setValue
// @grant GM.xmlHttpRequest
// @grant GM.registerMenuCommand
// @grant GM.setValue
// @grant GM_getValue
// @grant GM.getValue
// @grant GM_deleteValue
// @grant GM.deleteValue
// @grant GM.info
// @grant unsafeWindow
// @connect translate.googleapis.com
// @connect api-edge.cognitive.microsofttranslator.com
@@ -98,6 +96,10 @@ 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
// @connect ghproxy.com
// @run-at document-end
// ==/UserScript==

View File

@@ -1,7 +1,7 @@
{
"name": "kiss-translator",
"description": "A minimalist bilingual translation Extension & Greasemonkey Script",
"version": "1.4.4",
"version": "1.5.3",
"author": "Gabe<yugang2002@gmail.com>",
"private": true,
"dependencies": {
@@ -25,7 +25,8 @@
"build:firefox": "rm -rf build/firefox && cp -r build/chrome build/firefox && cat ./build/firefox/manifest.firefox.json > ./build/firefox/manifest.json",
"build:web": "rm -rf build/web && BUILD_PATH=./build/web REACT_APP_CLIENT=userscript react-app-rewired build",
"build:userscript": "rm -rf build/userscript && mkdir build/userscript && cp build/web/kiss-translator.user.js build/userscript/kiss-translator.user.js",
"build": "yarn build:chrome && yarn build:edge && yarn build:firefox && yarn build:web && yarn build:userscript",
"build:rules": "babel-node src/rules.js",
"build": "yarn build:chrome && yarn build:edge && yarn build:firefox && yarn build:web && yarn build:userscript && yarn build:rules",
"deploy:web": "wrangler pages deploy ./build/web --project-name kiss-translator",
"test": "react-app-rewired test",
"eject": "react-scripts eject"
@@ -53,6 +54,10 @@
]
},
"devDependencies": {
"@babel/core": "^7.22.10",
"@babel/node": "^7.22.10",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-env": "^7.22.10",
"react-app-rewired": "^2.2.1",
"wrangler": "^3.4.0"
}

View File

@@ -4,5 +4,11 @@
},
"app_description": {
"message": "A minimalist bilingual translation Extension & Greasemonkey Script"
},
"toggle_translate": {
"message": "Toggle Translate"
},
"toggle_style": {
"message": "Toggle Style"
}
}

View File

@@ -4,5 +4,11 @@
},
"app_description": {
"message": "一个简约的双语网页翻译扩展 & 油猴脚本"
},
"toggle_translate": {
"message": "切换翻译"
},
"toggle_style": {
"message": "切换样式"
}
}

View File

@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_app_name__",
"description": "__MSG_app_description__",
"version": "1.4.4",
"version": "1.5.3",
"default_locale": "en",
"author": "Gabe<yugang2002@gmail.com>",
"homepage_url": "https://github.com/fishjar/kiss-translator",
@@ -15,6 +15,20 @@
"matches": ["<all_urls>"]
}
],
"commands": {
"toggleTranslate": {
"suggested_key": {
"default": "Alt+Q"
},
"description": "__MSG_toggle_translate__"
},
"toggleStyle": {
"suggested_key": {
"default": "Alt+C"
},
"description": "__MSG_toggle_style__"
}
},
"permissions": ["<all_urls>", "storage"],
"icons": {
"16": "images/logo16.png",

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "__MSG_app_name__",
"description": "__MSG_app_description__",
"version": "1.4.4",
"version": "1.5.3",
"default_locale": "en",
"author": "Gabe<yugang2002@gmail.com>",
"homepage_url": "https://github.com/fishjar/kiss-translator",
@@ -16,6 +16,20 @@
"matches": ["<all_urls>"]
}
],
"commands": {
"toggleTranslate": {
"suggested_key": {
"default": "Alt+Q"
},
"description": "__MSG_toggle_translate__"
},
"toggleStyle": {
"suggested_key": {
"default": "Alt+C"
},
"description": "__MSG_toggle_style__"
}
},
"permissions": ["storage"],
"host_permissions": ["<all_urls>"],
"icons": {

View File

@@ -8,9 +8,10 @@ import {
OPT_LANGS_SPECIAL,
PROMPT_PLACE_FROM,
PROMPT_PLACE_TO,
KV_HEADER_KEY,
KV_SALT_SYNC,
} from "../config";
import { getSetting, detectLang } from "../libs";
import { sha256 } from "../libs/utils";
/**
* 同步数据
@@ -20,18 +21,14 @@ import { getSetting, detectLang } from "../libs";
* @returns
*/
export const apiSyncData = async (url, key, data) =>
fetchPolyfill(
url,
{
headers: {
"Content-type": "application/json",
[KV_HEADER_KEY]: key,
},
method: "POST",
body: JSON.stringify(data),
fetchPolyfill(url, {
headers: {
"Content-type": "application/json",
Authorization: `Bearer ${await sha256(key, KV_SALT_SYNC)}`,
},
{ useUnsafe: true }
);
method: "POST",
body: JSON.stringify(data),
});
/**
* 谷歌翻译

View File

@@ -3,6 +3,10 @@ import {
MSG_FETCH,
MSG_FETCH_LIMIT,
MSG_FETCH_CLEAR,
MSG_TRANS_TOGGLE,
MSG_TRANS_TOGGLE_STYLE,
CMD_TOGGLE_TRANSLATE,
CMD_TOGGLE_STYLE,
DEFAULT_SETTING,
DEFAULT_RULES,
DEFAULT_SYNC,
@@ -15,22 +19,24 @@ import storage from "./libs/storage";
import { getSetting } from "./libs";
import { syncAll } from "./libs/sync";
import { fetchData, fetchPool } from "./libs/fetch";
import { sendTabMsg } from "./libs/msg";
/**
* 插件安装
*/
browser.runtime.onInstalled.addListener(() => {
console.log("onInstalled");
console.log("KISS Translator onInstalled");
storage.trySetObj(STOKEY_SETTING, DEFAULT_SETTING);
storage.trySetObj(STOKEY_RULES, DEFAULT_RULES);
storage.trySetObj(STOKEY_SYNC, DEFAULT_SYNC);
// todo缓存内置rules
});
/**
* 浏览器启动
*/
browser.runtime.onStartup.addListener(async () => {
console.log("onStartup");
console.log("browser onStartup");
// 同步数据
await syncAll();
@@ -73,3 +79,19 @@ browser.runtime.onMessage.addListener(
return true;
}
);
/**
* 监听快捷键
*/
browser.commands.onCommand.addListener((command) => {
// console.log(`Command: ${command}`);
switch (command) {
case CMD_TOGGLE_TRANSLATE:
sendTabMsg(MSG_TRANS_TOGGLE);
break;
case CMD_TOGGLE_STYLE:
sendTabMsg(MSG_TRANS_TOGGLE_STYLE);
break;
default:
}
});

View File

@@ -104,6 +104,22 @@ export const I18N = {
zh: `添加`,
en: `Add`,
},
inject_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: `如果服务器存在其他客户端同步的数据,第一次同步将直接覆盖本地配置,后面则根据修改时间,新的覆盖旧的。`,
en: `If the server has data synchronized by other clients, the first synchronization will directly overwrite the local configuration, and later, according to the modification time, the new one will overwrite the old one.`,
@@ -149,12 +165,12 @@ export const I18N = {
en: `URL pattern`,
},
pattern_helper: {
zh: `多个URL支持英文逗号“,”分隔`,
en: `Multiple URLs can be separated by English commas ","`,
zh: `1、支持星号(*)通配符。2、多个URL支持英文逗号“,”分隔`,
en: `1. The asterisk (*) wildcard is supported. 2. Multiple URLs can be separated by English commas ",".`,
},
selector_helper: {
zh: `1、遵循CSS选择器规则,但不同浏览器,支持写法不尽相同。2、留空表示采用全局设置。`,
en: `1. Follow CSS selector rules, but different browsers support different writing methods. 2. Leave blank to adopt the global setting.`,
zh: `1、遵循CSS选择器规则。2、留空表示采用全局设置。`,
en: `1. Follow CSS selector rules. 2. Leave blank to adopt the global setting.`,
},
translate_switch: {
zh: `开启翻译`,
@@ -192,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`,
@@ -228,4 +248,12 @@ export const I18N = {
zh: `数据同步密钥`,
en: `Data Sync Key`,
},
error_got_some_wrong: {
zh: "抱歉,出错了!",
en: "Sorry, something went wrong!",
},
error_sync_setting: {
zh: "您的同步设置未填写,无法在线分享。",
en: "Your sync settings are missing and cannot be shared online.",
},
};

View File

@@ -1,5 +1,11 @@
import { DEFAULT_SELECTOR, RULES } from "./rules";
import {
DEFAULT_SELECTOR,
GLOBAL_KEY,
DEFAULT_RULE,
BUILTIN_RULES,
} from "./rules";
export { I18N, UI_LANGS } from "./i18n";
export { GLOBAL_KEY, DEFAULT_RULE, BUILTIN_RULES };
const APP_NAME = process.env.REACT_APP_NAME.trim().split(/\s+/).join("-");
@@ -10,8 +16,10 @@ 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 GLOBAL_KEY = "*";
export const CMD_TOGGLE_TRANSLATE = "toggleTranslate";
export const CMD_TOGGLE_STYLE = "toggleStyle";
export const CLIENT_WEB = "web";
export const CLIENT_CHROME = "chrome";
@@ -20,9 +28,11 @@ export const CLIENT_FIREFOX = "firefox";
export const CLIENT_USERSCRIPT = "userscript";
export const CLIENT_EXTS = [CLIENT_CHROME, CLIENT_EDGE, CLIENT_FIREFOX];
export const KV_HEADER_KEY = "X-KISS-PSK";
export const KV_RULES_KEY = "KT_RULES";
export const KV_RULES_SHARE_KEY = "KT_RULES_SHARE";
export const KV_SETTING_KEY = "KT_SETTING";
export const KV_SALT_SYNC = "KISS-Translator-SYNC";
export const KV_SALT_SHARE = "KISS-Translator-SHARE";
export const CACHE_NAME = `${APP_NAME}_cache`;
@@ -30,6 +40,7 @@ export const MSG_FETCH = "fetch";
export const MSG_FETCH_LIMIT = "fetch_limit";
export const MSG_FETCH_CLEAR = "fetch_clear";
export const MSG_TRANS_TOGGLE = "trans_toggle";
export const MSG_TRANS_TOGGLE_STYLE = "trans_toggle_style";
export const MSG_TRANS_GETRULE = "trans_getrule";
export const MSG_TRANS_PUTRULE = "trans_putrule";
export const MSG_TRANS_CURRULE = "trans_currule";
@@ -143,17 +154,16 @@ export const GLOBLA_RULE = {
bgColor: "",
};
// 默认规则
export const DEFAULT_RULE = {
pattern: "",
selector: "",
translator: GLOBAL_KEY,
fromLang: GLOBAL_KEY,
toLang: GLOBAL_KEY,
textStyle: GLOBAL_KEY,
transOpen: GLOBAL_KEY,
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, // 深色模式
@@ -161,6 +171,8 @@ export const DEFAULT_SETTING = {
fetchLimit: DEFAULT_FETCH_LIMIT, // 最大任务数量
fetchInterval: DEFAULT_FETCH_INTERVAL, // 任务间隔时间
clearCache: false, // 是否在浏览器下次启动时清除缓存
injectRules: true, // 是否注入订阅规则
subrulesList: DEFAULT_SUBRULES_LIST, // 订阅列表
googleUrl: "https://translate.googleapis.com/translate_a/single", // 谷歌翻译接口
openaiUrl: "https://api.openai.com/v1/chat/completions",
openaiKey: "",
@@ -168,14 +180,7 @@ export const DEFAULT_SETTING = {
openaiPrompt: `You will be provided with a sentence in ${PROMPT_PLACE_FROM}, and your task is to translate it into ${PROMPT_PLACE_TO}.`,
};
export const DEFAULT_RULES = [
...RULES.map((item) => ({
...DEFAULT_RULE,
...item,
transOpen: "true",
})),
GLOBLA_RULE,
];
export const DEFAULT_RULES = [GLOBLA_RULE];
export const TRANS_MIN_LENGTH = 5; // 最短翻译长度
export const TRANS_MAX_LENGTH = 5000; // 最长翻译长度

View File

@@ -2,15 +2,32 @@ const els = `li, p, h1, h2, h3, h4, h5, h6, dd`;
export const DEFAULT_SELECTOR = `:is(${els})`;
export const RULES = [
export const GLOBAL_KEY = "*";
export const DEFAULT_RULE = {
pattern: "",
selector: "",
translator: GLOBAL_KEY,
fromLang: GLOBAL_KEY,
toLang: GLOBAL_KEY,
textStyle: GLOBAL_KEY,
transOpen: GLOBAL_KEY,
bgColor: "",
};
const RULES = [
{
pattern: `bearblog.dev, www.theverge.com, www.tampermonkey.net/documentation.php`,
selector: DEFAULT_SELECTOR,
pattern: `www.google.com/search`,
selector: `h3, .IsZvec, .VwiC3b`,
},
{
pattern: `https://news.google.com/`,
selector: `h4`,
},
{
pattern: `bearblog.dev, www.theverge.com, www.tampermonkey.net/documentation.php`,
selector: DEFAULT_SELECTOR,
},
{
pattern: `themessenger.com`,
selector: `.leading-tight, .leading-tighter, .my-2 p, .font-body p, article ${DEFAULT_SELECTOR}`,
@@ -117,7 +134,7 @@ export const RULES = [
},
{
pattern: `https://github.com/`,
selector: `.markdown-body ${DEFAULT_SELECTOR}, .repo-description p, .Layout-sidebar .f4, .container-lg .py-4 .f5, .container-lg .my-4 .f5, .Box-row .pr-4, .Box-row article .mt-1, [itemprop='description']`,
selector: `.markdown-body ${DEFAULT_SELECTOR}, .repo-description p, .Layout-sidebar .f4, .container-lg .py-4 .f5, .container-lg .my-4 .f5, .Box-row .pr-4, .Box-row article .mt-1, [itemprop='description'], .markdown-title, bdi`,
},
{
pattern: `twitter.com`,
@@ -127,8 +144,10 @@ export const RULES = [
pattern: `youtube.com`,
selector: `h1, #video-title, #content-text, #title, yt-attributed-string>span>span`,
},
{
pattern: `www.google.com/search`,
selector: `h3, .IsZvec, .VwiC3b`,
},
];
export const BUILTIN_RULES = RULES.map((item) => ({
...DEFAULT_RULE,
...item,
transOpen: "true",
}));

View File

@@ -1,6 +1,7 @@
import { browser } from "./libs/browser";
import {
MSG_TRANS_TOGGLE,
MSG_TRANS_TOGGLE_STYLE,
MSG_TRANS_GETRULE,
MSG_TRANS_PUTRULE,
} from "./config";
@@ -13,7 +14,7 @@ import { Translator } from "./libs/translator";
(async () => {
const setting = await getSetting();
const rules = await getRules();
const rule = matchRule(rules, document.location.href);
const rule = await matchRule(rules, document.location.href, setting);
const translator = new Translator(rule, setting);
// 监听消息
@@ -22,6 +23,9 @@ import { Translator } from "./libs/translator";
case MSG_TRANS_TOGGLE:
translator.toggle();
break;
case MSG_TRANS_TOGGLE_STYLE:
translator.toggleStyle();
break;
case MSG_TRANS_GETRULE:
break;
case MSG_TRANS_PUTRULE:

60
src/hooks/Alert.js Normal file
View File

@@ -0,0 +1,60 @@
import { createContext, useContext, useState, forwardRef } from "react";
import Snackbar from "@mui/material/Snackbar";
import MuiAlert from "@mui/material/Alert";
const Alert = forwardRef(function Alert(props, ref) {
return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />;
});
const AlertContext = createContext(null);
/**
* 左下角提示注入context后方便全局调用
* @param {*} param0
* @returns
*/
export function AlertProvider({ children }) {
const vertical = "top";
const horizontal = "center";
const [open, setOpen] = useState(false);
const [severity, setSeverity] = useState("info");
const [message, setMessage] = useState("");
const error = (msg) => showAlert(msg, "error");
const warning = (msg) => showAlert(msg, "warning");
const info = (msg) => showAlert(msg, "info");
const success = (msg) => showAlert(msg, "success");
const showAlert = (msg, type) => {
setOpen(true);
setMessage(msg);
setSeverity(type);
};
const handleClose = (_, reason) => {
if (reason === "clickaway") {
return;
}
setOpen(false);
};
return (
<AlertContext.Provider value={{ error, warning, info, success }}>
{children}
<Snackbar
open={open}
autoHideDuration={3000}
onClose={handleClose}
anchorOrigin={{ vertical, horizontal }}
>
<Alert onClose={handleClose} severity={severity} sx={{ width: "100%" }}>
{message}
</Alert>
</Snackbar>
</AlertContext.Provider>
);
}
export function useAlert() {
return useContext(AlertContext);
}

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
@@ -25,11 +19,7 @@ export function useRules() {
const updateAt = sync.opt?.rulesUpdateAt ? Date.now() : 0;
await storage.setObj(STOKEY_RULES, rules);
await sync.update({ rulesUpdateAt: updateAt });
try {
await syncRules();
} catch (err) {
console.log("[sync rules]", err);
}
syncRules();
};
const add = async (rule) => {
@@ -65,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 };
}

View File

@@ -2,6 +2,7 @@ import { STOKEY_SETTING } from "../config";
import storage from "../libs/storage";
import { useStorages } from "./Storage";
import { useSync } from "./Sync";
import { syncSetting } from "../libs/sync";
/**
* 设置hook
@@ -22,5 +23,6 @@ export function useSettingUpdate() {
const updateAt = sync.opt?.settingUpdateAt ? Date.now() : 0;
await storage.putObj(STOKEY_SETTING, obj);
await sync.update({ settingUpdateAt: updateAt });
syncSetting();
};
}

View File

@@ -3,7 +3,6 @@ import { browser, isExt, isGm, isWeb } from "../libs/browser";
import {
STOKEY_SETTING,
STOKEY_RULES,
STOKEY_MSAUTH,
STOKEY_SYNC,
DEFAULT_SETTING,
DEFAULT_RULES,
@@ -15,12 +14,13 @@ import storage from "../libs/storage";
* 默认配置
*/
export const defaultStorage = {
[STOKEY_MSAUTH]: null,
[STOKEY_SETTING]: DEFAULT_SETTING,
[STOKEY_RULES]: DEFAULT_RULES,
[STOKEY_SYNC]: DEFAULT_SYNC,
};
const activeKeys = Object.keys(defaultStorage);
const StoragesContext = createContext(null);
export function StoragesProvider({ children }) {
@@ -38,7 +38,10 @@ export function StoragesProvider({ children }) {
}
const newStorages = {};
Object.entries(changes)
.filter(([_, { oldValue, newValue }]) => oldValue !== newValue)
.filter(
([key, { oldValue, newValue }]) =>
activeKeys.includes(key) && oldValue !== newValue
)
.forEach(([key, { newValue }]) => {
newStorages[key] = JSON.parse(newValue);
});
@@ -51,8 +54,7 @@ export function StoragesProvider({ children }) {
// 首次从storage同步配置到内存
(async () => {
const curStorages = {};
const keys = Object.keys(defaultStorage);
for (const key of keys) {
for (const key of activeKeys) {
const val = await storage.get(key);
if (val) {
curStorages[key] = JSON.parse(val);

View File

@@ -1,6 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import CircularProgress from "@mui/material/CircularProgress";
import Divider from "@mui/material/Divider";
import ReactMarkdown from "react-markdown";
import Paper from "@mui/material/Paper";
import { useFetch } from "./hooks/Fetch";
@@ -12,6 +13,7 @@ function App() {
);
return (
<Paper sx={{ padding: 2, margin: 2 }}>
<Divider>{`KISS Translator v${process.env.REACT_APP_VERSION}`}</Divider>
{loading ? (
<center>
<CircularProgress />

View File

@@ -65,7 +65,7 @@ const newCacheReq = async (request) => {
* @param {*} param0
* @returns
*/
const fetchApi = async ({ input, init, useUnsafe, translator, token }) => {
const fetchApi = async ({ input, init, translator, token }) => {
if (translator === OPT_TRANS_MICROSOFT) {
init.headers["Authorization"] = `Bearer ${token}`;
} else if (translator === OPT_TRANS_OPENAI) {
@@ -73,8 +73,13 @@ const fetchApi = async ({ input, init, useUnsafe, translator, token }) => {
init.headers["api-key"] = token; // Azure OpenAI
}
if (isGm && !useUnsafe) {
return fetchGM(input, init);
if (isGm) {
const connects = GM?.info?.script?.connects || [];
const url = new URL(input);
const isSafe = connects.find((item) => url.hostname.endsWith(item));
if (isSafe) {
return fetchGM(input, init);
}
}
return fetch(input, init);
};
@@ -105,7 +110,7 @@ export const fetchPool = taskPool(
export const fetchData = async (
input,
init,
{ useCache, usePool, translator, useUnsafe, token } = {}
{ useCache, usePool, translator, token } = {}
) => {
const cacheReq = await newCacheReq(new Request(input, init));
const cache = await caches.open(CACHE_NAME);
@@ -123,9 +128,9 @@ export const fetchData = async (
if (!res) {
// 发送请求
if (usePool) {
res = await fetchPool.push({ input, init, useUnsafe, translator, token });
res = await fetchPool.push({ input, init, translator, token });
} else {
res = await fetchApi({ input, init, useUnsafe, translator, token });
res = await fetchApi({ input, init, translator, token });
}
if (!res?.ok) {

View File

@@ -6,8 +6,11 @@ import {
STOKEY_FAB,
GLOBLA_RULE,
GLOBAL_KEY,
DEFAULT_SUBRULES_LIST,
} from "../config";
import { browser } from "./browser";
import { isMatch } from "./utils";
import { tryLoadRules } from "./rules";
/**
* 获取节点列表并转为数组
@@ -47,14 +50,29 @@ export const setFab = async (obj) => await storage.setObj(STOKEY_FAB, obj);
/**
* 根据href匹配规则
* TODO: 支持通配符(*)匹配
* @param {*} rules
* @param {string} href
* @returns
*/
export const matchRule = (rules, href) => {
export const matchRule = async (
rules,
href,
{ injectRules, subrulesList = DEFAULT_SUBRULES_LIST }
) => {
if (injectRules) {
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) =>
rule.pattern.split(",").some((p) => href.includes(p.trim()))
rule.pattern.split(",").some((p) => isMatch(href, p.trim()))
);
const globalRule =
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

@@ -3,72 +3,91 @@ import {
DEFAULT_SYNC,
KV_SETTING_KEY,
KV_RULES_KEY,
KV_RULES_SHARE_KEY,
STOKEY_SETTING,
STOKEY_RULES,
KV_SALT_SHARE,
} from "../config";
import storage from "../libs/storage";
import { getSetting, getRules } from ".";
import { apiSyncData } from "../apis";
import { sha256 } from "./utils";
const loadOpt = async () => (await storage.getObj(STOKEY_SYNC)) || DEFAULT_SYNC;
export const loadSyncOpt = async () =>
(await storage.getObj(STOKEY_SYNC)) || DEFAULT_SYNC;
export const syncSetting = async () => {
const { syncUrl, syncKey, settingUpdateAt } = await loadOpt();
if (!syncUrl || !syncKey) {
return;
}
try {
const { syncUrl, syncKey, settingUpdateAt } = await loadSyncOpt();
if (!syncUrl || !syncKey) {
return;
}
const setting = await getSetting();
const res = await apiSyncData(syncUrl, syncKey, {
key: KV_SETTING_KEY,
value: setting,
updateAt: settingUpdateAt,
});
const setting = await getSetting();
const res = await apiSyncData(syncUrl, syncKey, {
key: KV_SETTING_KEY,
value: setting,
updateAt: settingUpdateAt,
});
if (res && res.updateAt > settingUpdateAt) {
await storage.putObj(STOKEY_SYNC, {
settingUpdateAt: res.updateAt,
settingSyncAt: res.updateAt,
});
await storage.setObj(STOKEY_SETTING, res.value);
} else {
await storage.putObj(STOKEY_SYNC, {
settingSyncAt: res.updateAt,
});
if (res && res.updateAt > settingUpdateAt) {
await storage.putObj(STOKEY_SYNC, {
settingUpdateAt: res.updateAt,
settingSyncAt: res.updateAt,
});
await storage.setObj(STOKEY_SETTING, res.value);
} else {
await storage.putObj(STOKEY_SYNC, {
settingSyncAt: res.updateAt,
});
}
} catch (err) {
console.log("[sync setting]", err);
}
};
export const syncRules = async () => {
const { syncUrl, syncKey, rulesUpdateAt } = await loadOpt();
if (!syncUrl || !syncKey) {
return;
}
try {
const { syncUrl, syncKey, rulesUpdateAt } = await loadSyncOpt();
if (!syncUrl || !syncKey) {
return;
}
const rules = await getRules();
const res = await apiSyncData(syncUrl, syncKey, {
key: KV_RULES_KEY,
const rules = await getRules();
const res = await apiSyncData(syncUrl, syncKey, {
key: KV_RULES_KEY,
value: rules,
updateAt: rulesUpdateAt,
});
if (res && res.updateAt > rulesUpdateAt) {
await storage.putObj(STOKEY_SYNC, {
rulesUpdateAt: res.updateAt,
rulesSyncAt: res.updateAt,
});
await storage.setObj(STOKEY_RULES, res.value);
} else {
await storage.putObj(STOKEY_SYNC, {
rulesSyncAt: res.updateAt,
});
}
} catch (err) {
console.log("[sync user rules]", err);
}
};
export const syncShareRules = async ({ rules, syncUrl, syncKey }) => {
await apiSyncData(syncUrl, syncKey, {
key: KV_RULES_SHARE_KEY,
value: rules,
updateAt: rulesUpdateAt,
updateAt: Date.now(),
});
if (res && res.updateAt > rulesUpdateAt) {
await storage.putObj(STOKEY_SYNC, {
rulesUpdateAt: res.updateAt,
rulesSyncAt: res.updateAt,
});
await storage.setObj(STOKEY_RULES, res.value);
} else {
await storage.putObj(STOKEY_SYNC, {
rulesSyncAt: res.updateAt,
});
}
const psk = await sha256(syncKey, KV_SALT_SHARE);
const shareUrl = `${syncUrl}?psk=${psk}`;
return shareUrl;
};
export const syncAll = async () => {
try {
await syncSetting();
await syncRules();
} catch (err) {
console.log("[sync all]", err);
}
await syncSetting();
await syncRules();
};

View File

@@ -5,8 +5,9 @@ import {
TRANS_MAX_LENGTH,
EVENT_KISS,
MSG_TRANS_CURRULE,
OPT_STYLE_DASHLINE,
OPT_STYLE_FUZZY,
} from "../config";
import { StoragesProvider } from "../hooks/Storage";
import { queryEls } from ".";
import Content from "../views/Content";
import { fetchUpdate, fetchClear } from "./fetch";
@@ -87,6 +88,14 @@ export class Translator {
}
};
toggleStyle = () => {
const textStyle =
this.rule.textStyle === OPT_STYLE_FUZZY
? OPT_STYLE_DASHLINE
: OPT_STYLE_FUZZY;
this.rule = { ...this.rule, textStyle };
};
_register = () => {
// 监听节点变化
this._mutaObserver.observe(document, {
@@ -144,10 +153,6 @@ export class Translator {
"-webkit-line-clamp: unset; max-height: none; height: auto;";
const root = createRoot(span);
root.render(
<StoragesProvider>
<Content q={q} translator={this} />
</StoragesProvider>
);
root.render(<Content q={q} translator={this} />);
};
}

View File

@@ -51,3 +51,63 @@ export const debounce = (func, delay = 200) => {
}, delay);
};
};
/**
* 字符串通配符(*)匹配
* @param {*} s
* @param {*} p
* @returns
*/
export const isMatch = (s, p) => {
if (s.length === 0 || p.length === 0) {
return false;
}
p = `*${p}*`;
let [sIndex, pIndex] = [0, 0];
let [sRecord, pRecord] = [-1, -1];
while (sIndex < s.length && pRecord < p.length) {
if (p[pIndex] === "*") {
pIndex++;
[sRecord, pRecord] = [sIndex, pIndex];
} else if (s[sIndex] === p[pIndex]) {
sIndex++;
pIndex++;
} else if (sRecord + 1 < s.length) {
sRecord++;
[sIndex, pIndex] = [sRecord, pRecord];
} else {
return false;
}
}
if (p.length === pIndex) {
return true;
}
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();
};
/**
* sha256
* @param {*} text
* @returns
*/
export const sha256 = async (text, salt) => {
const data = new TextEncoder().encode(text + salt);
const digest = await crypto.subtle.digest({ name: "SHA-256" }, data);
return [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
};

17
src/rules.js Normal file
View File

@@ -0,0 +1,17 @@
import fs from "fs";
import path from "path";
import { BUILTIN_RULES } from "./config/rules";
(() => {
try {
const data = JSON.stringify(BUILTIN_RULES, null, " ");
const file = path.resolve(
__dirname,
"../build/web/kiss-translator-rules.json"
);
fs.writeFileSync(file, data);
console.info(`Built-in rules generated: ${file}`);
} catch (err) {
console.error(err);
}
})();

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);
const rule = await matchRule(rules, document.location.href, setting);
const translator = new Translator(rule, setting);
// 浮球按钮
@@ -54,4 +54,20 @@ import { Translator } from "./libs/translator";
</CacheProvider>
</React.StrictMode>
);
// 注册菜单
GM.registerMenuCommand(
"Toggle Translate",
(event) => {
translator.toggle();
},
"Q"
);
GM.registerMenuCommand(
"Toggle Style",
(event) => {
translator.toggleStyle();
},
"C"
);
})();

View File

@@ -30,6 +30,8 @@ const getEdgePosition = (
edge = "top";
top = 0;
}
left = limitNumber(left, 0, windowWidth - width);
top = limitNumber(top, 0, windowHeight - height);
return { x: left, y: top, edge, hide: false };
};

View File

@@ -76,7 +76,7 @@ export default function Action({ translator, fab }) {
windowSize,
width: fabWidth,
height: fabWidth,
left: fab.x ?? windowSize.w - fabWidth,
left: fab.x ?? 0,
top: fab.y ?? windowSize.h / 2,
};
@@ -112,7 +112,9 @@ export default function Action({ translator, fab }) {
}
>
<Paper>
<Popup setShowPopup={setShowPopup} translator={translator} />
{showPopup && (
<Popup setShowPopup={setShowPopup} translator={translator} />
)}
</Paper>
</Draggable>
<Draggable

View File

@@ -6,7 +6,6 @@ import Box from "@mui/material/Box";
import Navigator from "./Navigator";
import Header from "./Header";
import { useTheme } from "@mui/material/styles";
import { syncAll } from "../../libs/sync";
export default function Layout() {
const navWidth = 256;
@@ -21,7 +20,6 @@ export default function Layout() {
useEffect(() => {
setOpen(false);
syncAll();
}, [location]);
return (

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,
@@ -10,7 +11,7 @@ import {
OPT_TRANS_ALL,
OPT_STYLE_ALL,
} 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";
@@ -22,6 +23,21 @@ import MenuItem from "@mui/material/MenuItem";
import Grid from "@mui/material/Grid";
import FileDownloadIcon from "@mui/icons-material/FileDownload";
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 ShareIcon from "@mui/icons-material/Share";
import SyncIcon from "@mui/icons-material/Sync";
import { useSubrules } from "../../hooks/Rules";
import { rulesCache, tryLoadRules } from "../../libs/rules";
import { useAlert } from "../../hooks/Alert";
import { loadSyncOpt, syncShareRules } from "../../libs/sync";
function RuleFields({ rule, rules, setShow }) {
const initFormValues = rule || { ...DEFAULT_RULE, transOpen: "true" };
@@ -122,6 +138,7 @@ function RuleFields({ rule, rules, setShow }) {
disabled={rule?.pattern === "*" || disabled}
onChange={handleChange}
onFocus={handleFocus}
multiline
/>
<TextField
size="small"
@@ -134,8 +151,6 @@ function RuleFields({ rule, rules, setShow }) {
onChange={handleChange}
onFocus={handleFocus}
multiline
minRows={2}
maxRows={10}
/>
<Box>
@@ -246,61 +261,91 @@ function RuleFields({ rule, rules, setShow }) {
</Grid>
</Box>
{editMode ? (
// 编辑
<Stack direction="row" spacing={2}>
{disabled ? (
<>
<Button
size="small"
variant="contained"
onClick={(e) => {
e.preventDefault();
setDisabled(false);
}}
>
{i18n("edit")}
</Button>
{rule?.pattern !== "*" && (
{rules &&
(editMode ? (
// 编辑
<Stack direction="row" spacing={2}>
{disabled ? (
<>
<Button
size="small"
variant="contained"
onClick={(e) => {
e.preventDefault();
setDisabled(false);
}}
>
{i18n("edit")}
</Button>
{rule?.pattern !== "*" && (
<Button
size="small"
variant="outlined"
onClick={(e) => {
e.preventDefault();
rules.del(rule.pattern);
}}
>
{i18n("delete")}
</Button>
)}
</>
) : (
<>
<Button size="small" variant="contained" type="submit">
{i18n("save")}
</Button>
<Button
size="small"
variant="outlined"
onClick={(e) => {
e.preventDefault();
rules.del(rule.pattern);
}}
onClick={handleCancel}
>
{i18n("delete")}
{i18n("cancel")}
</Button>
)}
</>
) : (
<>
<Button size="small" variant="contained" type="submit">
{i18n("save")}
</Button>
<Button size="small" variant="outlined" onClick={handleCancel}>
{i18n("cancel")}
</Button>
</>
)}
</Stack>
) : (
// 添加
<Stack direction="row" spacing={2}>
<Button size="small" variant="contained" type="submit">
{i18n("save")}
</Button>
<Button size="small" variant="outlined" onClick={handleCancel}>
{i18n("cancel")}
</Button>
</Stack>
)}
</>
)}
</Stack>
) : (
// 添加
<Stack direction="row" spacing={2}>
<Button size="small" variant="contained" type="submit">
{i18n("save")}
</Button>
<Button size="small" variant="outlined" onClick={handleCancel}>
{i18n("cancel")}
</Button>
</Stack>
))}
</Stack>
</form>
);
}
function RuleAccordion({ rule, rules }) {
const [expanded, setExpanded] = useState(false);
const handleChange = (e) => {
setExpanded((pre) => !pre);
};
return (
<Accordion expanded={expanded} onChange={handleChange}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography
style={{
opacity: rules ? 1 : 0.5,
}}
>
{rule.pattern}
</Typography>
</AccordionSummary>
<AccordionDetails>
{expanded && <RuleFields rule={rule} rules={rules} />}
</AccordionDetails>
</Accordion>
);
}
function DownloadButton({ data, text, fileName }) {
const handleClick = (e) => {
e.preventDefault();
@@ -351,10 +396,58 @@ function UploadButton({ onChange, text }) {
);
}
export default function Rules() {
function ShareButton({ rules, injectRules, selectedSub }) {
const alert = useAlert();
const i18n = useI18n();
const handleClick = async () => {
try {
const { syncUrl, syncKey } = await loadSyncOpt();
if (!syncUrl || !syncKey) {
alert.warning(i18n("error_sync_setting"));
return;
}
const shareRules = [...rules.list];
if (injectRules) {
const subRules = await tryLoadRules(selectedSub?.url);
shareRules.splice(-1, 0, ...subRules);
}
const url = await syncShareRules({
rules: shareRules,
syncUrl,
syncKey,
});
window.open(url, "_blank");
} catch (err) {
alert.warning(i18n("error_got_some_wrong"));
console.log("[share rules]", err);
}
};
return (
<Button
size="small"
variant="outlined"
onClick={handleClick}
startIcon={<ShareIcon />}
>
{"分享"}
</Button>
);
}
function UserRules() {
const i18n = useI18n();
const rules = useRules();
const [showAdd, setShowAdd] = useState(false);
const setting = useSetting();
const updateSetting = useSettingUpdate();
const subrules = useSubrules();
const selectedSub = subrules.list.find((item) => item.selected);
const injectRules = !!setting?.injectRules;
const handleImport = (e) => {
const file = e.target.files[0];
@@ -378,43 +471,281 @@ export default function Rules() {
reader.readAsText(file);
};
const handleInject = () => {
updateSetting({
injectRules: !injectRules,
});
};
return (
<Stack spacing={3}>
<Stack direction="row" spacing={2} useFlexGap flexWrap="wrap">
<Button
size="small"
variant="contained"
disabled={showAdd}
onClick={(e) => {
e.preventDefault();
setShowAdd(true);
}}
>
{i18n("add")}
</Button>
<UploadButton text={i18n("import")} onChange={handleImport} />
<DownloadButton
data={JSON.stringify([...rules.list].reverse(), null, "\t")}
text={i18n("export")}
/>
<ShareButton
rules={rules}
injectRules={injectRules}
selectedSub={selectedSub}
/>
<FormControlLabel
control={
<Switch
size="small"
checked={injectRules}
onChange={handleInject}
/>
}
label={i18n("inject_rules")}
/>
</Stack>
{showAdd && <RuleFields rules={rules} setShow={setShowAdd} />}
<Box>
{rules.list.map((rule) => (
<RuleAccordion key={rule.pattern} rule={rule} rules={rules} />
))}
</Box>
</Stack>
);
}
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 sub 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}>
<Stack direction="row" spacing={2}>
<Button
size="small"
variant="contained"
disabled={showAdd}
onClick={(e) => {
e.preventDefault();
setShowAdd(true);
}}
>
{i18n("add")}
</Button>
<UploadButton text={i18n("import")} onChange={handleImport} />
<DownloadButton
data={JSON.stringify([...rules.list].reverse(), null, "\t")}
text={i18n("export")}
/>
</Stack>
{showAdd && <RuleFields rules={rules} setShow={setShowAdd} />}
<Box>
{rules.list.map((rule) => (
<Accordion key={rule.pattern}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography>{rule.pattern}</Typography>
</AccordionSummary>
<AccordionDetails>
<RuleFields rule={rule} rules={rules} />
</AccordionDetails>
</Accordion>
))}
<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>
);

View File

@@ -6,15 +6,37 @@ import MenuItem from "@mui/material/MenuItem";
import FormControl from "@mui/material/FormControl";
import Select from "@mui/material/Select";
import { useSetting, useSettingUpdate } from "../../hooks/Setting";
import { limitNumber } from "../../libs/utils";
import { limitNumber, debounce } from "../../libs/utils";
import { useI18n } from "../../hooks/I18n";
import { UI_LANGS } from "../../config";
import { useMemo } from "react";
export default function Settings() {
const i18n = useI18n();
const setting = useSetting();
const updateSetting = useSettingUpdate();
const handleChange = useMemo(
() =>
debounce((e) => {
e.preventDefault();
let { name, value } = e.target;
switch (name) {
case "fetchLimit":
value = limitNumber(value, 1, 100);
break;
case "fetchInterval":
value = limitNumber(value, 0, 5000);
break;
default:
}
updateSetting({
[name]: value,
});
}, 500),
[updateSetting]
);
if (!setting) {
return;
}
@@ -37,13 +59,10 @@ export default function Settings() {
<FormControl size="small">
<InputLabel>{i18n("ui_lang")}</InputLabel>
<Select
name="uiLang"
value={uiLang}
label={i18n("ui_lang")}
onChange={(e) => {
updateSetting({
uiLang: e.target.value,
});
}}
onChange={handleChange}
>
{UI_LANGS.map(([lang, name]) => (
<MenuItem key={lang} value={lang}>
@@ -57,36 +76,27 @@ export default function Settings() {
size="small"
label={i18n("fetch_limit")}
type="number"
name="fetchLimit"
defaultValue={fetchLimit}
onChange={(e) => {
updateSetting({
fetchLimit: limitNumber(e.target.value, 1, 100),
});
}}
onChange={handleChange}
/>
<TextField
size="small"
label={i18n("fetch_interval")}
type="number"
name="fetchInterval"
defaultValue={fetchInterval}
onChange={(e) => {
updateSetting({
fetchInterval: limitNumber(e.target.value, 0, 5000),
});
}}
onChange={handleChange}
/>
<FormControl size="small">
<InputLabel>{i18n("clear_cache")}</InputLabel>
<Select
name="clearCache"
value={clearCache}
label={i18n("clear_cache")}
onChange={(e) => {
updateSetting({
clearCache: e.target.value,
});
}}
onChange={handleChange}
>
<MenuItem value={false}>{i18n("clear_cache_never")}</MenuItem>
<MenuItem value={true}>{i18n("clear_cache_restart")}</MenuItem>
@@ -96,60 +106,43 @@ export default function Settings() {
<TextField
size="small"
label={i18n("google_api")}
name="googleUrl"
defaultValue={googleUrl}
onChange={(e) => {
updateSetting({
googleUrl: e.target.value,
});
}}
onChange={handleChange}
/>
<TextField
size="small"
label={i18n("openai_api")}
name="openaiUrl"
defaultValue={openaiUrl}
onChange={(e) => {
updateSetting({
openaiUrl: e.target.value,
});
}}
onChange={handleChange}
/>
<TextField
size="small"
type="password"
label={i18n("openai_key")}
name="openaiKey"
defaultValue={openaiKey}
onChange={(e) => {
updateSetting({
openaiKey: e.target.value,
});
}}
onChange={handleChange}
/>
<TextField
size="small"
label={i18n("openai_model")}
name="openaiModel"
defaultValue={openaiModel}
onChange={(e) => {
updateSetting({
openaiModel: e.target.value,
});
}}
onChange={handleChange}
/>
<TextField
size="small"
label={i18n("openai_prompt")}
name="openaiPrompt"
defaultValue={openaiPrompt}
onChange={(e) => {
updateSetting({
openaiPrompt: e.target.value,
});
}}
onChange={handleChange}
multiline
minRows={2}
maxRows={10}
/>
</Stack>
</Box>

View File

@@ -3,23 +3,35 @@ import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import { useI18n } from "../../hooks/I18n";
import { useSync } from "../../hooks/Sync";
import { syncAll } from "../../libs/sync";
import Alert from "@mui/material/Alert";
import Link from "@mui/material/Link";
import { URL_KISS_WORKER } from "../../config";
import { debounce } from "../../libs/utils";
import { useMemo } from "react";
import { syncAll } from "../../libs/sync";
export default function SyncSetting() {
const i18n = useI18n();
const sync = useSync();
const handleChange = useMemo(
() =>
debounce(async (e) => {
e.preventDefault();
const { name, value } = e.target;
await sync.update({
[name]: value,
});
await syncAll();
}, 1000),
[sync]
);
if (!sync.opt) {
return;
}
const { syncUrl, syncKey } = sync.opt;
const handleSyncBlur = () => {
syncAll();
};
return (
<Box>
@@ -29,13 +41,9 @@ export default function SyncSetting() {
<TextField
size="small"
label={i18n("data_sync_url")}
name="syncUrl"
defaultValue={syncUrl}
onChange={(e) => {
sync.update({
syncUrl: e.target.value,
});
}}
onBlur={handleSyncBlur}
onChange={handleChange}
helperText={
<Link href={URL_KISS_WORKER}>{i18n("about_sync_api")}</Link>
}
@@ -45,13 +53,9 @@ export default function SyncSetting() {
size="small"
type="password"
label={i18n("data_sync_key")}
name="syncKey"
defaultValue={syncKey}
onChange={(e) => {
sync.update({
syncKey: e.target.value,
});
}}
onBlur={handleSyncBlur}
onChange={handleChange}
/>
</Stack>
</Box>

View File

@@ -10,31 +10,35 @@ import { useEffect, useState } from "react";
import { isGm } from "../../libs/browser";
import { sleep } from "../../libs/utils";
import CircularProgress from "@mui/material/CircularProgress";
import { syncAll } from "../../libs/sync";
import { AlertProvider } from "../../hooks/Alert";
export default function Options() {
const [error, setError] = useState(false);
const [ready, setReady] = useState(false);
useEffect(() => {
if (!isGm) {
return;
}
(async () => {
let i = 0;
for (;;) {
if (window.APP_NAME === process.env.REACT_APP_NAME) {
setReady(true);
break;
}
if (isGm) {
// 等待GM注入
let i = 0;
for (;;) {
if (window.APP_NAME === process.env.REACT_APP_NAME) {
setReady(true);
break;
}
if (++i > 8) {
setError(true);
break;
}
if (++i > 8) {
setError(true);
break;
}
await sleep(1000);
await sleep(1000);
}
}
// 同步数据
syncAll();
})();
}, []);
@@ -66,16 +70,18 @@ export default function Options() {
return (
<StoragesProvider>
<ThemeProvider>
<HashRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Setting />} />
<Route path="rules" element={<Rules />} />
<Route path="sync" element={<SyncSetting />} />
<Route path="about" element={<About />} />
</Route>
</Routes>
</HashRouter>
<AlertProvider>
<HashRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Setting />} />
<Route path="rules" element={<Rules />} />
<Route path="sync" element={<SyncSetting />} />
<Route path="about" element={<About />} />
</Route>
</Routes>
</HashRouter>
</AlertProvider>
</ThemeProvider>
</StoragesProvider>
);

4651
yarn.lock

File diff suppressed because it is too large Load Diff