Compare commits

..

3 Commits

Author SHA1 Message Date
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
21 changed files with 671 additions and 128 deletions

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.6
REACT_APP_VERSION=1.5.0
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

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

@@ -1,7 +1,7 @@
{
"name": "kiss-translator",
"description": "A minimalist bilingual translation Extension & Greasemonkey Script",
"version": "1.4.6",
"version": "1.5.0",
"author": "Gabe<yugang2002@gmail.com>",
"private": true,
"dependencies": {

View File

@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_app_name__",
"description": "__MSG_app_description__",
"version": "1.4.6",
"version": "1.5.0",
"default_locale": "en",
"author": "Gabe<yugang2002@gmail.com>",
"homepage_url": "https://github.com/fishjar/kiss-translator",

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "__MSG_app_name__",
"description": "__MSG_app_description__",
"version": "1.4.6",
"version": "1.5.0",
"default_locale": "en",
"author": "Gabe<yugang2002@gmail.com>",
"homepage_url": "https://github.com/fishjar/kiss-translator",

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";
/**
* 同步数据
@@ -25,7 +26,7 @@ export const apiSyncData = async (url, key, data) =>
{
headers: {
"Content-type": "application/json",
[KV_HEADER_KEY]: key,
Authorization: `Bearer ${await sha256(key, KV_SALT_SYNC)}`,
},
method: "POST",
body: JSON.stringify(data),

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

@@ -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";
@@ -24,9 +25,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`;
@@ -147,13 +150,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);
// 监听消息

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

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

@@ -3,18 +3,22 @@ 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 () => {
try {
const { syncUrl, syncKey, settingUpdateAt } = await loadOpt();
const { syncUrl, syncKey, settingUpdateAt } = await loadSyncOpt();
if (!syncUrl || !syncKey) {
return;
}
@@ -44,7 +48,7 @@ export const syncSetting = async () => {
export const syncRules = async () => {
try {
const { syncUrl, syncKey, rulesUpdateAt } = await loadOpt();
const { syncUrl, syncKey, rulesUpdateAt } = await loadSyncOpt();
if (!syncUrl || !syncKey) {
return;
}
@@ -72,6 +76,17 @@ export const syncRules = async () => {
}
};
export const syncShareRules = async ({ rules, syncUrl, syncKey }) => {
await apiSyncData(syncUrl, syncKey, {
key: KV_RULES_SHARE_KEY,
value: rules,
updateAt: Date.now(),
});
const psk = await sha256(syncKey, KV_SALT_SHARE);
const shareUrl = `${syncUrl}?psk=${psk}`;
return shareUrl;
};
export const syncAll = async () => {
await syncSetting();
await syncRules();

View File

@@ -88,3 +88,26 @@ 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();
};
/**
* 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("");
};

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,18 @@ 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" };
@@ -384,12 +396,57 @@ 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) => {
@@ -420,55 +477,275 @@ export default function Rules() {
});
};
return (
<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")}
/>
<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 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")}
/>
<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 sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs value={activeTab} onChange={handleTabChange}>
<Tab label={i18n("edit_rules")} />
<Tab label={i18n("subscribe_rules")} />
</Tabs>
</Box>
{injectRules && (
<Box>
{BUILTIN_RULES.map((rule) => (
<RuleAccordion key={rule.pattern} rule={rule} />
))}
</Box>
)}
<div hidden={activeTab !== 0}>{activeTab === 0 && <UserRules />}</div>
<div hidden={activeTab !== 1}>{activeTab === 1 && <SubRules />}</div>
</Stack>
</Box>
);

View File

@@ -8,6 +8,7 @@ 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();
@@ -15,13 +16,14 @@ export default function SyncSetting() {
const handleChange = useMemo(
() =>
debounce((e) => {
debounce(async (e) => {
e.preventDefault();
const { name, value } = e.target;
sync.update({
await sync.update({
[name]: value,
});
}, 500),
await syncAll();
}, 1000),
[sync]
);

View File

@@ -11,6 +11,7 @@ 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);
@@ -69,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>
);