feat: Extensive refactoring and modification to support any number of interfaces

This commit is contained in:
Gabe
2025-09-24 23:24:00 +08:00
parent 779c9fc850
commit 2a46939aa5
65 changed files with 2054 additions and 1947 deletions

View File

@@ -28,12 +28,12 @@ const MsgHistory = (maxSize = DEFAULT_CONTEXT_SIZE) => {
};
};
export const getMsgHistory = (translator, maxSize) => {
if (historyMap.has(translator)) {
return historyMap.get(translator);
export const getMsgHistory = (apiSlug, maxSize) => {
if (historyMap.has(apiSlug)) {
return historyMap.get(apiSlug);
}
const msgHistory = MsgHistory(maxSize);
historyMap.set(translator, msgHistory);
historyMap.set(apiSlug, msgHistory);
return msgHistory;
};

View File

@@ -7,7 +7,8 @@ import {
OPT_LANGS_TENCENT,
OPT_LANGS_SPECIAL,
OPT_LANGS_MICROSOFT,
OPT_TRANS_BATCH,
API_SPE_TYPES,
DEFAULT_API_SETTING,
} from "../config";
import { sha256 } from "../libs/utils";
import { msAuth } from "../libs/auth";
@@ -200,11 +201,10 @@ export const apiTencentLangdetect = async (text) => {
* @returns
*/
export const apiTranslate = async ({
translator,
text,
fromLang,
toLang,
apiSetting = {},
apiSetting = DEFAULT_API_SETTING,
docInfo = {},
useCache = true,
usePool = true,
@@ -213,23 +213,23 @@ export const apiTranslate = async ({
return ["", false];
}
const { apiType, apiSlug, useBatchFetch } = apiSetting;
const from =
OPT_LANGS_SPECIAL[translator].get(fromLang) ??
OPT_LANGS_SPECIAL[translator].get("auto");
const to = OPT_LANGS_SPECIAL[translator].get(toLang);
OPT_LANGS_SPECIAL[apiType].get(fromLang) ??
OPT_LANGS_SPECIAL[apiType].get("auto");
const to = OPT_LANGS_SPECIAL[apiType].get(toLang);
if (!to) {
kissLog(`target lang: ${toLang} not support`, "translate");
kissLog(`target lang: ${toLang} not support`);
return ["", false];
}
// TODO: 优化缓存失效因素
// todo: 优化缓存失效因素
const [v1, v2] = process.env.REACT_APP_VERSION.split(".");
const cacheOpts = {
translator,
apiSlug,
text,
fromLang,
toLang,
model: apiSetting.model, // model改变缓存失效
version: [v1, v2].join("."),
};
const cacheInput = `${URL_CACHE_TRAN}?${queryString.stringify(cacheOpts)}`;
@@ -245,26 +245,21 @@ export const apiTranslate = async ({
// 请求接口数据
let trText = "";
let srLang = "";
if (apiSetting.useBatchFetch && OPT_TRANS_BATCH.has(translator)) {
const queue = getBatchQueue(
{
translator,
from,
to,
docInfo,
apiSetting,
usePool,
taskFn: handleTranslate,
},
apiSetting
);
if (useBatchFetch && API_SPE_TYPES.batch.has(apiType)) {
const queue = getBatchQueue({
from,
to,
docInfo,
apiSetting,
usePool,
taskFn: handleTranslate,
});
const tranlation = await queue.addTask({ text });
if (Array.isArray(tranlation)) {
[trText, srLang = ""] = tranlation;
}
} else {
const translations = await handleTranslate({
translator,
texts: [text],
from,
to,
@@ -281,7 +276,7 @@ export const apiTranslate = async ({
// 插入缓存
if (useCache && trText) {
await putHttpCachePolyfill(cacheInput, null, { trText, isSame, srLang });
putHttpCachePolyfill(cacheInput, null, { trText, isSame, srLang });
}
return [trText, isSame];

View File

@@ -11,25 +11,17 @@ import {
OPT_TRANS_TENCENT,
OPT_TRANS_VOLCENGINE,
OPT_TRANS_OPENAI,
OPT_TRANS_OPENAI_2,
OPT_TRANS_OPENAI_3,
OPT_TRANS_GEMINI,
OPT_TRANS_GEMINI_2,
OPT_TRANS_CLAUDE,
OPT_TRANS_CLOUDFLAREAI,
OPT_TRANS_OLLAMA,
OPT_TRANS_OLLAMA_2,
OPT_TRANS_OLLAMA_3,
OPT_TRANS_OPENROUTER,
OPT_TRANS_CUSTOMIZE,
OPT_TRANS_CUSTOMIZE_2,
OPT_TRANS_CUSTOMIZE_3,
OPT_TRANS_CUSTOMIZE_4,
OPT_TRANS_CUSTOMIZE_5,
OPT_TRANS_CONTEXT,
API_SPE_TYPES,
INPUT_PLACE_FROM,
INPUT_PLACE_TO,
INPUT_PLACE_TEXT,
// INPUT_PLACE_TEXT,
INPUT_PLACE_KEY,
INPUT_PLACE_MODEL,
} from "../config";
@@ -46,7 +38,7 @@ const keyMap = new Map();
const urlMap = new Map();
// 轮询key/url
const keyPick = (translator, key = "", cacheMap) => {
const keyPick = (apiSlug, key = "", cacheMap) => {
const keys = key
.split(/\n|,/)
.map((item) => item.trim())
@@ -56,9 +48,9 @@ const keyPick = (translator, key = "", cacheMap) => {
return "";
}
const preIndex = cacheMap.get(translator) ?? -1;
const preIndex = cacheMap.get(apiSlug) ?? -1;
const curIndex = (preIndex + 1) % keys.length;
cacheMap.set(translator, curIndex);
cacheMap.set(apiSlug, curIndex);
return keys[curIndex];
};
@@ -68,20 +60,30 @@ const genSystemPrompt = ({ systemPrompt, from, to }) =>
.replaceAll(INPUT_PLACE_FROM, from)
.replaceAll(INPUT_PLACE_TO, to);
const genUserPrompt = ({ userPrompt, from, to, texts, docInfo }) => {
const genUserPrompt = ({
// userPrompt,
tone,
glossary = {},
// from,
to,
texts,
docInfo,
}) => {
const prompt = JSON.stringify({
targetLanguage: to,
title: docInfo.title,
description: docInfo.description,
segments: texts.map((text, i) => ({ id: i, text })),
glossary,
tone,
});
if (userPrompt.includes(INPUT_PLACE_TEXT)) {
return userPrompt
.replaceAll(INPUT_PLACE_FROM, from)
.replaceAll(INPUT_PLACE_TO, to)
.replaceAll(INPUT_PLACE_TEXT, prompt);
}
// if (userPrompt.includes(INPUT_PLACE_TEXT)) {
// return userPrompt
// .replaceAll(INPUT_PLACE_FROM, from)
// .replaceAll(INPUT_PLACE_TO, to)
// .replaceAll(INPUT_PLACE_TEXT, prompt);
// }
return prompt;
};
@@ -93,15 +95,19 @@ const parseAIRes = (raw) => {
const jsonString = extractJson(raw);
data = JSON.parse(jsonString);
} catch (err) {
kissLog(err, "parseAIRes");
data = { translations: [] };
kissLog("parseAIRes", err);
return [];
}
if (!Array.isArray(data.translations)) {
data.translations = [];
return [];
}
return data.translations.map((item) => [item.text]);
// todo: 考虑序号id可能会打乱
return data.translations.map((item) => [
item?.text ?? "",
item?.sourceLanguage ?? "",
]);
};
const genGoogle = ({ texts, from, to, url, key }) => {
@@ -675,35 +681,27 @@ const genCustom = ({
* @param {*}
* @returns
*/
export const genTransReq = (translator, args) => {
switch (translator) {
export const genTransReq = ({ apiType, apiSlug, ...args }) => {
switch (apiType) {
case OPT_TRANS_DEEPL:
case OPT_TRANS_OPENAI:
case OPT_TRANS_OPENAI_2:
case OPT_TRANS_OPENAI_3:
case OPT_TRANS_GEMINI:
case OPT_TRANS_GEMINI_2:
case OPT_TRANS_CLAUDE:
case OPT_TRANS_CLOUDFLAREAI:
case OPT_TRANS_OLLAMA:
case OPT_TRANS_OLLAMA_2:
case OPT_TRANS_OLLAMA_3:
case OPT_TRANS_OPENROUTER:
case OPT_TRANS_NIUTRANS:
case OPT_TRANS_CUSTOMIZE:
case OPT_TRANS_CUSTOMIZE_2:
case OPT_TRANS_CUSTOMIZE_3:
case OPT_TRANS_CUSTOMIZE_4:
case OPT_TRANS_CUSTOMIZE_5:
args.key = keyPick(translator, args.key, keyMap);
args.key = keyPick(apiSlug, args.key, keyMap);
break;
case OPT_TRANS_DEEPLX:
args.url = keyPick(translator, args.url, urlMap);
args.url = keyPick(apiSlug, args.url, urlMap);
break;
default:
}
switch (translator) {
switch (apiType) {
case OPT_TRANS_GOOGLE:
return genGoogle(args);
case OPT_TRANS_GOOGLE_2:
@@ -725,8 +723,6 @@ export const genTransReq = (translator, args) => {
case OPT_TRANS_VOLCENGINE:
return genVolcengine(args);
case OPT_TRANS_OPENAI:
case OPT_TRANS_OPENAI_2:
case OPT_TRANS_OPENAI_3:
return genOpenAI(args);
case OPT_TRANS_GEMINI:
return genGemini(args);
@@ -737,37 +733,29 @@ export const genTransReq = (translator, args) => {
case OPT_TRANS_CLOUDFLAREAI:
return genCloudflareAI(args);
case OPT_TRANS_OLLAMA:
case OPT_TRANS_OLLAMA_2:
case OPT_TRANS_OLLAMA_3:
return genOllama(args);
case OPT_TRANS_OPENROUTER:
return genOpenRouter(args);
case OPT_TRANS_CUSTOMIZE:
case OPT_TRANS_CUSTOMIZE_2:
case OPT_TRANS_CUSTOMIZE_3:
case OPT_TRANS_CUSTOMIZE_4:
case OPT_TRANS_CUSTOMIZE_5:
return genCustom(args);
default:
throw new Error(`[trans] translator: ${translator} not support`);
throw new Error(`[trans] ${apiType} not support`);
}
};
/**
* 解析翻译接口返回数据
* @param {*} translator
* @param {*} res
* @param {*} param3
* @returns
*/
export const parseTransRes = (
translator,
res,
{ texts, from, to, resHook, thinkIgnore, history, userMsg }
{ texts, from, to, resHook, thinkIgnore, history, userMsg, apiType }
) => {
let modelMsg = "";
switch (translator) {
switch (apiType) {
case OPT_TRANS_GOOGLE:
return [[res?.sentences?.map((item) => item.trans).join(" "), res?.src]];
case OPT_TRANS_GOOGLE_2:
@@ -814,8 +802,6 @@ export const parseTransRes = (
case OPT_TRANS_VOLCENGINE:
return [[res?.translation, res?.detected_language]];
case OPT_TRANS_OPENAI:
case OPT_TRANS_OPENAI_2:
case OPT_TRANS_OPENAI_3:
case OPT_TRANS_GEMINI_2:
case OPT_TRANS_OPENROUTER:
modelMsg = res?.choices?.[0]?.message;
@@ -844,8 +830,6 @@ export const parseTransRes = (
case OPT_TRANS_CLOUDFLAREAI:
return [[res?.result?.translated_text]];
case OPT_TRANS_OLLAMA:
case OPT_TRANS_OLLAMA_2:
case OPT_TRANS_OLLAMA_3:
modelMsg = res?.choices?.[0]?.message;
const deepModels = thinkIgnore.split(",").filter((model) => model.trim());
@@ -861,10 +845,6 @@ export const parseTransRes = (
}
return parseAIRes(modelMsg?.content);
case OPT_TRANS_CUSTOMIZE:
case OPT_TRANS_CUSTOMIZE_2:
case OPT_TRANS_CUSTOMIZE_3:
case OPT_TRANS_CUSTOMIZE_4:
case OPT_TRANS_CUSTOMIZE_5:
if (resHook?.trim()) {
interpreter.run(`exports.resHook = ${resHook}`);
if (history) {
@@ -894,7 +874,6 @@ export const parseTransRes = (
* @returns
*/
export const handleTranslate = async ({
translator,
texts,
from,
to,
@@ -904,12 +883,21 @@ export const handleTranslate = async ({
}) => {
let history = null;
let hisMsgs = [];
if (apiSetting.useContext && OPT_TRANS_CONTEXT.has(translator)) {
history = getMsgHistory(translator, apiSetting.contextSize);
const {
apiType,
apiSlug,
contextSize,
useContext,
fetchInterval,
fetchLimit,
httpTimeout,
} = apiSetting;
if (useContext && API_SPE_TYPES.context.has(apiType)) {
history = getMsgHistory(apiSlug, contextSize);
hisMsgs = history.getAll();
}
const [input, init, userMsg] = await genTransReq(translator, {
const [input, init, userMsg] = await genTransReq({
texts,
from,
to,
@@ -921,15 +909,15 @@ export const handleTranslate = async ({
const res = await fetchData(input, init, {
useCache: false,
usePool,
fetchInterval: apiSetting.fetchInterval,
fetchLimit: apiSetting.fetchLimit,
httpTimeout: apiSetting.httpTimeout,
fetchInterval,
fetchLimit,
httpTimeout,
});
if (!res) {
throw new Error("tranlate got empty response");
}
return parseTransRes(translator, res, {
return parseTransRes(res, {
texts,
from,
to,

View File

@@ -49,7 +49,7 @@ async function addContextMenus(contextMenuType = 1) {
try {
await browser.contextMenus.removeAll();
} catch (err) {
kissLog(err, "remove contextMenus");
kissLog("remove contextMenus", err);
}
switch (contextMenuType) {
@@ -122,7 +122,7 @@ async function updateCspRules(csplist = DEFAULT_CSPLIST.join(",\n")) {
addRules: newRules,
});
} catch (err) {
kissLog(err, "update csp rules");
kissLog("update csp rules", err);
}
}

View File

@@ -26,22 +26,16 @@ export const OPT_TRANS_BAIDU = "Baidu";
export const OPT_TRANS_TENCENT = "Tencent";
export const OPT_TRANS_VOLCENGINE = "Volcengine";
export const OPT_TRANS_OPENAI = "OpenAI";
export const OPT_TRANS_OPENAI_2 = "OpenAI2";
export const OPT_TRANS_OPENAI_3 = "OpenAI3";
export const OPT_TRANS_GEMINI = "Gemini";
export const OPT_TRANS_GEMINI_2 = "Gemini2";
export const OPT_TRANS_CLAUDE = "Claude";
export const OPT_TRANS_CLOUDFLAREAI = "CloudflareAI";
export const OPT_TRANS_OLLAMA = "Ollama";
export const OPT_TRANS_OLLAMA_2 = "Ollama2";
export const OPT_TRANS_OLLAMA_3 = "Ollama3";
export const OPT_TRANS_OPENROUTER = "OpenRouter";
export const OPT_TRANS_CUSTOMIZE = "Custom";
export const OPT_TRANS_CUSTOMIZE_2 = "Custom2";
export const OPT_TRANS_CUSTOMIZE_3 = "Custom3";
export const OPT_TRANS_CUSTOMIZE_4 = "Custom4";
export const OPT_TRANS_CUSTOMIZE_5 = "Custom5";
export const OPT_TRANS_ALL = [
// 内置支持的翻译引擎
export const OPT_ALL_TYPES = [
OPT_TRANS_GOOGLE,
OPT_TRANS_GOOGLE_2,
OPT_TRANS_MICROSOFT,
@@ -53,64 +47,74 @@ export const OPT_TRANS_ALL = [
OPT_TRANS_DEEPLX,
OPT_TRANS_NIUTRANS,
OPT_TRANS_OPENAI,
OPT_TRANS_OPENAI_2,
OPT_TRANS_OPENAI_3,
OPT_TRANS_GEMINI,
OPT_TRANS_GEMINI_2,
OPT_TRANS_CLAUDE,
OPT_TRANS_CLOUDFLAREAI,
OPT_TRANS_OLLAMA,
OPT_TRANS_OLLAMA_2,
OPT_TRANS_OLLAMA_3,
OPT_TRANS_OPENROUTER,
OPT_TRANS_CUSTOMIZE,
OPT_TRANS_CUSTOMIZE_2,
OPT_TRANS_CUSTOMIZE_3,
OPT_TRANS_CUSTOMIZE_4,
OPT_TRANS_CUSTOMIZE_5,
];
// 可使用批处理的翻译引擎
export const OPT_TRANS_BATCH = new Set([
OPT_TRANS_GOOGLE_2,
OPT_TRANS_MICROSOFT,
OPT_TRANS_TENCENT,
OPT_TRANS_DEEPL,
OPT_TRANS_OPENAI,
OPT_TRANS_OPENAI_2,
OPT_TRANS_OPENAI_3,
OPT_TRANS_GEMINI,
OPT_TRANS_GEMINI_2,
OPT_TRANS_CLAUDE,
OPT_TRANS_OLLAMA,
OPT_TRANS_OLLAMA_2,
OPT_TRANS_OLLAMA_3,
OPT_TRANS_OPENROUTER,
OPT_TRANS_CUSTOMIZE,
OPT_TRANS_CUSTOMIZE_2,
OPT_TRANS_CUSTOMIZE_3,
OPT_TRANS_CUSTOMIZE_4,
OPT_TRANS_CUSTOMIZE_5,
]);
// 可使用上下文的翻译引擎
export const OPT_TRANS_CONTEXT = new Set([
OPT_TRANS_OPENAI,
OPT_TRANS_OPENAI_2,
OPT_TRANS_OPENAI_3,
OPT_TRANS_GEMINI,
OPT_TRANS_GEMINI_2,
OPT_TRANS_CLAUDE,
OPT_TRANS_OLLAMA,
OPT_TRANS_OLLAMA_2,
OPT_TRANS_OLLAMA_3,
OPT_TRANS_OPENROUTER,
OPT_TRANS_CUSTOMIZE,
OPT_TRANS_CUSTOMIZE_2,
OPT_TRANS_CUSTOMIZE_3,
OPT_TRANS_CUSTOMIZE_4,
OPT_TRANS_CUSTOMIZE_5,
]);
// 翻译引擎特殊集合
export const API_SPE_TYPES = {
// 内置翻译
builtin: new Set(OPT_ALL_TYPES),
// 机器翻译
machine: new Set([
OPT_TRANS_MICROSOFT,
OPT_TRANS_DEEPLFREE,
OPT_TRANS_BAIDU,
OPT_TRANS_TENCENT,
OPT_TRANS_VOLCENGINE,
]),
// AI翻译
ai: new Set([
OPT_TRANS_OPENAI,
OPT_TRANS_GEMINI,
OPT_TRANS_GEMINI_2,
OPT_TRANS_CLAUDE,
OPT_TRANS_OLLAMA,
OPT_TRANS_OPENROUTER,
]),
// 支持多key
mulkeys: new Set([
OPT_TRANS_DEEPL,
OPT_TRANS_OPENAI,
OPT_TRANS_GEMINI,
OPT_TRANS_GEMINI_2,
OPT_TRANS_CLAUDE,
OPT_TRANS_CLOUDFLAREAI,
OPT_TRANS_OLLAMA,
OPT_TRANS_OPENROUTER,
OPT_TRANS_NIUTRANS,
OPT_TRANS_CUSTOMIZE,
]),
// 支持批处理
batch: new Set([
OPT_TRANS_GOOGLE_2,
OPT_TRANS_MICROSOFT,
OPT_TRANS_TENCENT,
OPT_TRANS_DEEPL,
OPT_TRANS_OPENAI,
OPT_TRANS_GEMINI,
OPT_TRANS_GEMINI_2,
OPT_TRANS_CLAUDE,
OPT_TRANS_OLLAMA,
OPT_TRANS_OPENROUTER,
OPT_TRANS_CUSTOMIZE,
]),
// 支持上下文
context: new Set([
OPT_TRANS_OPENAI,
OPT_TRANS_GEMINI,
OPT_TRANS_GEMINI_2,
OPT_TRANS_CLAUDE,
OPT_TRANS_OLLAMA,
OPT_TRANS_OPENROUTER,
OPT_TRANS_CUSTOMIZE,
]),
};
export const OPT_LANGDETECTOR_ALL = [
OPT_TRANS_GOOGLE,
@@ -119,6 +123,24 @@ export const OPT_LANGDETECTOR_ALL = [
OPT_TRANS_TENCENT,
];
export const BUILTIN_STONES = [
"formal", // 正式风格
"casual", // 口语风格
"neutral", // 中性风格
"technical", // 技术风格
"marketing", // 营销风格
"Literary", // 文学风格
"academic", // 学术风格
"legal", // 法律风格
"literal", // 直译风格
"ldiomatic", // 意译风格
"transcreation", // 创译风格
"machine-like", // 机器风格
"concise", // 简明风格
];
export const BUILTIN_PLACEHOULDERS = ["{ }", "{{ }}", "[ ]", "[[ ]]"];
export const BUILTIN_TAG_NAMES = ["i", "a", "span"];
export const OPT_LANGS_TO = [
["en", "English - English"],
["zh-CN", "Simplified Chinese - 简体中文"],
@@ -250,12 +272,6 @@ export const OPT_LANGS_SPECIAL = {
[OPT_TRANS_OPENAI]: new Map(
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
),
[OPT_TRANS_OPENAI_2]: new Map(
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
),
[OPT_TRANS_OPENAI_3]: new Map(
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
),
[OPT_TRANS_GEMINI]: new Map(
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
),
@@ -268,12 +284,6 @@ export const OPT_LANGS_SPECIAL = {
[OPT_TRANS_OLLAMA]: new Map(
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
),
[OPT_TRANS_OLLAMA_2]: new Map(
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
),
[OPT_TRANS_OLLAMA_3]: new Map(
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
),
[OPT_TRANS_OPENROUTER]: new Map(
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
),
@@ -294,18 +304,6 @@ export const OPT_LANGS_SPECIAL = {
[OPT_TRANS_CUSTOMIZE]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key]),
]),
[OPT_TRANS_CUSTOMIZE_2]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key]),
]),
[OPT_TRANS_CUSTOMIZE_3]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key]),
]),
[OPT_TRANS_CUSTOMIZE_4]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key]),
]),
[OPT_TRANS_CUSTOMIZE_5]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key]),
]),
};
export const OPT_LANGS_LIST = OPT_LANGS_TO.map(([lang]) => lang);
export const OPT_LANGS_MICROSOFT = new Map(
@@ -328,37 +326,44 @@ export const OPT_LANGS_TENCENT = new Map(
);
OPT_LANGS_TENCENT.set("zh", "zh-CN");
// 翻译接口
const defaultSystemPrompt = `Act as a translation API. Output a single raw JSON object only. No extra text or fences.
Input:
{"targetLanguage":"<lang>","title":"<context>","description":"<context>","segments":[{"id":1,"text":"..."}],"glossary":{"sourceTerm":"targetTerm"},"tone":"<formal|casual>"}
Output:
{"translations":[{"id":1,"text":"...","sourceLanguage":"<detected>"}]}
Rules:
1. Use title/description for context only; do not output them.
2. Keep id, order, and count of segments.
3. Preserve whitespace, HTML entities, and all HTML-like tags (e.g., <i1>, <a1>). Translate inner text only.
4. Highest priority: Follow 'glossary'. Use value for translation; if value is "", keep the key.
5. Do not translate: content in <code>, <pre>, text enclosed in backticks, or placeholders like {1}, {{1}}, [1], [[1]].
6. Apply the specified tone to the translation.
7. Detect sourceLanguage for each segment.
8. Return empty or unchanged inputs as is.
Example:
Input: {"targetLanguage":"zh-CN","segments":[{"id":1,"text":"A <b>React</b> component."}],"glossary":{"component":"组件","React":""}}
Output: {"translations":[{"id":1,"text":"一个<b>React</b>组件","sourceLanguage":"en"}]}
Fail-safe: On any error, return {"translations":[]}.`;
// 翻译接口默认参数
const defaultApi = {
apiSlug: "", // 唯一标识
apiName: "", // 接口名称
apiType: "", // 接口类型
url: "",
key: "",
model: "", // 模型名称
systemPrompt: `You are a translation API.
Output:
- Return one raw JSON object only.
- Start with "{" and end with "}".
- No fences or extra text.
Input JSON:
{"targetLanguage":"<lang>","title":"<title>","description":"<desc>","segments":[{"id":1,"text":"..."}]}
Output JSON:
{"translations":[{"id":1,"text":"...","sourceLanguage":"<detected-language>"}]}
Rules:
1. Use title/description as context only, do not output them.
2. Keep ids/order/count.
3. Translate inner text only, not HTML tags.
4. Do not translate <code>, <pre>, backticks, or terms like React, Docker, JavaScript, API.
5. Preserve whitespace & entities.
6. Automatically detect the source language of each segment and add it in the "sourceLanguage" field.
7. Empty/unchanged input → unchanged.
Fail-safe: {"translations":[]}`,
userPrompt: `${INPUT_PLACE_TEXT}`,
systemPrompt: defaultSystemPrompt,
userPrompt: "",
tone: "neutral", // 翻译风格
placeholder: "{ }", // 占位符todo: 备用)
tagName: "i", // 标签符 todo: 备用)
aiTerms: false, // AI智能专业术语 todo: 备用)
customHeader: "",
customBody: "",
reqHook: "", // request 钩子函数
@@ -370,7 +375,6 @@ Fail-safe: {"translations":[]}`,
batchSize: DEFAULT_BATCH_SIZE, // 每次最多发送段落数量
batchLength: DEFAULT_BATCH_LENGTH, // 每次发送最大文字数量
useBatchFetch: false, // 是否启用聚合发送请求
useRichText: false, // 是否启用富文本翻译
useContext: false, // 是否启用智能上下文
contextSize: DEFAULT_CONTEXT_SIZE, // 智能上下文保留会话数
temperature: 0,
@@ -379,10 +383,97 @@ Fail-safe: {"translations":[]}`,
thinkIgnore: "qwen3,deepseek-r1",
isDisabled: false, // 是否不显示
};
const defaultCustomApi = {
...defaultApi,
url: "https://translate.googleapis.com/translate_a/single?client=gtx&dj=1&dt=t&ie=UTF-8&q={{text}}&sl=en&tl=zh-CN",
reqHook: `// Request Hook
const defaultApiOpts = {
[OPT_TRANS_GOOGLE]: {
...defaultApi,
url: "https://translate.googleapis.com/translate_a/single",
},
[OPT_TRANS_GOOGLE_2]: {
...defaultApi,
url: "https://translate-pa.googleapis.com/v1/translateHtml",
key: "AIzaSyATBXajvzQLTDHEQbcpq0Ihe0vWDHmO520",
useBatchFetch: true,
},
[OPT_TRANS_MICROSOFT]: {
...defaultApi,
useBatchFetch: true,
},
[OPT_TRANS_BAIDU]: {
...defaultApi,
},
[OPT_TRANS_TENCENT]: {
...defaultApi,
useBatchFetch: true,
},
[OPT_TRANS_VOLCENGINE]: {
...defaultApi,
},
[OPT_TRANS_DEEPL]: {
...defaultApi,
url: "https://api-free.deepl.com/v2/translate",
useBatchFetch: true,
},
[OPT_TRANS_DEEPLFREE]: {
...defaultApi,
fetchLimit: 1,
},
[OPT_TRANS_DEEPLX]: {
...defaultApi,
url: "http://localhost:1188/translate",
fetchLimit: 1,
},
[OPT_TRANS_NIUTRANS]: {
...defaultApi,
url: "https://api.niutrans.com/NiuTransServer/translation",
dictNo: "",
memoryNo: "",
},
[OPT_TRANS_OPENAI]: {
...defaultApi,
url: "https://api.openai.com/v1/chat/completions",
model: "gpt-4",
useBatchFetch: true,
fetchLimit: 1,
},
[OPT_TRANS_GEMINI]: {
...defaultApi,
url: `https://generativelanguage.googleapis.com/v1/models/${INPUT_PLACE_MODEL}:generateContent?key=${INPUT_PLACE_KEY}`,
model: "gemini-2.5-flash",
useBatchFetch: true,
},
[OPT_TRANS_GEMINI_2]: {
...defaultApi,
url: `https://generativelanguage.googleapis.com/v1beta/openai/chat/completions`,
model: "gemini-2.0-flash",
useBatchFetch: true,
},
[OPT_TRANS_CLAUDE]: {
...defaultApi,
url: "https://api.anthropic.com/v1/messages",
model: "claude-3-haiku-20240307",
useBatchFetch: true,
},
[OPT_TRANS_CLOUDFLAREAI]: {
...defaultApi,
url: "https://api.cloudflare.com/client/v4/accounts/{{ACCOUNT_ID}}/ai/run/@cf/meta/m2m100-1.2b",
},
[OPT_TRANS_OLLAMA]: {
...defaultApi,
url: "http://localhost:11434/v1/chat/completions",
model: "llama3.1",
useBatchFetch: true,
},
[OPT_TRANS_OPENROUTER]: {
...defaultApi,
url: "https://openrouter.ai/api/v1/chat/completions",
model: "openai/gpt-4o",
useBatchFetch: true,
},
[OPT_TRANS_CUSTOMIZE]: {
...defaultApi,
url: "https://translate.googleapis.com/translate_a/single?client=gtx&dj=1&dt=t&ie=UTF-8&q={{text}}&sl=en&tl=zh-CN",
reqHook: `// Request Hook
(text, from, to, url, key) => [url, {
headers: {
"Content-type": "application/json",
@@ -390,172 +481,18 @@ const defaultCustomApi = {
method: "GET",
body: null,
}]`,
resHook: `// Response Hook
resHook: `// Response Hook
(res, text, from, to) => [res.sentences.map((item) => item.trans).join(" "), to === res.src]`,
};
const defaultOpenaiApi = {
...defaultApi,
url: "https://api.openai.com/v1/chat/completions",
model: "gpt-4",
fetchLimit: 1,
};
const defaultOllamaApi = {
...defaultApi,
url: "http://localhost:11434/v1/chat/completions",
model: "llama3.1",
};
export const DEFAULT_TRANS_APIS = {
[OPT_TRANS_GOOGLE]: {
...defaultApi,
apiSlug: OPT_TRANS_GOOGLE,
apiName: OPT_TRANS_GOOGLE,
url: "https://translate.googleapis.com/translate_a/single",
},
[OPT_TRANS_GOOGLE_2]: {
...defaultApi,
apiSlug: OPT_TRANS_GOOGLE_2,
apiName: OPT_TRANS_GOOGLE_2,
url: "https://translate-pa.googleapis.com/v1/translateHtml",
key: "AIzaSyATBXajvzQLTDHEQbcpq0Ihe0vWDHmO520",
useBatchFetch: true,
},
[OPT_TRANS_MICROSOFT]: {
...defaultApi,
apiSlug: OPT_TRANS_MICROSOFT,
apiName: OPT_TRANS_MICROSOFT,
useBatchFetch: true,
},
[OPT_TRANS_BAIDU]: {
...defaultApi,
apiSlug: OPT_TRANS_BAIDU,
apiName: OPT_TRANS_BAIDU,
},
[OPT_TRANS_TENCENT]: {
...defaultApi,
apiSlug: OPT_TRANS_TENCENT,
apiName: OPT_TRANS_TENCENT,
useBatchFetch: true,
},
[OPT_TRANS_VOLCENGINE]: {
...defaultApi,
apiSlug: OPT_TRANS_VOLCENGINE,
apiName: OPT_TRANS_VOLCENGINE,
},
[OPT_TRANS_DEEPL]: {
...defaultApi,
apiSlug: OPT_TRANS_DEEPL,
apiName: OPT_TRANS_DEEPL,
url: "https://api-free.deepl.com/v2/translate",
useBatchFetch: true,
},
[OPT_TRANS_DEEPLFREE]: {
...defaultApi,
apiSlug: OPT_TRANS_DEEPLFREE,
apiName: OPT_TRANS_DEEPLFREE,
fetchLimit: 1,
},
[OPT_TRANS_DEEPLX]: {
...defaultApi,
apiSlug: OPT_TRANS_DEEPLX,
apiName: OPT_TRANS_DEEPLX,
url: "http://localhost:1188/translate",
fetchLimit: 1,
},
[OPT_TRANS_NIUTRANS]: {
...defaultApi,
apiSlug: OPT_TRANS_NIUTRANS,
apiName: OPT_TRANS_NIUTRANS,
url: "https://api.niutrans.com/NiuTransServer/translation",
dictNo: "",
memoryNo: "",
},
[OPT_TRANS_OPENAI]: {
...defaultOpenaiApi,
apiSlug: OPT_TRANS_OPENAI,
apiName: OPT_TRANS_OPENAI,
},
[OPT_TRANS_OPENAI_2]: {
...defaultOpenaiApi,
apiSlug: OPT_TRANS_OPENAI_2,
apiName: OPT_TRANS_OPENAI_2,
},
[OPT_TRANS_OPENAI_3]: {
...defaultOpenaiApi,
apiSlug: OPT_TRANS_OPENAI_3,
apiName: OPT_TRANS_OPENAI_3,
},
[OPT_TRANS_GEMINI]: {
...defaultApi,
apiSlug: OPT_TRANS_GEMINI,
apiName: OPT_TRANS_GEMINI,
url: `https://generativelanguage.googleapis.com/v1/models/${INPUT_PLACE_MODEL}:generateContent?key=${INPUT_PLACE_KEY}`,
model: "gemini-2.5-flash",
},
[OPT_TRANS_GEMINI_2]: {
...defaultApi,
apiSlug: OPT_TRANS_GEMINI_2,
apiName: OPT_TRANS_GEMINI_2,
url: `https://generativelanguage.googleapis.com/v1beta/openai/chat/completions`,
model: "gemini-2.0-flash",
},
[OPT_TRANS_CLAUDE]: {
...defaultApi,
apiSlug: OPT_TRANS_CLAUDE,
apiName: OPT_TRANS_CLAUDE,
url: "https://api.anthropic.com/v1/messages",
model: "claude-3-haiku-20240307",
},
[OPT_TRANS_CLOUDFLAREAI]: {
...defaultApi,
apiSlug: OPT_TRANS_CLOUDFLAREAI,
apiName: OPT_TRANS_CLOUDFLAREAI,
url: "https://api.cloudflare.com/client/v4/accounts/{{ACCOUNT_ID}}/ai/run/@cf/meta/m2m100-1.2b",
},
[OPT_TRANS_OLLAMA]: {
...defaultOllamaApi,
apiSlug: OPT_TRANS_OLLAMA,
apiName: OPT_TRANS_OLLAMA,
},
[OPT_TRANS_OLLAMA_2]: {
...defaultOllamaApi,
apiSlug: OPT_TRANS_OLLAMA_2,
apiName: OPT_TRANS_OLLAMA_2,
},
[OPT_TRANS_OLLAMA_3]: {
...defaultOllamaApi,
apiSlug: OPT_TRANS_OLLAMA_3,
apiName: OPT_TRANS_OLLAMA_3,
},
[OPT_TRANS_OPENROUTER]: {
...defaultApi,
apiSlug: OPT_TRANS_OPENROUTER,
apiName: "",
url: "https://openrouter.ai/api/v1/chat/completions",
model: "openai/gpt-4o",
},
[OPT_TRANS_CUSTOMIZE]: {
...defaultCustomApi,
apiSlug: OPT_TRANS_CUSTOMIZE,
apiName: OPT_TRANS_CUSTOMIZE,
},
[OPT_TRANS_CUSTOMIZE_2]: {
...defaultCustomApi,
apiSlug: OPT_TRANS_CUSTOMIZE_2,
apiName: OPT_TRANS_CUSTOMIZE_2,
},
[OPT_TRANS_CUSTOMIZE_3]: {
...defaultCustomApi,
apiSlug: OPT_TRANS_CUSTOMIZE_3,
apiName: OPT_TRANS_CUSTOMIZE_3,
},
[OPT_TRANS_CUSTOMIZE_4]: {
...defaultCustomApi,
apiSlug: OPT_TRANS_CUSTOMIZE_4,
apiName: OPT_TRANS_CUSTOMIZE_4,
},
[OPT_TRANS_CUSTOMIZE_5]: {
...defaultCustomApi,
apiSlug: OPT_TRANS_CUSTOMIZE_5,
apiName: OPT_TRANS_CUSTOMIZE_5,
},
};
// 内置翻译接口列表(带参数)
export const DEFAULT_API_LIST = OPT_ALL_TYPES.map((apiType) => ({
...defaultApiOpts[apiType],
apiSlug: apiType,
apiName: apiType,
apiType,
}));
export const DEFAULT_API_TYPE = OPT_TRANS_MICROSOFT;
export const DEFAULT_API_SETTING = DEFAULT_API_LIST[DEFAULT_API_TYPE];

View File

@@ -1342,4 +1342,59 @@ export const I18N = {
en: `Hide Original`,
zh_TW: `隱藏原文`,
},
confirm_title: {
zh: `确认`,
en: `Confirm`,
zh_TW: `確認`,
},
confirm_message: {
zh: `确定操作吗?`,
en: `Are you sure you want to proceed?`,
zh_TW: `確定操作嗎?`,
},
confirm_action: {
zh: `确定`,
en: `Confirm`,
zh_TW: `確定`,
},
cancel_action: {
zh: `取消`,
en: `Cancel`,
zh_TW: `取消`,
},
pls_press_shortcut: {
zh: `请按下快捷键组合`,
en: `Please press the shortcut key combination`,
zh_TW: `請按下快速鍵組合`,
},
load_setting_err: {
zh: `数据加载出错,请刷新页面或卸载后重新安装。`,
en: `Please press the shortcut key combination`,
zh_TW: `請按下快速鍵組合`,
},
translation_style: {
zh: `翻译风格`,
en: `Translation style`,
zh_TW: `翻譯風格`,
},
placeholder: {
zh: `占位符`,
en: `Placeholder`,
zh_TW: `佔位符`,
},
tag_name: {
zh: `占位标签名`,
en: `Placeholder tag name`,
zh_TW: `佔位標名`,
},
ai_terms: {
zh: `AI识别术语表`,
en: `AI Identification Glossary`,
zh_TW: `AI辨識術語表`,
},
system_prompt_helper: {
zh: `在未完全理解默认Prompt的情况下请勿随意修改否则可能翻译失败。`,
en: `If you do not fully understand the default prompt, please do not modify it at will, otherwise the translation may fail.`,
zh_TW: `在未完全理解預設Prompt的情況下請勿隨意修改否則可能翻譯失敗。`,
},
};

View File

@@ -1,4 +1,3 @@
import { FIXER_BR, FIXER_BN, FIXER_BR_DIV, FIXER_BN_DIV } from "../libs/webfix";
import { OPT_TRANS_MICROSOFT } from "./api";
export const GLOBAL_KEY = "*";
@@ -58,6 +57,19 @@ export const OPT_TIMING_ALL = [
OPT_TIMING_ALT,
];
const DEFAULT_DIY_STYLE = `color: #666;
background: linear-gradient(
45deg,
LightGreen 20%,
LightPink 20% 40%,
LightSalmon 40% 60%,
LightSeaGreen 60% 80%,
LightSkyBlue 80%
);
&:hover {
color: #333;
};`;
export const DEFAULT_SELECTOR =
"h1, h2, h3, h4, h5, h6, li, p, dd, blockquote, figcaption, label, legend";
export const DEFAULT_IGNORE_SELECTOR =
@@ -68,13 +80,13 @@ export const DEFAULT_RULE = {
selector: "", // 选择器
keepSelector: "", // 保留元素选择器
terms: "", // 专业术语
translator: GLOBAL_KEY, // 翻译服务
apiSlug: GLOBAL_KEY, // 翻译服务
fromLang: GLOBAL_KEY, // 源语言
toLang: GLOBAL_KEY, // 目标语言
textStyle: GLOBAL_KEY, // 译文样式
transOpen: GLOBAL_KEY, // 开启翻译
bgColor: "", // 译文颜色
textDiyStyle: "", // 自定义译文样式
textDiyStyle: DEFAULT_DIY_STYLE, // 自定义译文样式
selectStyle: "", // 选择器节点样式
parentStyle: "", // 选择器父节点样式
injectJs: "", // 注入JS
@@ -104,7 +116,7 @@ export const GLOBLA_RULE = {
selector: DEFAULT_SELECTOR, // 选择器
keepSelector: DEFAULT_KEEP_SELECTOR, // 保留元素选择器
terms: "", // 专业术语
translator: OPT_TRANS_MICROSOFT, // 翻译服务
apiSlug: OPT_TRANS_MICROSOFT, // 翻译服务
fromLang: "auto", // 源语言
toLang: "zh-CN", // 目标语言
textStyle: OPT_STYLE_NONE, // 译文样式
@@ -136,21 +148,8 @@ export const GLOBLA_RULE = {
export const DEFAULT_RULES = [GLOBLA_RULE];
const DEFAULT_DIY_STYLE = `color: #666;
background: linear-gradient(
45deg,
LightGreen 20%,
LightPink 20% 40%,
LightSalmon 40% 60%,
LightSeaGreen 60% 80%,
LightSkyBlue 80%
);
&:hover {
color: #333;
};`;
export const DEFAULT_OW_RULE = {
translator: REMAIN_KEY,
apiSlug: REMAIN_KEY,
fromLang: REMAIN_KEY,
toLang: REMAIN_KEY,
textStyle: REMAIN_KEY,
@@ -159,258 +158,33 @@ export const DEFAULT_OW_RULE = {
textDiyStyle: DEFAULT_DIY_STYLE,
};
// todo: 校验几个内置规则
const RULES_MAP = {
"www.google.com/search": {
selector: `h3, .IsZvec, .VwiC3b`,
},
"news.google.com": {
selector: `[data-n-tid], ${DEFAULT_SELECTOR}`,
},
"www.foxnews.com": {
selector: `h1, h2, .title, .sidebar [data-type="Title"], .article-content ${DEFAULT_SELECTOR}; [data-spotim-module="conversation"]>div >>> [data-spot-im-class="message-text"] p, [data-spot-im-class="message-text"]`,
},
"bearblog.dev, www.theverge.com, www.tampermonkey.net/documentation.php": {
selector: `${DEFAULT_SELECTOR}`,
},
"themessenger.com": {
selector: `.leading-tight, .leading-tighter, .my-2 p, .font-body p, article ${DEFAULT_SELECTOR}`,
},
"www.telegraph.co.uk, go.dev/doc/": {
selector: `article ${DEFAULT_SELECTOR}`,
},
"www.theguardian.com": {
selector: `.show-underline, .dcr-hup5wm div, .dcr-7vl6y8 div, .dcr-12evv1c, figcaption, article ${DEFAULT_SELECTOR}, [data-cy="mostviewed-footer"] h4`,
},
"www.semafor.com": {
selector: `${DEFAULT_SELECTOR}, .styles_intro__IYj__, [class*="styles_description"]`,
},
"www.noemamag.com": {
selector: `.splash__title, .single-card__title, .single-card__type, .single-card__topic, .highlighted-content__title, .single-card__author, article ${DEFAULT_SELECTOR}, .quote__text, .wp-caption-text div`,
},
"restofworld.org": {
selector: `${DEFAULT_SELECTOR}, .recirc-story__headline, .recirc-story__dek`,
},
"www.axios.com": {
selector: `.h7, ${DEFAULT_SELECTOR}`,
},
"www.newyorker.com": {
selector: `.summary-item__hed, .summary-item__dek, .summary-collection-grid__dek, .dqtvfu, .rubric__link, .caption, article ${DEFAULT_SELECTOR}, .HEhan ${DEFAULT_SELECTOR}, .ContributorBioBio-fBolsO, .BaseText-ewhhUZ`,
},
"time.com": {
selector: `h1, h3, .summary, .video-title, #article-body ${DEFAULT_SELECTOR}, .image-wrap-container .credit.body-caption, .media-heading`,
},
"www.dw.com": {
selector: `.ts-teaser-title a, .news-title a, .title a, .teaser-description a, .hbudab h3, .hbudab p, figcaption ,article ${DEFAULT_SELECTOR}`,
},
"www.bbc.com": {
selector: `h1, h2, .media__link, .media__summary, article ${DEFAULT_SELECTOR}, .ssrcss-y7krbn-Stack, .ssrcss-17zglt8-PromoHeadline, .ssrcss-18cjaf3-Headline, .gs-c-promo-heading__title, .gs-c-promo-summary, .media__content h3, .article__intro, .lx-c-summary-points>li`,
},
"www.chinadaily.com.cn": {
selector: `h1, .tMain [shape="rect"], .cMain [shape="rect"], .photo_art [shape="rect"], .mai_r [shape="rect"], .lisBox li, #Content ${DEFAULT_SELECTOR}`,
},
"www.facebook.com": {
selector: `[role="main"] [dir="auto"]`,
},
"www.reddit.com, new.reddit.com, sh.reddit.com": {
selector: `:is(#AppRouter-main-content, #overlayScrollContainer) :is([class^=tbIA],[class^=_1zP],[class^=ULWj],[class^=_2Jj], [class^=_334],[class^=_2Gr],[class^=_7T4],[class^=_1WO], ${DEFAULT_SELECTOR}); [id^="post-title"], :is([slot="text-body"], [slot="comment"]) ${DEFAULT_SELECTOR}, recent-posts h3, aside :is(span:has(>h2), p); shreddit-subreddit-header >>> :is(#title, #description)`,
},
"www.quora.com": {
selector: `.qu-wordBreak--break-word`,
},
"edition.cnn.com": {
selector: `.container__title, .container__headline, .headline__text, .image__caption, [data-type="Title"], .article__content ${DEFAULT_SELECTOR}`,
},
"www.reuters.com": {
selector: `#main-content [data-testid="Heading"], #main-content [data-testid="Body"], .article-body__content__17Yit ${DEFAULT_SELECTOR}`,
},
"www.bloomberg.com": {
selector: `[data-component="headline"], [data-component="related-item-headline"], [data-component="title"], article ${DEFAULT_SELECTOR}`,
},
"deno.land, docs.github.com": {
selector: `main ${DEFAULT_SELECTOR}`,
keepSelector: DEFAULT_KEEP_SELECTOR,
},
"doc.rust-lang.org": {
selector: `.content ${DEFAULT_SELECTOR}`,
keepSelector: DEFAULT_KEEP_SELECTOR,
},
"www.indiehackers.com": {
selector: `h1, h3, .content ${DEFAULT_SELECTOR}, .feed-item__title-link`,
},
"platform.openai.com/docs": {
selector: `.docs-body ${DEFAULT_SELECTOR}`,
keepSelector: DEFAULT_KEEP_SELECTOR,
},
"en.wikipedia.org": {
selector: `h1, .mw-parser-output ${DEFAULT_SELECTOR}`,
keepSelector: `.mwe-math-element`,
},
"stackoverflow.com, serverfault.com, superuser.com, stackexchange.com, askubuntu.com, stackapps.com, mathoverflow.net":
{
selector: `.s-prose ${DEFAULT_SELECTOR}, .comment-copy, .question-hyperlink, .s-post-summary--content-title, .s-post-summary--content-excerpt`,
keepSelector: `${DEFAULT_KEEP_SELECTOR}, .math-container`,
},
"www.npmjs.com/package, developer.chrome.com/docs, medium.com, react.dev, create-react-app.dev, pytorch.org":
{
selector: `article ${DEFAULT_SELECTOR}`,
},
"news.ycombinator.com": {
selector: `.title, p`,
fixerSelector: `.toptext, .commtext`,
fixerFunc: FIXER_BR,
},
"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"], .markdown-title, bdi, .ws-pre-wrap, .status-meta, span.status-meta, .col-10.color-fg-muted, .TimelineItem-body, .pinned-item-list-item-content .color-fg-muted, .markdown-body td, .markdown-body th`,
keepSelector: DEFAULT_KEEP_SELECTOR,
},
"twitter.com": {
"twitter.com, https://x.com": {
selector: `[data-testid="tweetText"], [data-testid="birdwatch-pivot"]>div.css-1rynq56`,
keepSelector: `img, a, .r-18u37iz, .css-175oi2r`,
},
"m.youtube.com": {
selector: `.slim-video-information-title .yt-core-attributed-string, .media-item-headline .yt-core-attributed-string, .comment-text .yt-core-attributed-string, .typography-body-2b .yt-core-attributed-string, #ytp-caption-window-container .ytp-caption-segment`,
selectStyle: `-webkit-line-clamp: unset; max-height: none; height: auto;`,
parentStyle: `-webkit-line-clamp: unset; max-height: none; height: auto;`,
keepSelector: `img, #content-text>a`,
},
"www.youtube.com": {
selector: `h1, #video-title, #content-text, #title, yt-attributed-string>span>span, #ytp-caption-window-container .ytp-caption-segment`,
selectStyle: `-webkit-line-clamp: unset; max-height: none; height: auto;`,
parentStyle: `-webkit-line-clamp: unset; max-height: none; height: auto;`,
keepSelector: `img, #content-text>a`,
},
"bard.google.com": {
selector: `.query-content ${DEFAULT_SELECTOR}, message-content ${DEFAULT_SELECTOR}`,
},
"www.bing.com, copilot.microsoft.com": {
selector: `.b_algoSlug, .rwrl_padref; .cib-serp-main >>> .ac-textBlock ${DEFAULT_SELECTOR}, .text-message-content div`,
},
"www.phoronix.com": {
selector: `article ${DEFAULT_SELECTOR}`,
fixerSelector: `.content`,
fixerFunc: FIXER_BR,
},
"wx2.qq.com": {
selector: `.js_message_plain`,
},
"app.slack.com/client/": {
selector: `.p-rich_text_section, .c-message_attachment__text, .p-rich_text_list li`,
},
"discord.com/channels/": {
selector: `div[class^=message], div[class^=headerText], div[class^=name_], section[aria-label='Search Results'] div[id^=message-content], div[id^=message]`,
keepSelector: `li[class^='card'] div[class^='message'], [class^='embedFieldValue'], [data-list-item-id^='forum-channel-list'] div[class^='headerText']`,
},
"t.me/s/": {
selector: `.js-message_text ${DEFAULT_SELECTOR}`,
fixerSelector: `.tgme_widget_message_text`,
fixerFunc: FIXER_BR,
},
"web.telegram.org/k": {
selector: `div.kiss-p`,
keepSelector: `div[class^=time], .peer-title, .document-wrapper, .message.spoilers-container custom-emoji-element, reactions-element`,
fixerSelector: `.message`,
fixerFunc: FIXER_BN_DIV,
},
"web.telegram.org/a": {
selector: `.text-content > .kiss-p`,
keepSelector: `.Reactions, .time, .peer-title, .document-wrapper, .message.spoilers-container custom-emoji-element`,
fixerSelector: `.text-content`,
fixerFunc: FIXER_BR_DIV,
},
"www.instagram.com/": {
selector: `h1, article span[dir=auto] > span[dir=auto], ._ab1y`,
},
"www.instagram.com/p/,www.instagram.com/reels/": {
selector: `h1, div[class='x9f619 xjbqb8w x78zum5 x168nmei x13lgxp2 x5pf9jr xo71vjh x1uhb9sk x1plvlek xryxfnj x1c4vz4f x2lah0s xdt5ytf xqjyukv x1cy8zhl x1oa3qoh x1nhvcw1'] > span[class='x1lliihq x1plvlek xryxfnj x1n2onr6 x193iq5w xeuugli x1fj9vlw x13faqbe x1vvkbs x1s928wv xhkezso x1gmr53x x1cpjm7i x1fgarty x1943h6x x1i0vuye xvs91rp xo1l8bm x5n08af x10wh9bi x1wdrske x8viiok x18hxmgj'], span[class='x193iq5w xeuugli x1fj9vlw x13faqbe x1vvkbs xt0psk2 x1i0vuye xvs91rp xo1l8bm x5n08af x10wh9bi x1wdrske x8viiok x18hxmgj']`,
},
"mail.google.com": {
selector: `.a3s.aiL ${DEFAULT_SELECTOR}, span[data-thread-id]`,
fixerSelector: `.a3s.aiL`,
fixerFunc: FIXER_BR,
},
"web.whatsapp.com": {
selector: `.copyable-text > span`,
},
"chat.openai.com": {
selector: `div[data-message-author-role] > div ${DEFAULT_SELECTOR}`,
fixerSelector: `div[data-message-author-role='user'] > div`,
fixerFunc: FIXER_BN,
},
"forum.ru-board.com": {
selector: `.tit, .dats, .kiss-p, .lgf ${DEFAULT_SELECTOR}`,
fixerSelector: `span.post`,
fixerFunc: FIXER_BR,
},
"education.github.com": {
selector: `${DEFAULT_SELECTOR}, a, summary, span.Button-content`,
},
"blogs.windows.com": {
selector: `${DEFAULT_SELECTOR}, .c-uhf-nav-link, figcaption`,
fixerSelector: `.t-content>div>ul>li`,
fixerFunc: FIXER_BR,
},
"developer.apple.com/documentation/": {
selector: `#main ${DEFAULT_SELECTOR}, #main .abstract .content, #main .abstract.content, #main .link span`,
keepSelector: DEFAULT_KEEP_SELECTOR,
},
"greasyfork.org": {
selector: `h2, .script-link, .script-description, #additional-info ${DEFAULT_SELECTOR}`,
},
"www.fmkorea.com": {
selector: `#container ${DEFAULT_SELECTOR}`,
},
"forum.arduino.cc": {
selector: `.top-row>.title, .featured-topic>.title, .link-top-line>.title, .category-description, .topic-excerpt, .fancy-title, .cooked ${DEFAULT_SELECTOR}`,
},
"docs.arduino.cc": {
selector: `[class^="tutorial-module--left"] ${DEFAULT_SELECTOR}`,
},
"www.historydefined.net": {
selector: `.wp-element-caption, ${DEFAULT_SELECTOR}`,
},
"gobyexample.com": {
selector: `.docs p`,
keepSelector: `code`,
},
"go.dev/tour": {
selector: `#left-side ${DEFAULT_SELECTOR}`,
keepSelector: `code, img, svg >>> code`,
},
"pkg.go.dev": {
selector: `.Documentation-content ${DEFAULT_SELECTOR}`,
keepSelector: `${DEFAULT_KEEP_SELECTOR}, a, span`,
},
"docs.rs": {
selector: `.docblock ${DEFAULT_SELECTOR}, .docblock-short`,
keepSelector: `code >>> code`,
},
"randomnerdtutorials.com": {
selector: `article ${DEFAULT_SELECTOR}`,
},
"notebooks.githubusercontent.com/view/ipynb": {
selector: `#notebook-container ${DEFAULT_SELECTOR}`,
keepSelector: DEFAULT_KEEP_SELECTOR,
},
"developers.cloudflare.com": {
selector: `article ${DEFAULT_SELECTOR}, .WorkerStarter--description`,
keepSelector: `a[rel='noopener'], code`,
},
"ubuntuforums.org": {
fixerSelector: `.postcontent`,
fixerFunc: FIXER_BR,
},
"play.google.com/store/apps/details": {
fixerSelector: `[data-g-id="description"]`,
fixerFunc: FIXER_BR,
},
"news.yahoo.co.jp/articles/": {
fixerSelector: `.sc-cTsKDU`,
fixerFunc: FIXER_BN,
},
"chromereleases.googleblog.com": {
fixerSelector: `.post-content, .post-content > span, li > span`,
fixerFunc: FIXER_BR,
},
};
export const BUILTIN_RULES = Object.entries(RULES_MAP)

View File

@@ -2,7 +2,7 @@ import {
OPT_DICT_BAIDU,
DEFAULT_HTTP_TIMEOUT,
OPT_TRANS_MICROSOFT,
DEFAULT_TRANS_APIS,
DEFAULT_API_LIST,
} from "./api";
import { DEFAULT_OW_RULE } from "./rules";
@@ -50,7 +50,7 @@ export const OPT_INPUT_TRANS_SIGNS = ["/", "//", "\\", "\\\\", ">", ">>"];
export const DEFAULT_INPUT_SHORTCUT = ["AltLeft", "KeyI"];
export const DEFAULT_INPUT_RULE = {
transOpen: true,
translator: OPT_TRANS_MICROSOFT,
apiSlug: OPT_TRANS_MICROSOFT,
fromLang: "auto",
toLang: "en",
triggerShortcut: DEFAULT_INPUT_SHORTCUT,
@@ -75,7 +75,7 @@ export const OPT_TRANBOX_TRIGGER_ALL = [
export const DEFAULT_TRANBOX_SHORTCUT = ["AltLeft", "KeyS"];
export const DEFAULT_TRANBOX_SETTING = {
// transOpen: true, // 是否启用划词翻译作废移至rule
translator: OPT_TRANS_MICROSOFT,
apiSlug: OPT_TRANS_MICROSOFT,
fromLang: "auto",
toLang: "zh-CN",
toLang2: "en",
@@ -109,17 +109,17 @@ export const DEFAULT_SUBRULES_LIST = [
},
];
export const DEFAULT__MOUSEHOVER_KEY = ["ControlLeft"];
export const DEFAULT_MOUSEHOVER_KEY = ["ControlLeft"];
export const DEFAULT_MOUSE_HOVER_SETTING = {
useMouseHover: true, // 是否启用鼠标悬停翻译
mouseHoverKey: DEFAULT__MOUSEHOVER_KEY, // 鼠标悬停翻译组合键
mouseHoverKey: DEFAULT_MOUSEHOVER_KEY, // 鼠标悬停翻译组合键
};
export const DEFAULT_SETTING = {
darkMode: false, // 深色模式
uiLang: "en", // 界面语言
// fetchLimit: DEFAULT_FETCH_LIMIT, // 最大任务数量(移至transApis,作废)
// fetchInterval: DEFAULT_FETCH_INTERVAL, // 任务间隔时间(移至transApis,作废)
// fetchLimit: DEFAULT_FETCH_LIMIT, // 最大任务数量(移至rule,作废)
// fetchInterval: DEFAULT_FETCH_INTERVAL, // 任务间隔时间(移至rule,作废)
minLength: TRANS_MIN_LENGTH,
maxLength: TRANS_MAX_LENGTH,
newlineLength: TRANS_NEWLINE_LENGTH,
@@ -136,7 +136,7 @@ export const DEFAULT_SETTING = {
// transTitle: false, // 是否同时翻译页面标题(移至rule作废)
subrulesList: DEFAULT_SUBRULES_LIST, // 订阅列表
owSubrule: DEFAULT_OW_RULE, // 覆写订阅规则
transApis: DEFAULT_TRANS_APIS, // 翻译接口
transApis: DEFAULT_API_LIST, // 翻译接口 (v2.0 对象改为数组)
// mouseKey: OPT_TIMING_PAGESCROLL, // 翻译时机/鼠标悬停翻译(移至rule作废)
shortcuts: DEFAULT_SHORTCUTS, // 快捷键
inputRule: DEFAULT_INPUT_RULE, // 输入框设置
@@ -145,7 +145,7 @@ export const DEFAULT_SETTING = {
blacklist: DEFAULT_BLACKLIST.join(",\n"), // 禁用翻译名单
csplist: DEFAULT_CSPLIST.join(",\n"), // 禁用CSP名单
// disableLangs: [], // 不翻译的语言(移至rule作废)
transInterval: 200, // 翻译等待时间
transInterval: 100, // 翻译等待时间
langDetector: OPT_TRANS_MICROSOFT, // 远程语言识别服务
mouseHoverSetting: DEFAULT_MOUSE_HOVER_SETTING, // 鼠标悬停翻译
};

View File

@@ -1,34 +1,107 @@
import { useCallback } from "react";
import { DEFAULT_TRANS_APIS } from "../config";
import { useCallback, useMemo } from "react";
import { DEFAULT_API_LIST, API_SPE_TYPES } from "../config";
import { useSetting } from "./Setting";
export function useApi(translator) {
function useApiState() {
const { setting, updateSetting } = useSetting();
const transApis = setting?.transApis || DEFAULT_TRANS_APIS;
const transApis = setting?.transApis || [];
const updateApi = useCallback(
async (obj) => {
const api = {
...DEFAULT_TRANS_APIS[translator],
...(transApis[translator] || {}),
};
Object.assign(transApis, { [translator]: { ...api, ...obj } });
await updateSetting({ transApis });
},
[translator, transApis, updateSetting]
return { transApis, updateSetting };
}
export function useApiList() {
const { transApis, updateSetting } = useApiState();
const userApis = useMemo(
() =>
transApis
.filter((api) => !API_SPE_TYPES.builtin.has(api.apiSlug))
.sort((a, b) => a.apiSlug.localeCompare(b.apiSlug)),
[transApis]
);
const resetApi = useCallback(async () => {
Object.assign(transApis, { [translator]: DEFAULT_TRANS_APIS[translator] });
await updateSetting({ transApis });
}, [translator, transApis, updateSetting]);
const builtinApis = useMemo(
() => transApis.filter((api) => API_SPE_TYPES.builtin.has(api.apiSlug)),
[transApis]
);
return {
api: {
...DEFAULT_TRANS_APIS[translator],
...(transApis[translator] || {}),
const enabledApis = useMemo(
() => transApis.filter((api) => !api.isDisabled),
[transApis]
);
const addApi = useCallback(
(apiType) => {
const defaultApiOpt =
DEFAULT_API_LIST.find((da) => da.apiType === apiType) || {};
const uuid = crypto.randomUUID();
const apiSlug = `${apiType}_${crypto.randomUUID()}`;
const apiName = `${apiType}_${uuid.slice(0, 8)}`;
const newApi = {
...defaultApiOpt,
apiSlug,
apiName,
apiType,
};
updateSetting((prev) => ({
...prev,
transApis: [...(prev?.transApis || []), newApi],
}));
},
updateApi,
resetApi,
};
[updateSetting]
);
const deleteApi = useCallback(
(apiSlug) => {
updateSetting((prev) => ({
...prev,
transApis: (prev?.transApis || []).filter((api) => api.apiSlug !== apiSlug),
}));
},
[updateSetting]
);
return { transApis, userApis, builtinApis, enabledApis, addApi, deleteApi };
}
export function useApiItem(apiSlug) {
const { transApis, updateSetting } = useApiState();
const api = useMemo(
() => transApis.find((a) => a.apiSlug === apiSlug),
[transApis, apiSlug]
);
const update = useCallback(
(updateData) => {
updateSetting((prev) => ({
...prev,
transApis: (prev?.transApis || []).map((item) =>
item.apiSlug === apiSlug ? { ...item, ...updateData, apiSlug } : item
),
}));
},
[apiSlug, updateSetting]
);
const reset = useCallback(() => {
updateSetting((prev) => ({
...prev,
transApis: (prev?.transApis || []).map((item) => {
if (item.apiSlug === apiSlug) {
const defaultApiOpt =
DEFAULT_API_LIST.find((da) => da.apiType === item.apiType) || {};
return {
...defaultApiOpt,
apiSlug: item.apiSlug,
apiName: item.apiName,
apiType: item.apiType,
};
}
return item;
}),
}));
}, [apiSlug, updateSetting]);
return { api, update, reset };
}

View File

@@ -52,7 +52,7 @@ export function useTextAudio(text, lan = "uk", spd = 3) {
try {
setSrc(await apiBaiduTTS(text, lan, spd));
} catch (err) {
kissLog(err, "baidu tts");
kissLog("baidu tts", err);
}
})();
}, [text, lan, spd]);

View File

@@ -11,8 +11,8 @@ export function useDarkMode() {
updateSetting,
} = useSetting();
const toggleDarkMode = useCallback(async () => {
await updateSetting({ darkMode: !darkMode });
const toggleDarkMode = useCallback(() => {
updateSetting({ darkMode: !darkMode });
}, [darkMode, updateSetting]);
return { darkMode, toggleDarkMode };

97
src/hooks/Confirm.js Normal file
View File

@@ -0,0 +1,97 @@
import {
useState,
useContext,
createContext,
useCallback,
useRef,
useMemo,
} from "react";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import Button from "@mui/material/Button";
import { useI18n } from "./I18n";
const ConfirmContext = createContext(null);
export function ConfirmProvider({ children }) {
const [dialogConfig, setDialogConfig] = useState(null);
const resolveRef = useRef(null);
const i18n = useI18n();
const translatedDefaults = useMemo(
() => ({
title: i18n("confirm_title", "Confirm"),
message: i18n("confirm_message", "Are you sure you want to proceed?"),
confirmText: i18n("confirm_action", "Confirm"),
cancelText: i18n("cancel_action", "Cancel"),
}),
[i18n]
);
const confirm = useCallback(
(config) => {
return new Promise((resolve) => {
setDialogConfig({ ...translatedDefaults, ...config });
resolveRef.current = resolve;
});
},
[translatedDefaults]
);
const handleClose = () => {
if (resolveRef.current) {
resolveRef.current(false);
}
setDialogConfig(null);
};
const handleConfirm = () => {
if (resolveRef.current) {
resolveRef.current(true);
}
setDialogConfig(null);
};
return (
<ConfirmContext.Provider value={confirm}>
{children}
<Dialog
open={!!dialogConfig}
onClose={handleClose}
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-description"
>
{dialogConfig && (
<>
<DialogTitle id="confirm-dialog-title">
{dialogConfig.title}
</DialogTitle>
<DialogContent>
<DialogContentText id="confirm-dialog-description">
{dialogConfig.message}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>{dialogConfig.cancelText}</Button>
<Button onClick={handleConfirm} color="primary" autoFocus>
{dialogConfig.confirmText}
</Button>
</DialogActions>
</>
)}
</Dialog>
</ConfirmContext.Provider>
);
}
export function useConfirm() {
const context = useContext(ConfirmContext);
if (!context) {
throw new Error("useConfirm must be used within a ConfirmProvider");
}
return context;
}

View File

@@ -0,0 +1,17 @@
import { useMemo, useEffect, useRef } from "react";
import { debounce } from "../libs/utils";
export function useDebouncedCallback(callback, delay) {
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const debouncedCallback = useMemo(
() => debounce((...args) => callbackRef.current(...args), delay),
[delay]
);
return debouncedCallback;
}

View File

@@ -1,11 +1,13 @@
import { STOKEY_FAB } from "../config";
import { useStorage } from "./Storage";
const DEFAULT_FAB = {};
/**
* fab hook
* @returns
*/
export function useFab() {
const { data, update } = useStorage(STOKEY_FAB);
const { data, update } = useStorage(STOKEY_FAB, DEFAULT_FAB);
return { fab: data, updateFab: update };
}

View File

@@ -1,68 +1,55 @@
import { KV_WORDS_KEY } from "../config";
import { useCallback, useEffect, useState } from "react";
import { trySyncWords } from "../libs/sync";
import { getWordsWithDefault, setWords } from "../libs/storage";
import { useSyncMeta } from "./Sync";
import { kissLog } from "../libs/log";
import { STOKEY_WORDS, KV_WORDS_KEY } from "../config";
import { useCallback, useMemo } from "react";
import { useStorage } from "./Storage";
const DEFAULT_FAVWORDS = {};
export function useFavWords() {
const [loading, setLoading] = useState(false);
const [favWords, setFavWords] = useState({});
const { updateSyncMeta } = useSyncMeta();
const { data: favWords, save } = useStorage(
STOKEY_WORDS,
DEFAULT_FAVWORDS,
KV_WORDS_KEY
);
const toggleFav = useCallback(
async (word) => {
const favs = { ...favWords };
if (favs[word]) {
(word) => {
save((prev) => {
if (!prev[word]) {
return { ...prev, [word]: { createdAt: Date.now() } };
}
const favs = { ...prev };
delete favs[word];
} else {
favs[word] = { createdAt: Date.now() };
}
await setWords(favs);
await updateSyncMeta(KV_WORDS_KEY);
await trySyncWords();
setFavWords(favs);
return favs;
});
},
[updateSyncMeta, favWords]
[save]
);
const mergeWords = useCallback(
async (newWords) => {
const favs = { ...favWords };
newWords.forEach((word) => {
if (!favs[word]) {
favs[word] = { createdAt: Date.now() };
}
});
await setWords(favs);
await updateSyncMeta(KV_WORDS_KEY);
await trySyncWords();
setFavWords(favs);
(words) => {
save((prev) => ({
...words.reduce((acc, key) => {
acc[key] = { createdAt: Date.now() };
return acc;
}, {}),
...prev,
}));
},
[updateSyncMeta, favWords]
[save]
);
const clearWords = useCallback(async () => {
await setWords({});
await updateSyncMeta(KV_WORDS_KEY);
await trySyncWords();
setFavWords({});
}, [updateSyncMeta]);
const clearWords = useCallback(() => {
save({});
}, [save]);
useEffect(() => {
(async () => {
try {
setLoading(true);
await trySyncWords();
const favWords = await getWordsWithDefault();
setFavWords(favWords);
} catch (err) {
kissLog(err, "query fav");
} finally {
setLoading(false);
}
})();
}, []);
const favList = useMemo(
() =>
Object.entries(favWords || {}).sort((a, b) => a[0].localeCompare(b[0])),
[favWords]
);
return { loading, favWords, toggleFav, mergeWords, clearWords };
const wordList = useMemo(() => favList.map(([word]) => word), [favList]);
return { favWords, favList, wordList, toggleFav, mergeWords, clearWords };
}

View File

@@ -1,18 +1,10 @@
import { useCallback } from "react";
import { DEFAULT_INPUT_RULE } from "../config";
import { useSetting } from "./Setting";
export function useInputRule() {
const { setting, updateSetting } = useSetting();
const { setting, updateChild } = useSetting();
const inputRule = setting?.inputRule || DEFAULT_INPUT_RULE;
const updateInputRule = useCallback(
async (obj) => {
Object.assign(inputRule, obj);
await updateSetting({ inputRule });
},
[inputRule, updateSetting]
);
const updateInputRule = updateChild("inputRule");
return { inputRule, updateInputRule };
}

16
src/hooks/Loading.js Normal file
View File

@@ -0,0 +1,16 @@
import CircularProgress from "@mui/material/CircularProgress";
import Link from "@mui/material/Link";
import Divider from "@mui/material/Divider";
export default function Loading() {
return (
<center>
<Divider>
<Link
href={process.env.REACT_APP_HOMEPAGE}
>{`KISS Translator v${process.env.REACT_APP_VERSION}`}</Link>
</Divider>
<CircularProgress />
</center>
);
}

View File

@@ -1,19 +1,11 @@
import { useCallback } from "react";
import { DEFAULT_MOUSE_HOVER_SETTING } from "../config";
import { useSetting } from "./Setting";
export function useMouseHoverSetting() {
const { setting, updateSetting } = useSetting();
const { setting, updateChild } = useSetting();
const mouseHoverSetting =
setting?.mouseHoverSetting || DEFAULT_MOUSE_HOVER_SETTING;
const updateMouseHoverSetting = useCallback(
async (obj) => {
Object.assign(mouseHoverSetting, obj);
await updateSetting({ mouseHoverSetting });
},
[mouseHoverSetting, updateSetting]
);
const updateMouseHoverSetting = updateChild("mouseHoverSetting");
return { mouseHoverSetting, updateMouseHoverSetting };
}

View File

@@ -1,90 +1,88 @@
import { STOKEY_RULES, DEFAULT_RULES, KV_RULES_KEY } from "../config";
import { useStorage } from "./Storage";
import { trySyncRules } from "../libs/sync";
import { checkRules } from "../libs/rules";
import { useCallback } from "react";
import { useSyncMeta } from "./Sync";
/**
* 规则 hook
* @returns
*/
export function useRules() {
const { data: list, save } = useStorage(STOKEY_RULES, DEFAULT_RULES);
const { updateSyncMeta } = useSyncMeta();
const updateRules = useCallback(
async (rules) => {
await save(rules);
await updateSyncMeta(KV_RULES_KEY);
trySyncRules();
},
[save, updateSyncMeta]
const { data: list, save } = useStorage(
STOKEY_RULES,
DEFAULT_RULES,
KV_RULES_KEY
);
const add = useCallback(
async (rule) => {
const rules = [...list];
if (rule.pattern === "*") {
return;
}
if (rules.map((item) => item.pattern).includes(rule.pattern)) {
return;
}
rules.unshift(rule);
await updateRules(rules);
(rule) => {
save((prev) => {
if (
rule.pattern === "*" ||
prev.some((item) => item.pattern === rule.pattern)
) {
return prev;
}
return [rule, ...prev];
});
},
[list, updateRules]
[save]
);
const del = useCallback(
async (pattern) => {
let rules = [...list];
if (pattern === "*") {
return;
}
rules = rules.filter((item) => item.pattern !== pattern);
await updateRules(rules);
(pattern) => {
save((prev) => {
if (pattern === "*") {
return prev;
}
return prev.filter((item) => item.pattern !== pattern);
});
},
[list, updateRules]
[save]
);
const clear = useCallback(async () => {
let rules = [...list];
rules = rules.filter((item) => item.pattern === "*");
await updateRules(rules);
}, [list, updateRules]);
const clear = useCallback(() => {
save((prev) => prev.filter((item) => item.pattern === "*"));
}, [save]);
const put = useCallback(
async (pattern, obj) => {
const rules = [...list];
if (pattern === "*") {
obj.pattern = "*";
}
const rule = rules.find((r) => r.pattern === pattern);
rule && Object.assign(rule, obj);
await updateRules(rules);
(pattern, obj) => {
save((prev) => {
if (
prev.some(
(item) => item.pattern === obj.pattern && item.pattern !== pattern
)
) {
return prev;
}
return prev.map((item) =>
item.pattern === pattern ? { ...item, ...obj } : item
);
});
},
[list, updateRules]
[save]
);
const merge = useCallback(
async (newRules) => {
const rules = [...list];
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);
(rules) => {
save((prev) => {
const adds = checkRules(rules);
if (adds.length === 0) {
return prev;
}
const map = new Map();
// 不进行深度合并
// [...prev, ...adds].forEach((item) => {
// const k = item.pattern;
// map.set(k, { ...(map.get(k) || {}), ...item });
// });
prev.forEach((item) => map.set(item.pattern, item));
adds.forEach((item) => map.set(item.pattern, item));
return [...map.values()];
});
await updateRules(rules);
},
[list, updateRules]
[save]
);
return { list, add, del, clear, put, merge };

View File

@@ -1,51 +1,70 @@
import { createContext, useCallback, useContext, useMemo } from "react";
import Alert from "@mui/material/Alert";
import { STOKEY_SETTING, DEFAULT_SETTING, KV_SETTING_KEY } from "../config";
import { useStorage } from "./Storage";
import { trySyncSetting } from "../libs/sync";
import { createContext, useCallback, useContext, useMemo } from "react";
import { debounce } from "../libs/utils";
import { useSyncMeta } from "./Sync";
import { debounceSyncMeta } from "../libs/storage";
import Loading from "./Loading";
const SettingContext = createContext({
setting: null,
updateSetting: async () => {},
reloadSetting: async () => {},
setting: DEFAULT_SETTING,
updateSetting: () => {},
reloadSetting: () => {},
});
export function SettingProvider({ children }) {
const { data, update, reload } = useStorage(STOKEY_SETTING, DEFAULT_SETTING);
const { updateSyncMeta } = useSyncMeta();
const syncSetting = useMemo(
() =>
debounce(() => {
trySyncSetting();
}, [2000]),
[]
);
const {
data: setting,
isLoading,
update,
reload,
} = useStorage(STOKEY_SETTING, DEFAULT_SETTING, KV_SETTING_KEY);
const updateSetting = useCallback(
async (obj) => {
await update(obj);
await updateSyncMeta(KV_SETTING_KEY);
syncSetting();
(objOrFn) => {
update(objOrFn);
debounceSyncMeta(KV_SETTING_KEY);
},
[update, syncSetting, updateSyncMeta]
[update]
);
if (!data) {
return;
const updateChild = useCallback(
(key) => async (obj) => {
updateSetting((prev) => ({
...prev,
[key]: { ...(prev?.[key] || {}), ...obj },
}));
},
[updateSetting]
);
const value = useMemo(
() => ({
setting,
updateSetting,
updateChild,
reloadSetting: reload,
}),
[setting, updateSetting, updateChild, reload]
);
if (isLoading) {
return <Loading />;
}
if (!setting) {
<center>
<Alert severity="error" sx={{ maxWidth: 600, margin: "60px auto" }}>
<p>数据加载出错请刷新页面或卸载后重新安装</p>
<p>
Data loading error, please refresh the page or uninstall and
reinstall.
</p>
</Alert>
</center>;
}
return (
<SettingContext.Provider
value={{
setting: data,
updateSetting,
reloadSetting: reload,
}}
>
{children}
</SettingContext.Provider>
<SettingContext.Provider value={value}>{children}</SettingContext.Provider>
);
}

View File

@@ -6,13 +6,14 @@ export function useShortcut(action) {
const { setting, updateSetting } = useSetting();
const shortcuts = setting?.shortcuts || DEFAULT_SHORTCUTS;
const shortcut = shortcuts[action] || [];
const setShortcut = useCallback(
async (val) => {
Object.assign(shortcuts, { [action]: val });
await updateSetting({ shortcuts });
(val) => {
updateSetting((prev) => ({
...prev,
shortcuts: { ...(prev?.shortcuts || {}), [action]: val },
}));
},
[action, shortcuts, updateSetting]
[action, updateSetting]
);
return { shortcut, setShortcut };

View File

@@ -1,70 +1,144 @@
import { useCallback, useEffect, useState } from "react";
import { storage } from "../libs/storage";
import { kissLog } from "../libs/log";
import { syncData } from "../libs/sync";
import { useDebouncedCallback } from "./DebouncedCallback";
/**
* 用于将组件状态与 Storage 同步
*
* @param {*} key
* @param {*} defaultVal 需为调用hook外的常量
* @returns
* @param {string} key 用于在 Storage 中存取值的键
* @param {*} defaultVal 默认值。建议在组件外定义为常量
* @param {string} [syncKey=""] 用于远端同步的可选键名
* @returns {{
* data: *,
* save: (valueOrFn: any | ((prevData: any) => any)) => void,
* update: (partialDataOrFn: object | ((prevData: object) => object)) => void,
* remove: () => Promise<void>,
* reload: () => Promise<void>
* }}
*/
export function useStorage(key, defaultVal) {
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
export function useStorage(key, defaultVal = null, syncKey = "") {
const [isLoading, setIsLoading] = useState(true);
const [data, setData] = useState(defaultVal);
const save = useCallback(
async (val) => {
setData(val);
await storage.setObj(key, val);
},
[key]
);
// 首次加载数据
useEffect(() => {
let isMounted = true;
const update = useCallback(
async (obj) => {
setData((pre = {}) => ({ ...pre, ...obj }));
await storage.putObj(key, obj);
},
[key]
);
const remove = useCallback(async () => {
setData(null);
await storage.del(key);
}, [key]);
const reload = useCallback(async () => {
try {
setLoading(true);
const val = await storage.getObj(key);
if (val) {
setData(val);
const loadInitialData = async () => {
try {
const storedVal = await storage.getObj(key);
if (storedVal === undefined || storedVal === null) {
await storage.setObj(key, defaultVal);
} else if (isMounted) {
setData(storedVal);
}
} catch (err) {
kissLog(`storage load error for key: ${key}`, err);
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
loadInitialData();
return () => {
isMounted = false;
};
}, [key, defaultVal]);
// 远端同步
const runSync = useCallback(async (keyToSync, valueToSync) => {
try {
const { value, isNew } = await syncData(keyToSync, valueToSync);
if (isNew) {
setData(value);
}
} catch (error) {
kissLog("Sync failed", keyToSync);
}
}, []);
const debouncedSync = useDebouncedCallback(runSync, 3000);
// 持久化
useEffect(() => {
if (isLoading) {
return;
}
if (data === null) {
return;
}
storage.setObj(key, data).catch((err) => {
kissLog(`storage save error for key: ${key}`, err);
});
// 触发远端同步
if (syncKey) {
debouncedSync(syncKey, data);
}
}, [key, syncKey, isLoading, data, debouncedSync]);
/**
* 全量替换状态值
* @param {any | ((prevData: any) => any)} valueOrFn 新的值或一个返回新值的函数。
*/
const save = useCallback((valueOrFn) => {
// kissLog("save storage:", valueOrFn);
setData((prevData) =>
typeof valueOrFn === "function" ? valueOrFn(prevData) : valueOrFn
);
}, []);
/**
* 合并对象到当前状态(假设状态是一个对象)。
* @param {object | ((prevData: object) => object)} partialDataOrFn 要合并的对象或一个返回该对象的函数。
*/
const update = useCallback((partialDataOrFn) => {
// kissLog("update storage:", partialDataOrFn);
setData((prevData) => {
const partialData =
typeof partialDataOrFn === "function"
? partialDataOrFn(prevData)
: partialDataOrFn;
// 确保 preData 是一个对象,避免展开 null 或 undefined
const baseObj =
typeof prevData === "object" && prevData !== null ? prevData : {};
return { ...baseObj, ...partialData };
});
}, []);
/**
* 从 Storage 中删除该值,并将状态重置为 null。
*/
const remove = useCallback(async () => {
// kissLog("remove storage:");
try {
await storage.del(key);
setData(null);
} catch (err) {
kissLog(err, "storage reload");
} finally {
setLoading(false);
kissLog(`storage remove error for key: ${key}`, err);
}
}, [key]);
useEffect(() => {
(async () => {
try {
setLoading(true);
const val = await storage.getObj(key);
if (val) {
setData(val);
} else if (defaultVal) {
setData(defaultVal);
await storage.setObj(key, defaultVal);
}
} catch (err) {
kissLog(err, "storage load");
} finally {
setLoading(false);
}
})();
/**
* 从 Storage 重新加载数据以覆盖当前状态。
*/
const reload = useCallback(async () => {
// kissLog("reload storage:");
try {
const storedVal = await storage.getObj(key);
setData(storedVal ?? defaultVal);
} catch (err) {
kissLog(`storage reload error for key: ${key}`, err);
// setData(defaultVal);
}
}, [key, defaultVal]);
return { data, save, update, remove, reload, loading };
return { data, save, update, remove, reload, isLoading };
}

View File

@@ -2,7 +2,6 @@ import { DEFAULT_SUBRULES_LIST, DEFAULT_OW_RULE } from "../config";
import { useSetting } from "./Setting";
import { useCallback, useEffect, useMemo, useState } from "react";
import { loadOrFetchSubRules } from "../libs/subRules";
import { delSubRules } from "../libs/storage";
import { kissLog } from "../libs/log";
/**
@@ -19,50 +18,36 @@ export function useSubRules() {
const selectedUrl = selectedSub.url;
const selectSub = useCallback(
async (url) => {
const subrulesList = [...list];
subrulesList.forEach((item) => {
if (item.url === url) {
item.selected = true;
} else {
item.selected = false;
}
});
await updateSetting({ subrulesList });
(url) => {
updateSetting((prev) => ({
...prev,
subrulesList: prev.subrulesList.map((item) => ({
...item,
selected: item.url === url,
})),
}));
},
[list, updateSetting]
);
const updateSub = useCallback(
async (url, obj) => {
const subrulesList = [...list];
subrulesList.forEach((item) => {
if (item.url === url) {
Object.assign(item, obj);
}
});
await updateSetting({ subrulesList });
},
[list, updateSetting]
[updateSetting]
);
const addSub = useCallback(
async (url) => {
const subrulesList = [...list];
subrulesList.push({ url, selected: false });
await updateSetting({ subrulesList });
(url) => {
updateSetting((prev) => ({
...prev,
subrulesList: [...prev.subrulesList, { url, selected: false }],
}));
},
[list, updateSetting]
[updateSetting]
);
const delSub = useCallback(
async (url) => {
let subrulesList = [...list];
subrulesList = subrulesList.filter((item) => item.url !== url);
await updateSetting({ subrulesList });
await delSubRules(url);
(url) => {
updateSetting((prev) => ({
...prev,
subrulesList: prev.subrulesList.filter((item) => item.url !== url),
}));
},
[list, updateSetting]
[updateSetting]
);
useEffect(() => {
@@ -73,7 +58,7 @@ export function useSubRules() {
const rules = await loadOrFetchSubRules(selectedUrl);
setSelectedRules(rules);
} catch (err) {
kissLog(err, "loadOrFetchSubRules");
kissLog("loadOrFetchSubRules", err);
} finally {
setLoading(false);
}
@@ -84,7 +69,6 @@ export function useSubRules() {
return {
subList: list,
selectSub,
updateSub,
addSub,
delSub,
selectedSub,
@@ -100,15 +84,9 @@ export function useSubRules() {
* @returns
*/
export function useOwSubRule() {
const { setting, updateSetting } = useSetting();
const { owSubrule = DEFAULT_OW_RULE } = setting;
const updateOwSubrule = useCallback(
async (obj) => {
await updateSetting({ owSubrule: { ...owSubrule, ...obj } });
},
[owSubrule, updateSetting]
);
const { setting, updateChild } = useSetting();
const owSubrule = setting?.owSubrule || DEFAULT_OW_RULE;
const updateOwSubrule = updateChild("owSubrule");
return { owSubrule, updateOwSubrule };
}

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { STOKEY_SYNC, DEFAULT_SYNC } from "../config";
import { useStorage } from "./Storage";
@@ -16,15 +16,24 @@ export function useSync() {
* @returns
*/
export function useSyncMeta() {
const { sync, updateSync } = useSync();
const { updateSync } = useSync();
const updateSyncMeta = useCallback(
async (key) => {
const syncMeta = sync?.syncMeta || {};
syncMeta[key] = { ...(syncMeta[key] || {}), updateAt: Date.now() };
await updateSync({ syncMeta });
(key) => {
updateSync((prevSync) => {
const newSyncMeta = {
...(prevSync?.syncMeta || {}),
[key]: {
...(prevSync?.syncMeta?.[key] || {}),
updateAt: Date.now(),
},
};
return { syncMeta: newSyncMeta };
});
},
[sync?.syncMeta, updateSync]
[updateSync]
);
return { updateSyncMeta };
}
@@ -37,25 +46,32 @@ export function useSyncCaches() {
const { sync, updateSync, reloadSync } = useSync();
const updateDataCache = useCallback(
async (url) => {
const dataCaches = sync?.dataCaches || {};
dataCaches[url] = Date.now();
await updateSync({ dataCaches });
(url) => {
updateSync((prevSync) => ({
dataCaches: {
...(prevSync?.dataCaches || {}),
[url]: Date.now(),
},
}));
},
[sync, updateSync]
[updateSync]
);
const deleteDataCache = useCallback(
async (url) => {
const dataCaches = sync?.dataCaches || {};
delete dataCaches[url];
await updateSync({ dataCaches });
(url) => {
updateSync((prevSync) => {
const newDataCaches = { ...(prevSync?.dataCaches || {}) };
delete newDataCaches[url];
return { dataCaches: newDataCaches };
});
},
[sync, updateSync]
[updateSync]
);
const dataCaches = useMemo(() => sync?.dataCaches || {}, [sync?.dataCaches]);
return {
dataCaches: sync?.dataCaches || {},
dataCaches,
updateDataCache,
deleteDataCache,
reloadSync,

View File

@@ -1,18 +1,10 @@
import { useCallback } from "react";
import { DEFAULT_TRANBOX_SETTING } from "../config";
import { useSetting } from "./Setting";
export function useTranbox() {
const { setting, updateSetting } = useSetting();
const { setting, updateChild } = useSetting();
const tranboxSetting = setting?.tranboxSetting || DEFAULT_TRANBOX_SETTING;
const updateTranbox = useCallback(
async (obj) => {
Object.assign(tranboxSetting, obj);
await updateSetting({ tranboxSetting });
},
[tranboxSetting, updateSetting]
);
const updateTranbox = updateChild("tranboxSetting");
return { tranboxSetting, updateTranbox };
}

View File

@@ -1,74 +0,0 @@
import { useEffect } from "react";
import { useState } from "react";
import { tryDetectLang } from "../libs";
import { apiTranslate } from "../apis";
import { DEFAULT_TRANS_APIS } from "../config";
import { kissLog } from "../libs/log";
/**
* 翻译hook
* @param {*} q
* @param {*} rule
* @param {*} setting
* @returns
*/
export function useTranslate(q, rule, setting, docInfo) {
const [text, setText] = useState("");
const [loading, setLoading] = useState(true);
const [sameLang, setSamelang] = useState(false);
const { translator, fromLang, toLang, detectRemote, skipLangs = [] } = rule;
useEffect(() => {
(async () => {
try {
if (!q.replace(/\[(\d+)\]/g, "").trim()) {
setText(q);
setSamelang(false);
return;
}
let deLang = "";
if (fromLang === "auto") {
deLang = await tryDetectLang(
q,
detectRemote === "true",
setting.langDetector
);
}
if (deLang && (toLang.includes(deLang) || skipLangs.includes(deLang))) {
setSamelang(true);
} else {
const [trText, isSame] = await apiTranslate({
translator,
text: q,
fromLang,
toLang,
apiSetting: {
...DEFAULT_TRANS_APIS[translator],
...(setting.transApis[translator] || {}),
},
docInfo,
});
setText(trText);
setSamelang(isSame);
}
} catch (err) {
kissLog(err, "translate");
} finally {
setLoading(false);
}
})();
}, [
q,
translator,
fromLang,
toLang,
detectRemote,
skipLangs,
setting,
docInfo,
]);
return { text, sameLang, loading };
}

View File

@@ -6,7 +6,7 @@ const parseMSToken = (token) => {
try {
return JSON.parse(atob(token.split(".")[1])).exp;
} catch (err) {
kissLog(err, "parseMSToken");
kissLog("parseMSToken", err);
}
return 0;
};

View File

@@ -131,17 +131,15 @@ const queueMap = new Map();
/**
* 获取批处理实例
* @param {*} translator
* @returns
*/
export const getBatchQueue = (args, opts) => {
const { translator, from, to } = args;
const key = `${translator}_${from}_${to}`;
export const getBatchQueue = (args) => {
const { from, to, apiSetting } = args;
const key = `${apiSetting.apiSlug}_${from}_${to}`;
if (queueMap.has(key)) {
return queueMap.get(key);
}
const queue = BatchQueue(args, opts);
const queue = BatchQueue(args, apiSetting);
queueMap.set(key, queue);
return queue;
};

View File

@@ -8,7 +8,7 @@ function _browser() {
try {
return require("webextension-polyfill");
} catch (err) {
// kissLog(err, "browser");
// kissLog("browser", err);
}
}

View File

@@ -43,7 +43,7 @@ export const getHttpCache = async (input, init) => {
return await parseResponse(res);
}
} catch (err) {
kissLog(err, "get cache");
kissLog("get cache", err);
}
return null;
};
@@ -54,7 +54,12 @@ export const getHttpCache = async (input, init) => {
* @param {*} init
* @param {*} data
*/
export const putHttpCache = async (input, init, data) => {
export const putHttpCache = async (
input,
init,
data,
maxAge = DEFAULT_CACHE_TIMEOUT // todo: 从设置里面读取最大缓存时间
) => {
try {
const req = await newCacheReq(input, init);
const cache = await caches.open(CACHE_NAME);
@@ -62,13 +67,13 @@ export const putHttpCache = async (input, init, data) => {
status: 200,
headers: {
"Content-Type": "application/json",
"Cache-Control": `max-age=${DEFAULT_CACHE_TIMEOUT}`,
"Cache-Control": `max-age=${maxAge}`,
},
});
// res.headers.set("Cache-Control", `max-age=${DEFAULT_CACHE_TIMEOUT}`);
// res.headers.set("Cache-Control", `max-age=${maxAge}`);
await cache.put(req, res);
} catch (err) {
kissLog(err, "put cache");
kissLog("put cache", err);
}
};

View File

@@ -57,7 +57,7 @@ export const fetchPatcher = async (input, init = {}, opts) => {
try {
timeout = (await getSettingWithDefault()).httpTimeout;
} catch (err) {
kissLog(err, "getSettingWithDefault");
kissLog("getSettingWithDefault", err);
}
}
if (!timeout) {

View File

@@ -28,7 +28,7 @@ export const tryClearCaches = async () => {
try {
caches.delete(CACHE_NAME);
} catch (err) {
kissLog(err, "clean caches");
kissLog("clean caches", err);
}
};
@@ -48,7 +48,7 @@ export const tryDetectLang = async (
try {
lang = await langdetectMap[langDetector](q);
} catch (err) {
kissLog(err, "detect lang remote");
kissLog("detect lang remote", err);
}
}
@@ -57,7 +57,7 @@ export const tryDetectLang = async (
const res = await browser?.i18n?.detectLanguage(q);
lang = res?.languages?.[0]?.language;
} catch (err) {
kissLog(err, "detect lang local");
kissLog("detect lang local", err);
}
}

View File

@@ -1,8 +1,8 @@
import {
DEFAULT_INPUT_RULE,
DEFAULT_TRANS_APIS,
DEFAULT_INPUT_SHORTCUT,
OPT_LANGS_LIST,
DEFAULT_API_SETTING,
} from "../config";
import { genEventName, removeEndchar, matchInputStr, sleep } from "./utils";
import { stepShortcutRegister } from "./shortcut";
@@ -87,7 +87,7 @@ export default function inputTranslate({
inputRule: {
transOpen,
triggerShortcut,
translator,
apiSlug,
fromLang,
toLang,
triggerCount,
@@ -100,7 +100,8 @@ export default function inputTranslate({
return;
}
const apiSetting = transApis?.[translator] || DEFAULT_TRANS_APIS[translator];
const apiSetting =
transApis.find((api) => api.apiSlug === apiSlug) || DEFAULT_API_SETTING;
if (triggerShortcut.length === 0) {
triggerShortcut = DEFAULT_INPUT_SHORTCUT;
triggerCount = 1;
@@ -156,7 +157,7 @@ export default function inputTranslate({
addLoading(node, loadingId);
const [trText, isSame] = await apiTranslate({
translator,
apiSlug,
text,
fromLang,
toLang,
@@ -188,7 +189,7 @@ export default function inputTranslate({
collapseToEnd(node);
}
} catch (err) {
kissLog(err, "translate input");
kissLog("translate input", err);
} finally {
removeLoading(node, loadingId);
}

View File

@@ -1,12 +1,126 @@
/**
* 日志函数
* @param {*} msg
* @param {*} type
*/
export const kissLog = (msg, type) => {
let prefix = `[KISS-Translator]`;
if (type) {
prefix += `[${type}]`;
}
console.log(`${prefix} ${msg}`);
// 定义日志级别
export const LogLevel = {
DEBUG: { value: 0, name: "DEBUG", color: "#6495ED" }, // 宝蓝色
INFO: { value: 1, name: "INFO", color: "#4CAF50" }, // 绿色
WARN: { value: 2, name: "WARN", color: "#FFC107" }, // 琥珀色
ERROR: { value: 3, name: "ERROR", color: "#F44336" }, // 红色
SILENT: { value: 4, name: "SILENT" }, // 特殊级别,用于关闭所有日志
};
class Logger {
/**
* @param {object} [options={}] 配置选项
* @param {LogLevel} [options.level=LogLevel.INFO] 要显示的最低日志级别
* @param {string} [options.prefix='App'] 日志前缀,用于区分模块
*/
constructor(options = {}) {
this.config = {
level: options.level || LogLevel.INFO,
prefix: options.prefix || "KISS-Translator",
};
}
/**
* 动态设置日志级别
* @param {LogLevel} level - 新的日志级别
*/
setLevel(level) {
if (level && typeof level.value === "number") {
this.config.level = level;
console.log(`[${this.config.prefix}] Log level set to ${level.name}`);
}
}
/**
* 核心日志记录方法
* @private
* @param {LogLevel} level - 当前消息的日志级别
* @param {...any} args - 要记录的多个参数,可以是任何类型
*/
_log(level, ...args) {
// 如果当前级别低于配置的最低级别,则不打印
if (level.value < this.config.level.value) {
return;
}
const timestamp = new Date().toISOString();
const prefixStr = `[${this.config.prefix}]`;
const levelStr = `[${level.name}]`;
// 判断是否在浏览器环境并且浏览器支持 console 样式
const isBrowser =
typeof window !== "undefined" && typeof window.document !== "undefined";
if (isBrowser) {
// 在浏览器中使用颜色高亮
const consoleMethod = this._getConsoleMethod(level);
consoleMethod(
`%c${timestamp} %c${prefixStr} %c${levelStr}`,
"color: gray; font-weight: lighter;", // 时间戳样式
"color: #7c57e0; font-weight: bold;", // 前缀样式 (紫色)
`color: ${level.color}; font-weight: bold;`, // 日志级别样式
...args
);
} else {
// 在 Node.js 或不支持样式的环境中,输出纯文本
const consoleMethod = this._getConsoleMethod(level);
consoleMethod(timestamp, prefixStr, levelStr, ...args);
}
}
/**
* 根据日志级别获取对应的 console 方法
* @private
*/
_getConsoleMethod(level) {
switch (level) {
case LogLevel.ERROR:
return console.error;
case LogLevel.WARN:
return console.warn;
case LogLevel.INFO:
return console.info;
default:
return console.log;
}
}
/**
* 记录 DEBUG 级别的日志
* @param {...any} args
*/
debug(...args) {
this._log(LogLevel.DEBUG, ...args);
}
/**
* 记录 INFO 级别的日志
* @param {...any} args
*/
info(...args) {
this._log(LogLevel.INFO, ...args);
}
/**
* 记录 WARN 级别的日志
* @param {...any} args
*/
warn(...args) {
this._log(LogLevel.WARN, ...args);
}
/**
* 记录 ERROR 级别的日志
* @param {...any} args
*/
error(...args) {
this._log(LogLevel.ERROR, ...args);
}
}
const isDevelopment =
typeof process === "undefined" || process.env.NODE_ENV !== "development";
const defaultLevel = isDevelopment ? LogLevel.DEBUG : LogLevel.INFO;
export const logger = new Logger({ level: defaultLevel });
export const kissLog = logger.info.bind(logger);

View File

@@ -70,7 +70,7 @@ class TaskPool {
const res = await fn(args);
resolve(res);
} catch (err) {
kissLog(err, "task");
kissLog("task pool", err);
if (retry < this.#maxRetry) {
setTimeout(() => {
this.#pool.unshift({ ...task, retry: retry + 1 }); // unshift 保证重试任务优先

View File

@@ -2,12 +2,12 @@ import { matchValue, type, isMatch } from "./utils";
import {
GLOBAL_KEY,
REMAIN_KEY,
OPT_TRANS_ALL,
OPT_STYLE_ALL,
OPT_LANGS_FROM,
OPT_LANGS_TO,
// OPT_TIMING_ALL,
GLOBLA_RULE,
DEFAULT_API_TYPE,
} from "../config";
import { loadOrFetchSubRules } from "./subRules";
import { getRulesWithDefault, setRules } from "./storage";
@@ -50,7 +50,7 @@ export const matchRule = async (
rules.splice(-1, 0, ...subRules);
}
} catch (err) {
kissLog(err, "load injectRules");
kissLog("load injectRules", err);
}
}
@@ -86,7 +86,7 @@ export const matchRule = async (
});
[
"translator",
"apiSlug",
"fromLang",
"toLang",
"transOpen",
@@ -158,7 +158,7 @@ export const checkRules = (rules) => {
parentStyle,
injectJs,
injectCss,
translator,
apiSlug,
fromLang,
toLang,
textStyle,
@@ -193,7 +193,7 @@ export const checkRules = (rules) => {
injectCss: type(injectCss) === "string" ? injectCss : "",
bgColor: type(bgColor) === "string" ? bgColor : "",
textDiyStyle: type(textDiyStyle) === "string" ? textDiyStyle : "",
translator: matchValue([GLOBAL_KEY, ...OPT_TRANS_ALL], translator),
apiSlug: apiSlug?.trim() || DEFAULT_API_TYPE,
fromLang: matchValue([GLOBAL_KEY, ...fromLangs], fromLang),
toLang: matchValue([GLOBAL_KEY, ...toLangs], toLang),
textStyle: matchValue([GLOBAL_KEY, ...OPT_STYLE_ALL], textStyle),

View File

@@ -33,7 +33,7 @@ export class ShadowRootMonitor {
try {
monitorInstance.callback(shadowRoot);
} catch (error) {
kissLog(error, "Error in ShadowRootMonitor callback");
kissLog("Error in ShadowRootMonitor callback", error);
}
}
return shadowRoot;

View File

@@ -1,112 +1,106 @@
import { isSameSet } from "./utils";
/**
* 键盘快捷键监听
* @param {*} fn
* @param {*} target
* @param {*} timeout
* @returns
* 键盘快捷键监听
* @param {(pressedKeys: Set<string>, event: KeyboardEvent) => void} onKeyDown - Keydown 回调
* @param {(pressedKeys: Set<string>, event: KeyboardEvent) => void} onKeyUp - Keyup 回调
* @param {EventTarget} target - 监听的目标元素
* @returns {() => void} - 用于注销监听的函数
*/
export const shortcutListener = (fn, target = document, timeout = 3000) => {
const allkeys = new Set();
const curkeys = new Set();
let timer = null;
export const shortcutListener = (
onKeyDown = () => {},
onKeyUp = () => {},
target = document
) => {
const pressedKeys = new Set();
const handleKeydown = (e) => {
timer && clearTimeout(timer);
timer = setTimeout(() => {
allkeys.clear();
curkeys.clear();
clearTimeout(timer);
timer = null;
}, timeout);
if (e.code) {
allkeys.add(e.code);
curkeys.add(e.code);
fn([...curkeys], [...allkeys]);
}
const handleKeyDown = (e) => {
if (pressedKeys.has(e.code)) return;
pressedKeys.add(e.code);
onKeyDown(new Set(pressedKeys), e);
};
const handleKeyup = (e) => {
curkeys.delete(e.code);
if (curkeys.size === 0) {
fn([...curkeys], [...allkeys]);
allkeys.clear();
}
const handleKeyUp = (e) => {
// onKeyUp 应该在 key 从集合中移除前触发,以便判断组合键
onKeyUp(new Set(pressedKeys), e);
pressedKeys.delete(e.code);
};
target.addEventListener("keydown", handleKeydown, true);
target.addEventListener("keyup", handleKeyup, true);
target.addEventListener("keydown", handleKeyDown);
target.addEventListener("keyup", handleKeyUp);
return () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
target.removeEventListener("keydown", handleKeydown);
target.removeEventListener("keyup", handleKeyup);
target.removeEventListener("keydown", handleKeyDown);
target.removeEventListener("keyup", handleKeyUp);
pressedKeys.clear();
};
};
/**
* 注册键盘快捷键
* @param {*} targetKeys
* @param {*} fn
* @param {*} target
* @returns
* @param {string[]} targetKeys - 目标快捷键数组
* @param {() => void} fn - 匹配成功后执行的回调
* @param {EventTarget} target - 监听目标
* @returns {() => void} - 注销函数
*/
export const shortcutRegister = (targetKeys = [], fn, target = document) => {
return shortcutListener((curkeys) => {
if (
targetKeys.length > 0 &&
isSameSet(new Set(targetKeys), new Set(curkeys))
) {
if (targetKeys.length === 0) return () => {};
const targetKeySet = new Set(targetKeys);
const onKeyDown = (pressedKeys, event) => {
if (targetKeySet.size > 0 && isSameSet(targetKeySet, pressedKeys)) {
event.preventDefault();
event.stopPropagation();
fn();
}
}, target);
};
const onKeyUp = () => {};
return shortcutListener(onKeyDown, onKeyUp, target);
};
/**
* 高阶函数:为目标函数增加计次和超时重置功能
* @param {() => void} fn - 需要被包装的函数
* @param {number} step - 需要触发的次数
* @param {number} timeout - 超时毫秒数
* @returns {() => void} - 包装后的新函数
*/
const withStepCounter = (fn, step, timeout) => {
let count = 0;
let timer = null;
return () => {
timer && clearTimeout(timer);
timer = setTimeout(() => {
count = 0;
}, timeout);
count++;
if (count === step) {
count = 0;
clearTimeout(timer);
fn();
}
};
};
/**
* 注册连续快捷键
* @param {*} targetKeys
* @param {*} fn
* @param {*} step
* @param {*} timeout
* @param {*} target
* @returns
* @param {string[]} targetKeys - 目标快捷键数组
* @param {() => void} fn - 成功回调
* @param {number} step - 连续触发次数
* @param {number} timeout - 每次触发的间隔超时
* @param {EventTarget} target - 监听目标
* @returns {() => void} - 注销函数
*/
export const stepShortcutRegister = (
targetKeys = [],
fn,
step = 3,
step = 2,
timeout = 500,
target = document
) => {
let count = 0;
let pre = Date.now();
let timer;
return shortcutListener((curkeys, allkeys) => {
timer && clearTimeout(timer);
timer = setTimeout(() => {
clearTimeout(timer);
count = 0;
}, timeout);
if (targetKeys.length > 0 && curkeys.length === 0) {
const now = Date.now();
if (
(count === 0 || now - pre < timeout) &&
isSameSet(new Set(targetKeys), new Set(allkeys))
) {
count++;
if (count === step) {
count = 0;
fn();
}
} else {
count = 0;
}
pre = now;
}
}, target);
const steppedFn = withStepCounter(fn, step, timeout);
return shortcutRegister(targetKeys, steppedFn, target);
};

View File

@@ -15,6 +15,7 @@ import {
import { isExt, isGm } from "./client";
import { browser } from "./browser";
import { kissLog } from "./log";
import { debounce } from "./utils";
async function set(key, val) {
if (isExt) {
@@ -90,7 +91,7 @@ export const getSettingWithDefault = async () => ({
...((await getSetting()) || {}),
});
export const setSetting = (val) => setObj(STOKEY_SETTING, val);
export const updateSetting = (obj) => putObj(STOKEY_SETTING, obj);
export const putSetting = (obj) => putObj(STOKEY_SETTING, obj);
/**
* 规则列表
@@ -122,14 +123,20 @@ export const setSubRules = (url, val) =>
export const getFab = () => getObj(STOKEY_FAB);
export const getFabWithDefault = async () => (await getFab()) || {};
export const setFab = (obj) => setObj(STOKEY_FAB, obj);
export const updateFab = (obj) => putObj(STOKEY_FAB, obj);
export const putFab = (obj) => putObj(STOKEY_FAB, obj);
/**
* 数据同步
*/
export const getSync = () => getObj(STOKEY_SYNC);
export const getSyncWithDefault = async () => (await getSync()) || DEFAULT_SYNC;
export const updateSync = (obj) => putObj(STOKEY_SYNC, obj);
export const putSync = (obj) => putObj(STOKEY_SYNC, obj);
export const putSyncMeta = async (key) => {
const { syncMeta = {} } = await getSyncWithDefault();
syncMeta[key] = { ...(syncMeta[key] || {}), updateAt: Date.now() };
await putSync({ syncMeta });
};
export const debounceSyncMeta = debounce(putSyncMeta, 300);
/**
* ms auth
@@ -156,6 +163,6 @@ export const tryInitDefaultData = async () => {
BUILTIN_RULES
);
} catch (err) {
kissLog(err, "init default");
kissLog("init default", err);
}
};

View File

@@ -1,7 +1,7 @@
import { GLOBAL_KEY } from "../config";
import {
getSyncWithDefault,
updateSync,
putSync,
setSubRules,
getSubRules,
} from "./storage";
@@ -17,7 +17,7 @@ import { kissLog } from "./log";
const updateSyncDataCache = async (url) => {
const { dataCaches = {} } = await getSyncWithDefault();
dataCaches[url] = Date.now();
await updateSync({ dataCaches });
await putSync({ dataCaches });
};
/**
@@ -47,7 +47,7 @@ export const syncAllSubRules = async (subrulesList) => {
await syncSubRules(subrules.url);
await updateSyncDataCache(subrules.url);
} catch (err) {
kissLog(err, `sync subrule error: ${subrules.url}`);
kissLog(`sync subrule error: ${subrules.url}`, err);
}
}
};
@@ -65,10 +65,10 @@ export const trySyncAllSubRules = async ({ subrulesList }) => {
if (now - subRulesSyncAt > interval) {
// 同步订阅规则
await syncAllSubRules(subrulesList);
await updateSync({ subRulesSyncAt: now });
await putSync({ subRulesSyncAt: now });
}
} catch (err) {
kissLog(err, "try sync all subrules");
kissLog("try sync all subrules", err);
}
};

View File

@@ -9,7 +9,7 @@ import {
} from "../config";
import {
getSyncWithDefault,
updateSync,
putSync,
getSettingWithDefault,
getRulesWithDefault,
getWordsWithDefault,
@@ -61,7 +61,7 @@ const syncByWorker = async (data, { syncUrl, syncKey }) => {
return await apiSyncData(`${syncUrl}/sync`, syncKey, data);
};
const syncData = async (key, valueFn) => {
export const syncData = async (key, value) => {
const {
syncType,
syncUrl,
@@ -70,13 +70,14 @@ const syncData = async (key, valueFn) => {
syncMeta = {},
} = await getSyncWithDefault();
if (!syncUrl || !syncKey || (syncType === OPT_SYNCTYPE_WEBDAV && !syncUser)) {
return;
throw new Error("sync args err");
}
let { updateAt = 0, syncAt = 0 } = syncMeta[key] || {};
syncAt === 0 && (updateAt = 0);
if (syncAt === 0) {
updateAt = 0; // 没有同步过,更新时间置零
}
const value = await valueFn();
const data = {
key,
value: JSON.stringify(value),
@@ -93,13 +94,20 @@ const syncData = async (key, valueFn) => {
? await syncByWebdav(data, args)
: await syncByWorker(data, args);
if (!res) {
throw new Error("sync data got err", key);
}
const newVal = JSON.parse(res.value);
const isNew = res.updateAt > updateAt;
syncMeta[key] = {
updateAt: res.updateAt,
syncAt: Date.now(),
};
await updateSync({ syncMeta });
await putSync({ syncMeta });
return { value: JSON.parse(res.value), isNew: res.updateAt > updateAt };
return { value: newVal, isNew };
};
/**
@@ -107,7 +115,8 @@ const syncData = async (key, valueFn) => {
* @returns
*/
const syncSetting = async () => {
const res = await syncData(KV_SETTING_KEY, getSettingWithDefault);
const value = await getSettingWithDefault();
const res = await syncData(KV_SETTING_KEY, value);
if (res?.isNew) {
await setSetting(res.value);
}
@@ -117,7 +126,7 @@ export const trySyncSetting = async () => {
try {
await syncSetting();
} catch (err) {
kissLog(err, "sync setting");
kissLog("sync setting", err.message);
}
};
@@ -126,7 +135,8 @@ export const trySyncSetting = async () => {
* @returns
*/
const syncRules = async () => {
const res = await syncData(KV_RULES_KEY, getRulesWithDefault);
const value = await getRulesWithDefault();
const res = await syncData(KV_RULES_KEY, value);
if (res?.isNew) {
await setRules(res.value);
}
@@ -136,7 +146,7 @@ export const trySyncRules = async () => {
try {
await syncRules();
} catch (err) {
kissLog(err, "sync user rules");
kissLog("sync user rules", err.message);
}
};
@@ -145,7 +155,8 @@ export const trySyncRules = async () => {
* @returns
*/
const syncWords = async () => {
const res = await syncData(KV_WORDS_KEY, getWordsWithDefault);
const value = await getWordsWithDefault();
const res = await syncData(KV_WORDS_KEY, value);
if (res?.isNew) {
await setWords(res.value);
}
@@ -155,7 +166,7 @@ export const trySyncWords = async () => {
try {
await syncWords();
} catch (err) {
kissLog(err, "sync fav words");
kissLog("sync fav words", err.message);
}
};

View File

@@ -7,9 +7,9 @@ import {
OPT_STYLE_FUZZY,
GLOBLA_RULE,
DEFAULT_SETTING,
DEFAULT_TRANS_APIS,
DEFAULT__MOUSEHOVER_KEY,
DEFAULT_MOUSEHOVER_KEY,
OPT_STYLE_NONE,
DEFAULT_API_SETTING,
} from "../config";
import interpreter from "./interpreter";
import { ShadowRootMonitor } from "./shadowroot";
@@ -356,7 +356,7 @@ export class Translator {
this.#startObserveShadowRoot(shadowRoot);
});
} catch (err) {
kissLog(err, "findAllShadowRoots");
kissLog("findAllShadowRoots", err);
}
}
}
@@ -419,7 +419,7 @@ export class Translator {
termPatterns.push(`(${key})`);
this.#termValues.push(value);
} catch (err) {
kissLog(err, `Invalid RegExp for term: "${key}"`);
kissLog(`Invalid RegExp for term: "${key}"`, err);
}
}
}
@@ -556,7 +556,7 @@ export class Translator {
}
}
} catch (err) {
kissLog(err, "无法访问某个 shadowRoot");
kissLog("无法访问某个 shadowRoot", err);
}
// const end = performance.now();
// const duration = end - start;
@@ -839,7 +839,7 @@ export class Translator {
nodes,
});
} catch (err) {
kissLog(err, "transStartHook");
kissLog("transStartHook", err);
}
}
@@ -913,14 +913,14 @@ export class Translator {
innerNode: inner,
});
} catch (err) {
kissLog(err, "transEndHook");
kissLog("transEndHook", err);
}
}
} catch (err) {
// inner.textContent = `[失败]...`;
// todo: 失败重试按钮
wrapper.remove();
kissLog(err, "translateNodeGroup");
kissLog("translateNodeGroup", err);
}
}
@@ -1037,16 +1037,13 @@ export class Translator {
// 发起翻译请求
#translateFetch(text) {
const { translator, fromLang, toLang } = this.#rule;
// const apiSetting = this.#setting.transApis[translator];
const apiSetting = {
...DEFAULT_TRANS_APIS[translator],
...(this.#setting.transApis[translator] || {}),
};
const { apiSlug, fromLang, toLang } = this.#rule;
const apiSetting =
this.#setting.transApis.find((api) => api.apiSlug === apiSlug) ||
DEFAULT_API_SETTING;
return apiTranslate({
text,
translator,
fromLang,
toLang,
apiSetting,
@@ -1150,11 +1147,11 @@ export class Translator {
return;
}
const { translator, fromLang, toLang, hasRichText, textStyle, transOnly } =
const { apiSlug, fromLang, toLang, hasRichText, textStyle, transOnly } =
this.#rule;
const needsRefresh =
appliedRule.translator !== translator ||
appliedRule.apiSlug !== apiSlug ||
appliedRule.fromLang !== fromLang ||
appliedRule.toLang !== toLang ||
appliedRule.hasRichText !== hasRichText;
@@ -1162,7 +1159,7 @@ export class Translator {
// 需要重新翻译
if (needsRefresh) {
Object.assign(appliedRule, {
translator,
apiSlug,
fromLang,
toLang,
hasRichText,
@@ -1207,7 +1204,7 @@ export class Translator {
document.addEventListener("mousemove", this.#boundMouseMoveHandler);
let { mouseHoverKey } = this.#setting.mouseHoverSetting;
if (mouseHoverKey.length === 0) {
mouseHoverKey = DEFAULT__MOUSEHOVER_KEY;
mouseHoverKey = DEFAULT_MOUSEHOVER_KEY;
}
this.#removeKeydownHandler = shortcutRegister(
mouseHoverKey,
@@ -1273,7 +1270,7 @@ export class Translator {
document.title = trText || title;
})
.catch((err) => {
kissLog(err, "tanslate title");
kissLog("tanslate title", err);
});
}
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { limitNumber } from "../../libs/utils";
import { isMobile } from "../../libs/mobile";
import { updateFab } from "../../libs/storage";
import { putFab } from "../../libs/storage";
import { debounce } from "../../libs/utils";
import Paper from "@mui/material/Paper";
@@ -61,7 +61,7 @@ export default function Draggable({
const [hover, setHover] = useState(false);
const [origin, setOrigin] = useState(null);
const [position, setPosition] = useState({ x: left, y: top });
const setFabPosition = useMemo(() => debounce(updateFab, 500), []);
const setFabPosition = useMemo(() => debounce(putFab, 500), []);
const handlePointerDown = (e) => {
!isMobile && e.target.setPointerCapture(e.pointerId);

View File

@@ -142,7 +142,7 @@ export default function Action({ translator, fab }) {
});
};
} catch (err) {
kissLog(err, "registerMenuCommand");
kissLog("registerMenuCommand", err);
}
}, [translator]);

View File

@@ -1,14 +0,0 @@
import { loadingSvg } from "../../libs/svg";
export default function LoadingIcon() {
return (
<span
style={{
display: "inline-block",
width: "1.2em",
height: "1em",
}}
dangerouslySetInnerHTML={{ __html: loadingSvg }}
/>
);
}

View File

@@ -1,208 +0,0 @@
import { useState, useEffect, useMemo } from "react";
import LoadingIcon from "./LoadingIcon";
import {
OPT_STYLE_LINE,
OPT_STYLE_DOTLINE,
OPT_STYLE_DASHLINE,
OPT_STYLE_WAVYLINE,
OPT_STYLE_DASHBOX,
OPT_STYLE_FUZZY,
OPT_STYLE_HIGHLIGHT,
OPT_STYLE_BLOCKQUOTE,
OPT_STYLE_DIY,
DEFAULT_COLOR,
MSG_TRANS_CURRULE,
} from "../../config";
import { useTranslate } from "../../hooks/Translate";
import { styled, css } from "@mui/material/styles";
import { APP_LCNAME } from "../../config";
import interpreter from "../../libs/interpreter";
const LINE_STYLES = {
[OPT_STYLE_LINE]: "solid",
[OPT_STYLE_DOTLINE]: "dotted",
[OPT_STYLE_DASHLINE]: "dashed",
[OPT_STYLE_WAVYLINE]: "wavy",
};
const StyledSpan = styled("span")`
${({ textStyle, textDiyStyle, bgColor }) => {
switch (textStyle) {
case OPT_STYLE_LINE: // 下划线
case OPT_STYLE_DOTLINE: // 点状线
case OPT_STYLE_DASHLINE: // 虚线
case OPT_STYLE_WAVYLINE: // 波浪线
return css`
opacity: 0.6;
-webkit-opacity: 0.6;
text-decoration-line: underline;
text-decoration-style: ${LINE_STYLES[textStyle]};
text-decoration-color: ${bgColor};
text-decoration-thickness: 2px;
text-underline-offset: 0.3em;
-webkit-text-decoration-line: underline;
-webkit-text-decoration-style: ${LINE_STYLES[textStyle]};
-webkit-text-decoration-color: ${bgColor};
-webkit-text-decoration-thickness: 2px;
-webkit-text-underline-offset: 0.3em;
&:hover {
opacity: 1;
-webkit-opacity: 1;
}
`;
case OPT_STYLE_DASHBOX: // 虚线框
return css`
color: ${bgColor || DEFAULT_COLOR};
border: 1px dashed ${bgColor || DEFAULT_COLOR};
background: transparent;
display: block;
padding: 0.2em;
box-sizing: border-box;
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
`;
case OPT_STYLE_FUZZY: // 模糊
return css`
filter: blur(0.2em);
-webkit-filter: blur(0.2em);
&:hover {
filter: none;
-webkit-filter: none;
}
`;
case OPT_STYLE_HIGHLIGHT: // 高亮
return css`
color: #fff;
background-color: ${bgColor || DEFAULT_COLOR};
`;
case OPT_STYLE_BLOCKQUOTE: // 引用
return css`
opacity: 0.6;
-webkit-opacity: 0.6;
display: block;
padding: 0 0.75em;
border-left: 0.25em solid ${bgColor || DEFAULT_COLOR};
&:hover {
opacity: 1;
-webkit-opacity: 1;
}
`;
case OPT_STYLE_DIY: // 自定义
return textDiyStyle;
default:
return ``;
}
}}
`;
export default function Content({ q, keeps, translator, $el }) {
const [rule, setRule] = useState(translator.rule);
const { text, sameLang, loading } = useTranslate(
q,
rule,
translator.setting,
translator.docInfo
);
const {
transOpen,
textStyle,
bgColor,
textDiyStyle,
transOnly,
transTag,
transEndHook,
} = rule;
const { newlineLength } = translator.setting;
const handleKissEvent = (e) => {
const { action, args } = e.detail;
switch (action) {
case MSG_TRANS_CURRULE:
setRule(args);
break;
default:
}
};
useEffect(() => {
window.addEventListener(translator.eventName, handleKissEvent);
return () => {
window.removeEventListener(translator.eventName, handleKissEvent);
};
}, [translator.eventName]);
const gap = useMemo(() => {
if (transOnly === "true") {
return "";
}
return q.length >= newlineLength ? <br /> : " ";
}, [q, transOnly, newlineLength]);
const styles = useMemo(
() => ({
textStyle,
textDiyStyle,
bgColor,
as: transTag,
}),
[textStyle, textDiyStyle, bgColor, transTag]
);
const trText = useMemo(() => {
if (loading || !transEndHook?.trim()) {
return text;
}
// 翻译完成钩子函数
interpreter.run(`exports.transEndHook = ${transEndHook}`);
return interpreter.exports.transEndHook($el, text, q, keeps);
}, [loading, $el, q, text, keeps, transEndHook]);
if (loading) {
return (
<>
{gap}
<LoadingIcon />
</>
);
}
if (!trText || sameLang) {
return;
}
if (
transOnly === "true" &&
transOpen === "true" &&
$el.querySelector(APP_LCNAME)
) {
Array.from($el.childNodes).forEach((el) => {
if (el.localName !== APP_LCNAME) {
el.remove();
}
});
}
if (keeps.length > 0) {
return (
<>
{gap}
<StyledSpan
{...styles}
dangerouslySetInnerHTML={{
__html: trText.replace(/\[(\d+)\]/g, (_, p) => keeps[parseInt(p)]),
}}
/>
</>
);
}
return (
<>
{gap}
<StyledSpan {...styles}>{trText}</StyledSpan>
</>
);
}

View File

@@ -1,3 +1,4 @@
import { useState, useEffect, useMemo } from "react";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
@@ -5,58 +6,44 @@ import LoadingButton from "@mui/lab/LoadingButton";
import MenuItem from "@mui/material/MenuItem";
import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
import {
OPT_TRANS_ALL,
OPT_TRANS_MICROSOFT,
OPT_TRANS_DEEPL,
OPT_TRANS_DEEPLX,
OPT_TRANS_DEEPLFREE,
OPT_TRANS_BAIDU,
OPT_TRANS_TENCENT,
OPT_TRANS_VOLCENGINE,
OPT_TRANS_OPENAI,
OPT_TRANS_OPENAI_2,
OPT_TRANS_OPENAI_3,
OPT_TRANS_GEMINI,
OPT_TRANS_GEMINI_2,
OPT_TRANS_CLAUDE,
OPT_TRANS_CLOUDFLAREAI,
OPT_TRANS_OLLAMA,
OPT_TRANS_OLLAMA_2,
OPT_TRANS_OLLAMA_3,
OPT_TRANS_OPENROUTER,
OPT_TRANS_CUSTOMIZE,
OPT_TRANS_CUSTOMIZE_2,
OPT_TRANS_CUSTOMIZE_3,
OPT_TRANS_CUSTOMIZE_4,
OPT_TRANS_CUSTOMIZE_5,
OPT_TRANS_NIUTRANS,
DEFAULT_FETCH_LIMIT,
DEFAULT_FETCH_INTERVAL,
DEFAULT_HTTP_TIMEOUT,
OPT_TRANS_BATCH,
OPT_TRANS_CONTEXT,
DEFAULT_BATCH_INTERVAL,
DEFAULT_BATCH_SIZE,
DEFAULT_BATCH_LENGTH,
DEFAULT_CONTEXT_SIZE,
} from "../../config";
import { useState } from "react";
import { useI18n } from "../../hooks/I18n";
import Typography from "@mui/material/Typography";
import Accordion from "@mui/material/Accordion";
import AccordionSummary from "@mui/material/AccordionSummary";
import AccordionDetails from "@mui/material/AccordionDetails";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import AddIcon from "@mui/icons-material/Add";
import Alert from "@mui/material/Alert";
import Menu from "@mui/material/Menu";
import Grid from "@mui/material/Grid";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { useAlert } from "../../hooks/Alert";
import { useApi } from "../../hooks/Api";
import { useApiList, useApiItem } from "../../hooks/Api";
import { useConfirm } from "../../hooks/Confirm";
import { apiTranslate } from "../../apis";
import Box from "@mui/material/Box";
import Link from "@mui/material/Link";
import { limitNumber, limitFloat } from "../../libs/utils";
import ReusableAutocomplete from "./ReusableAutocomplete";
import {
OPT_TRANS_DEEPLX,
OPT_TRANS_OLLAMA,
OPT_TRANS_CUSTOMIZE,
OPT_TRANS_NIUTRANS,
DEFAULT_FETCH_LIMIT,
DEFAULT_FETCH_INTERVAL,
DEFAULT_HTTP_TIMEOUT,
DEFAULT_BATCH_INTERVAL,
DEFAULT_BATCH_SIZE,
DEFAULT_BATCH_LENGTH,
DEFAULT_CONTEXT_SIZE,
OPT_ALL_TYPES,
API_SPE_TYPES,
BUILTIN_STONES,
// BUILTIN_PLACEHOULDERS,
// BUILTIN_TAG_NAMES,
} from "../../config";
function TestButton({ translator, api }) {
function TestButton({ apiSlug, api }) {
const i18n = useI18n();
const alert = useAlert();
const [loading, setLoading] = useState(false);
@@ -64,7 +51,7 @@ function TestButton({ translator, api }) {
try {
setLoading(true);
const [text] = await apiTranslate({
translator,
apiSlug,
text: "hello world",
fromLang: "en",
toLang: "zh-CN",
@@ -114,7 +101,7 @@ function TestButton({ translator, api }) {
return (
<LoadingButton
size="small"
variant="contained"
variant="outlined"
onClick={handleApiTest}
loading={loading}
>
@@ -123,39 +110,34 @@ function TestButton({ translator, api }) {
);
}
function ApiFields({ translator, api, updateApi, resetApi }) {
function ApiFields({ apiSlug, isUserApi, deleteApi }) {
const { api, update, reset } = useApiItem(apiSlug);
const i18n = useI18n();
const {
url = "",
key = "",
model = "",
systemPrompt = "",
userPrompt = "",
customHeader = "",
customBody = "",
think = false,
thinkIgnore = "",
fetchLimit = DEFAULT_FETCH_LIMIT,
fetchInterval = DEFAULT_FETCH_INTERVAL,
httpTimeout = DEFAULT_HTTP_TIMEOUT,
dictNo = "",
memoryNo = "",
reqHook = "",
resHook = "",
temperature = 0,
maxTokens = 256,
apiName = "",
isDisabled = false,
useBatchFetch = false,
batchInterval = DEFAULT_BATCH_INTERVAL,
batchSize = DEFAULT_BATCH_SIZE,
batchLength = DEFAULT_BATCH_LENGTH,
useContext = false,
contextSize = DEFAULT_CONTEXT_SIZE,
} = api;
const [formData, setFormData] = useState({});
const [isModified, setIsModified] = useState(false);
const confirm = useConfirm();
useEffect(() => {
if (api) {
setFormData(api);
}
}, [api]);
useEffect(() => {
if (!api) return;
const hasChanged = JSON.stringify(api) !== JSON.stringify(formData);
setIsModified(hasChanged);
}, [api, formData]);
const handleChange = (e) => {
let { name, value } = e.target;
let { name, value, type, checked } = e.target;
if (type === "checkbox" || type === "switch") {
value = checked;
}
// if (value === "true") value = true;
// if (value === "false") value = false;
switch (name) {
case "fetchLimit":
value = limitNumber(value, 1, 100);
@@ -186,56 +168,78 @@ function ApiFields({ translator, api, updateApi, resetApi }) {
break;
default:
}
updateApi({
setFormData((prevData) => ({
...prevData,
[name]: value,
});
}));
};
const builtinTranslators = [
OPT_TRANS_MICROSOFT,
OPT_TRANS_DEEPLFREE,
OPT_TRANS_BAIDU,
OPT_TRANS_TENCENT,
OPT_TRANS_VOLCENGINE,
];
const handleSave = () => {
// 过滤掉 api 对象中不存在的字段
// const updatedFields = Object.keys(formData).reduce((acc, key) => {
// if (api && Object.keys(api).includes(key)) {
// acc[key] = formData[key];
// }
// return acc;
// }, {});
// update(updatedFields);
update(formData);
};
const mulkeysTranslators = [
OPT_TRANS_DEEPL,
OPT_TRANS_OPENAI,
OPT_TRANS_OPENAI_2,
OPT_TRANS_OPENAI_3,
OPT_TRANS_GEMINI,
OPT_TRANS_GEMINI_2,
OPT_TRANS_CLAUDE,
OPT_TRANS_CLOUDFLAREAI,
OPT_TRANS_OLLAMA,
OPT_TRANS_OLLAMA_2,
OPT_TRANS_OLLAMA_3,
OPT_TRANS_OPENROUTER,
OPT_TRANS_NIUTRANS,
OPT_TRANS_CUSTOMIZE,
OPT_TRANS_CUSTOMIZE_2,
OPT_TRANS_CUSTOMIZE_3,
OPT_TRANS_CUSTOMIZE_4,
OPT_TRANS_CUSTOMIZE_5,
];
const handleReset = () => {
reset();
};
const keyHelper =
translator === OPT_TRANS_NIUTRANS ? (
<>
{i18n("mulkeys_help")}
<Link
href="https://niutrans.com/login?active=3&userSource=kiss-translator"
target="_blank"
>
{i18n("reg_niutrans")}
</Link>
</>
) : mulkeysTranslators.includes(translator) ? (
i18n("mulkeys_help")
) : (
""
);
const handleDelete = async () => {
const isConfirmed = await confirm({
confirmText: i18n("delete"),
cancelText: i18n("cancel"),
});
if (isConfirmed) {
deleteApi(apiSlug);
}
};
const {
url = "",
key = "",
model = "",
apiType,
systemPrompt = "",
// userPrompt = "",
customHeader = "",
customBody = "",
think = false,
thinkIgnore = "",
fetchLimit = DEFAULT_FETCH_LIMIT,
fetchInterval = DEFAULT_FETCH_INTERVAL,
httpTimeout = DEFAULT_HTTP_TIMEOUT,
dictNo = "",
memoryNo = "",
reqHook = "",
resHook = "",
temperature = 0,
maxTokens = 256,
apiName = "",
isDisabled = false,
useBatchFetch = false,
batchInterval = DEFAULT_BATCH_INTERVAL,
batchSize = DEFAULT_BATCH_SIZE,
batchLength = DEFAULT_BATCH_LENGTH,
useContext = false,
contextSize = DEFAULT_CONTEXT_SIZE,
tone = "neutral",
// placeholder = "{ }",
// tagName = "i",
// aiTerms = false,
} = formData;
const keyHelper = useMemo(
() => (API_SPE_TYPES.mulkeys.has(apiType) ? i18n("mulkeys_help") : ""),
[apiType, i18n]
);
return (
<Stack spacing={3}>
@@ -247,7 +251,7 @@ function ApiFields({ translator, api, updateApi, resetApi }) {
onChange={handleChange}
/>
{!builtinTranslators.includes(translator) && (
{!API_SPE_TYPES.machine.has(apiType) && (
<>
<TextField
size="small"
@@ -255,10 +259,10 @@ function ApiFields({ translator, api, updateApi, resetApi }) {
name="url"
value={url}
onChange={handleChange}
multiline={translator === OPT_TRANS_DEEPLX}
multiline={apiType === OPT_TRANS_DEEPLX}
maxRows={10}
helperText={
translator === OPT_TRANS_DEEPLX ? i18n("mulkeys_help") : ""
apiType === OPT_TRANS_DEEPLX ? i18n("mulkeys_help") : ""
}
/>
<TextField
@@ -267,26 +271,66 @@ function ApiFields({ translator, api, updateApi, resetApi }) {
name="key"
value={key}
onChange={handleChange}
multiline={mulkeysTranslators.includes(translator)}
multiline={API_SPE_TYPES.mulkeys.has(apiType)}
maxRows={10}
helperText={keyHelper}
/>
</>
)}
{(translator.startsWith(OPT_TRANS_OPENAI) ||
translator.startsWith(OPT_TRANS_OLLAMA) ||
translator === OPT_TRANS_CLAUDE ||
translator === OPT_TRANS_OPENROUTER ||
translator.startsWith(OPT_TRANS_GEMINI)) && (
{API_SPE_TYPES.ai.has(apiType) && (
<>
<TextField
size="small"
label={"MODEL"}
name="model"
value={model}
onChange={handleChange}
/>
<Box>
<Grid container spacing={2} columns={12}>
<Grid item xs={6} sm={6} md={6} lg={3}>
{/* todo 改成 ReusableAutocomplete 可选择和填写模型 */}
<TextField
size="small"
fullWidth
label={"MODEL"}
name="model"
value={model}
onChange={handleChange}
/>
</Grid>
<Grid item xs={6} sm={6} md={6} lg={3}>
<ReusableAutocomplete
freeSolo
size="small"
fullWidth
options={BUILTIN_STONES}
name="tone"
label={i18n("translation_style")}
value={tone}
onChange={handleChange}
/>
</Grid>
<Grid item xs={6} sm={6} md={6} lg={3}>
<TextField
size="small"
fullWidth
label={"Temperature"}
type="number"
name="temperature"
value={temperature}
onChange={handleChange}
/>
</Grid>
<Grid item xs={6} sm={6} md={6} lg={3}>
<TextField
size="small"
fullWidth
label={"Max Tokens"}
type="number"
name="maxTokens"
value={maxTokens}
onChange={handleChange}
/>
</Grid>
<Grid item xs={6} sm={6} md={6} lg={3}></Grid>
</Grid>
</Box>
<TextField
size="small"
label={"SYSTEM PROMPT"}
@@ -295,8 +339,9 @@ function ApiFields({ translator, api, updateApi, resetApi }) {
onChange={handleChange}
multiline
maxRows={10}
helperText={i18n("system_prompt_helper")}
/>
<TextField
{/* <TextField
size="small"
label={"USER PROMPT"}
name="userPrompt"
@@ -304,7 +349,51 @@ function ApiFields({ translator, api, updateApi, resetApi }) {
onChange={handleChange}
multiline
maxRows={10}
/>
/> */}
{/* <Box>
<Grid container spacing={2} columns={12}>
<Grid item xs={6} sm={6} md={6} lg={3}>
<ReusableAutocomplete
freeSolo
size="small"
fullWidth
options={BUILTIN_PLACEHOULDERS}
name="placeholder"
label={i18n("placeholder")}
value={placeholder}
onChange={handleChange}
/>
</Grid>
<Grid item xs={6} sm={6} md={6} lg={3}>
<ReusableAutocomplete
freeSolo
size="small"
fullWidth
options={BUILTIN_TAG_NAMES}
name="tagName"
label={i18n("tag_name")}
value={tagName}
onChange={handleChange}
/>
</Grid>
<Grid item xs={6} sm={6} md={6} lg={3}>
<TextField
select
size="small"
fullWidth
name="aiTerms"
value={aiTerms}
label={i18n("ai_terms")}
onChange={handleChange}
>
<MenuItem value={true}>{i18n("enable")}</MenuItem>
<MenuItem value={false}>{i18n("disable")}</MenuItem>
</TextField>
</Grid>
</Grid>
</Box> */}
<TextField
size="small"
label={i18n("custom_header")}
@@ -328,7 +417,7 @@ function ApiFields({ translator, api, updateApi, resetApi }) {
</>
)}
{translator.startsWith(OPT_TRANS_OLLAMA) && (
{apiType === OPT_TRANS_OLLAMA && (
<>
<TextField
select
@@ -351,32 +440,7 @@ function ApiFields({ translator, api, updateApi, resetApi }) {
</>
)}
{(translator.startsWith(OPT_TRANS_OPENAI) ||
translator === OPT_TRANS_CLAUDE ||
translator === OPT_TRANS_OPENROUTER ||
translator === OPT_TRANS_GEMINI ||
translator === OPT_TRANS_GEMINI_2) && (
<>
<TextField
size="small"
label={"Temperature"}
type="number"
name="temperature"
value={temperature}
onChange={handleChange}
/>
<TextField
size="small"
label={"Max Tokens"}
type="number"
name="maxTokens"
value={maxTokens}
onChange={handleChange}
/>
</>
)}
{translator === OPT_TRANS_NIUTRANS && (
{apiType === OPT_TRANS_NIUTRANS && (
<>
<TextField
size="small"
@@ -395,7 +459,7 @@ function ApiFields({ translator, api, updateApi, resetApi }) {
</>
)}
{translator.startsWith(OPT_TRANS_CUSTOMIZE) && (
{apiType === OPT_TRANS_CUSTOMIZE && (
<>
<TextField
size="small"
@@ -418,140 +482,180 @@ function ApiFields({ translator, api, updateApi, resetApi }) {
</>
)}
{OPT_TRANS_BATCH.has(translator) && (
<>
<TextField
select
size="small"
name="useBatchFetch"
value={useBatchFetch}
label={i18n("use_batch_fetch")}
onChange={handleChange}
>
<MenuItem value={false}>{i18n("disable")}</MenuItem>
<MenuItem value={true}>{i18n("enable")}</MenuItem>
</TextField>
{useBatchFetch && (
<>
{API_SPE_TYPES.batch.has(api.apiType) && (
<Box>
<Grid container spacing={2} columns={12}>
<Grid item xs={6} sm={6} md={6} lg={3}>
<TextField
select
fullWidth
size="small"
name="useBatchFetch"
value={useBatchFetch}
label={i18n("use_batch_fetch")}
onChange={handleChange}
>
<MenuItem value={false}>{i18n("disable")}</MenuItem>
<MenuItem value={true}>{i18n("enable")}</MenuItem>
</TextField>
</Grid>
<Grid item xs={6} sm={6} md={6} lg={3}>
<TextField
size="small"
fullWidth
label={i18n("batch_interval")}
type="number"
name="batchInterval"
value={batchInterval}
onChange={handleChange}
/>
</Grid>
<Grid item xs={6} sm={6} md={6} lg={3}>
<TextField
size="small"
fullWidth
label={i18n("batch_size")}
type="number"
name="batchSize"
value={batchSize}
onChange={handleChange}
/>
</Grid>
<Grid item xs={6} sm={6} md={6} lg={3}>
<TextField
size="small"
fullWidth
label={i18n("batch_length")}
type="number"
name="batchLength"
value={batchLength}
onChange={handleChange}
/>
</>
)}
</Grid>
</Grid>
</Box>
)}
{API_SPE_TYPES.context.has(api.apiType) && (
<>
<Box>
<Grid container spacing={2} columns={12}>
<Grid item xs={6} sm={6} md={6} lg={3}>
{" "}
<TextField
select
size="small"
fullWidth
name="useContext"
value={useContext}
label={i18n("use_context")}
onChange={handleChange}
>
<MenuItem value={false}>{i18n("disable")}</MenuItem>
<MenuItem value={true}>{i18n("enable")}</MenuItem>
</TextField>
</Grid>
<Grid item xs={6} sm={6} md={6} lg={3}>
{" "}
<TextField
size="small"
fullWidth
label={i18n("context_size")}
type="number"
name="contextSize"
value={contextSize}
onChange={handleChange}
/>
</Grid>
</Grid>
</Box>
</>
)}
{OPT_TRANS_CONTEXT.has(translator) && (
<>
<TextField
select
size="small"
name="useContext"
value={useContext}
label={i18n("use_context")}
onChange={handleChange}
>
<MenuItem value={false}>{i18n("disable")}</MenuItem>
<MenuItem value={true}>{i18n("enable")}</MenuItem>
</TextField>
{useBatchFetch && (
<Box>
<Grid container spacing={2} columns={12}>
<Grid item xs={6} sm={6} md={6} lg={3}>
<TextField
size="small"
label={i18n("context_size")}
fullWidth
label={i18n("fetch_limit")}
type="number"
name="contextSize"
value={contextSize}
name="fetchLimit"
value={fetchLimit}
onChange={handleChange}
/>
)}
</>
)}
<TextField
size="small"
label={i18n("fetch_limit")}
type="number"
name="fetchLimit"
value={fetchLimit}
onChange={handleChange}
/>
<TextField
size="small"
label={i18n("fetch_interval")}
type="number"
name="fetchInterval"
value={fetchInterval}
onChange={handleChange}
/>
<TextField
size="small"
label={i18n("http_timeout")}
type="number"
name="httpTimeout"
defaultValue={httpTimeout}
onChange={handleChange}
/>
<FormControlLabel
control={
<Switch
size="small"
name="isDisabled"
checked={isDisabled}
onChange={() => {
updateApi({ isDisabled: !isDisabled });
}}
/>
}
label={i18n("is_disabled")}
/>
</Grid>
<Grid item xs={6} sm={6} md={6} lg={3}>
<TextField
size="small"
fullWidth
label={i18n("fetch_interval")}
type="number"
name="fetchInterval"
value={fetchInterval}
onChange={handleChange}
/>
</Grid>
<Grid item xs={6} sm={6} md={6} lg={3}>
<TextField
size="small"
fullWidth
label={i18n("http_timeout")}
type="number"
name="httpTimeout"
value={httpTimeout}
onChange={handleChange}
/>
</Grid>
<Grid item xs={6} sm={6} md={6} lg={3}></Grid>
</Grid>
</Box>
<Stack direction="row" spacing={2}>
<TestButton translator={translator} api={api} />
<Button
size="small"
variant="outlined"
onClick={() => {
resetApi();
}}
variant="contained"
onClick={handleSave}
disabled={!isModified}
>
{i18n("save")}
</Button>
<TestButton apiSlug={apiSlug} api={api} />
<Button size="small" variant="outlined" onClick={handleReset}>
{i18n("restore_default")}
</Button>
{isUserApi && (
<Button
size="small"
variant="outlined"
color="error"
onClick={handleDelete}
>
{i18n("delete")}
</Button>
)}
<FormControlLabel
control={
<Switch
size="small"
fullWidth
name="isDisabled"
checked={isDisabled}
onChange={handleChange}
/>
}
label={i18n("is_disabled")}
/>
</Stack>
{translator.startsWith(OPT_TRANS_CUSTOMIZE) && (
<pre>{i18n("custom_api_help")}</pre>
)}
{apiType === OPT_TRANS_CUSTOMIZE && <pre>{i18n("custom_api_help")}</pre>}
</Stack>
);
}
function ApiAccordion({ translator }) {
function ApiAccordion({ api, isUserApi, deleteApi }) {
const [expanded, setExpanded] = useState(false);
const { api, updateApi, resetApi } = useApi(translator);
const handleChange = (e) => {
setExpanded((pre) => !pre);
@@ -566,16 +670,15 @@ function ApiAccordion({ translator }) {
overflowWrap: "anywhere",
}}
>
{api.apiName ? `${translator} (${api.apiName})` : translator}
{`[${api.apiType}] ${api.apiName}`}
</Typography>
</AccordionSummary>
<AccordionDetails>
{expanded && (
<ApiFields
translator={translator}
api={api}
updateApi={updateApi}
resetApi={resetApi}
apiSlug={api.apiSlug}
isUserApi={isUserApi}
deleteApi={deleteApi}
/>
)}
</AccordionDetails>
@@ -585,14 +688,85 @@ function ApiAccordion({ translator }) {
export default function Apis() {
const i18n = useI18n();
const { userApis, builtinApis, addApi, deleteApi } = useApiList();
const apiTypes = useMemo(
() =>
OPT_ALL_TYPES.map((type) => ({
type,
label: type,
})),
[]
);
const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl);
const handleClick = (event) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleMenuItemClick = (apiType) => {
addApi(apiType);
handleClose();
};
return (
<Box>
<Stack spacing={3}>
<Alert severity="info">{i18n("about_api")}</Alert>
<Box>
{OPT_TRANS_ALL.map((translator) => (
<ApiAccordion key={translator} translator={translator} />
<Button
size="small"
id="add-api-button"
variant="contained"
onClick={handleClick}
aria-controls={open ? "add-api-menu" : undefined}
aria-haspopup="true"
aria-expanded={open ? "true" : undefined}
endIcon={<KeyboardArrowDownIcon />}
startIcon={<AddIcon />}
>
{i18n("add")}
</Button>
<Menu
id="add-api-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
"aria-labelledby": "add-api-button",
}}
>
{apiTypes.map((apiOption) => (
<MenuItem
key={apiOption.type}
onClick={() => handleMenuItemClick(apiOption.type)}
>
{apiOption.label}
</MenuItem>
))}
</Menu>
</Box>
<Box>
{userApis.map((api) => (
<ApiAccordion
key={api.apiSlug}
api={api}
isUserApi={true}
deleteApi={deleteApi}
/>
))}
</Box>
<Box>
{builtinApis.map((api) => (
<ApiAccordion key={api.apiSlug} api={api} />
))}
</Box>
</Stack>

View File

@@ -18,7 +18,7 @@ export default function DownloadButton({ handleData, text, fileName }) {
link.click();
link.remove();
} catch (err) {
kissLog(err, "download");
kissLog("download", err);
} finally {
setLoading(false);
}

View File

@@ -5,7 +5,7 @@ import Accordion from "@mui/material/Accordion";
import AccordionSummary from "@mui/material/AccordionSummary";
import AccordionDetails from "@mui/material/AccordionDetails";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import CircularProgress from "@mui/material/CircularProgress";
// import CircularProgress from "@mui/material/CircularProgress";
import { useI18n } from "../../hooks/I18n";
import Box from "@mui/material/Box";
import { useFavWords } from "../../hooks/FavWords";
@@ -49,29 +49,25 @@ function FavAccordion({ word, index }) {
export default function FavWords() {
const i18n = useI18n();
const { loading, favWords, mergeWords, clearWords } = useFavWords();
const favList = Object.entries(favWords).sort((a, b) =>
a[0].localeCompare(b[0])
);
const downloadList = favList.map(([word]) => word);
const { favList, wordList, mergeWords, clearWords } = useFavWords();
const handleImport = async (data) => {
const handleImport = (data) => {
try {
const newWords = data
.split("\n")
.map((line) => line.split(",")[0].trim())
.filter(isValidWord);
await mergeWords(newWords);
mergeWords(newWords);
} catch (err) {
kissLog(err, "import rules");
kissLog("import rules", err);
}
};
const handleTranslation = async () => {
const tranList = [];
for (const text of downloadList) {
for (const text of wordList) {
try {
// todo
// todo: 修复
const dictRes = await apiTranslate({
text,
translator: OPT_TRANS_BAIDU,
@@ -122,7 +118,7 @@ export default function FavWords() {
fileExts={[".txt", ".csv"]}
/>
<DownloadButton
handleData={() => downloadList.join("\n")}
handleData={() => wordList.join("\n")}
text={i18n("export")}
fileName={`kiss-words_${Date.now()}.txt`}
/>
@@ -144,18 +140,14 @@ export default function FavWords() {
</Stack>
<Box>
{loading ? (
<CircularProgress size={24} />
) : (
favList.map(([word, { createdAt }], index) => (
<FavAccordion
key={word}
index={index}
word={word}
createdAt={createdAt}
/>
))
)}
{favList.map(([word, { createdAt }], index) => (
<FavAccordion
key={word}
index={index}
word={word}
createdAt={createdAt}
/>
))}
</Box>
</Stack>
</Box>

View File

@@ -4,7 +4,6 @@ import TextField from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem";
import { useI18n } from "../../hooks/I18n";
import {
OPT_TRANS_ALL,
OPT_LANGS_FROM,
OPT_LANGS_TO,
OPT_INPUT_TRANS_SIGNS,
@@ -16,10 +15,12 @@ import { useInputRule } from "../../hooks/InputRule";
import { useCallback } from "react";
import Grid from "@mui/material/Grid";
import { limitNumber } from "../../libs/utils";
import { useApiList } from "../../hooks/Api";
export default function InputSetting() {
const i18n = useI18n();
const { inputRule, updateInputRule } = useInputRule();
const { enabledApis } = useApiList();
const handleChange = (e) => {
e.preventDefault();
@@ -44,7 +45,7 @@ export default function InputSetting() {
const {
transOpen,
translator,
apiSlug,
fromLang,
toLang,
triggerShortcut,
@@ -73,14 +74,14 @@ export default function InputSetting() {
<TextField
select
size="small"
name="translator"
value={translator}
name="apiSlug"
value={apiSlug}
label={i18n("translate_service")}
onChange={handleChange}
>
{OPT_TRANS_ALL.map((item) => (
<MenuItem key={item} value={item}>
{item}
{enabledApis.map((api) => (
<MenuItem key={api.apiSlug} value={api.apiSlug}>
{api.apiName}
</MenuItem>
))}
</TextField>
@@ -166,7 +167,7 @@ export default function InputSetting() {
label={i18n("combo_timeout")}
type="number"
name="triggerTime"
defaultValue={triggerTime}
value={triggerTime}
onChange={handleChange}
/>
</Grid>

View File

@@ -7,6 +7,7 @@ import Switch from "@mui/material/Switch";
import { useMouseHoverSetting } from "../../hooks/MouseHover";
import { useCallback } from "react";
import Grid from "@mui/material/Grid";
import { DEFAULT_MOUSEHOVER_KEY } from "../../config";
export default function MouseHoverSetting() {
const i18n = useI18n();
@@ -19,7 +20,7 @@ export default function MouseHoverSetting() {
[updateMouseHoverSetting]
);
const { useMouseHover = true, mouseHoverKey = ["ControlLeft"] } =
const { useMouseHover = true, mouseHoverKey = DEFAULT_MOUSEHOVER_KEY } =
mouseHoverSetting;
return (

View File

@@ -6,7 +6,6 @@ import {
REMAIN_KEY,
OPT_LANGS_FROM,
OPT_LANGS_TO,
OPT_TRANS_ALL,
OPT_STYLE_ALL,
OPT_STYLE_DIY,
OPT_STYLE_USE_COLOR,
@@ -15,10 +14,12 @@ import { useI18n } from "../../hooks/I18n";
import MenuItem from "@mui/material/MenuItem";
import Grid from "@mui/material/Grid";
import { useOwSubRule } from "../../hooks/SubRules";
import { useApiList } from "../../hooks/Api";
export default function OwSubRule() {
const i18n = useI18n();
const { owSubrule, updateOwSubrule } = useOwSubRule();
const { enabledApis } = useApiList();
const handleChange = (e) => {
e.preventDefault();
@@ -27,7 +28,7 @@ export default function OwSubRule() {
};
const {
translator,
apiSlug,
fromLang,
toLang,
textStyle,
@@ -73,16 +74,16 @@ export default function OwSubRule() {
select
size="small"
fullWidth
name="translator"
value={translator}
name="apiSlug"
value={apiSlug}
label={i18n("translate_service")}
onChange={handleChange}
>
{RemainItem}
{GlobalItem}
{OPT_TRANS_ALL.map((item) => (
<MenuItem key={item} value={item}>
{item}
{enabledApis.map((api) => (
<MenuItem key={api.apiSlug} value={api.apiSlug}>
{api.apiName}
</MenuItem>
))}
</TextField>

View File

@@ -0,0 +1,74 @@
import { useState, useEffect, useRef } from "react";
import Autocomplete from "@mui/material/Autocomplete";
import TextField from "@mui/material/TextField";
/**
* 一个可复用的 Autocomplete 组件,增加了 name 属性和标准化的 onChange 事件
* @param {object} props - 组件的 props
* @param {string} props.name - 表单字段的名称,会包含在 onChange 的 event.target 中
* @param {string} props.label - TextField 的标签
* @param {any} props.value - 受控组件的当前值
* @param {function} props.onChange - 值改变时的回调函数 (event) => {}
* @param {Array} props.options - Autocomplete 的选项列表
*/
export default function ReusableAutocomplete({
name,
label,
value,
onChange,
...rest
}) {
const [inputValue, setInputValue] = useState(value || "");
const isChangeCommitted = useRef(false);
useEffect(() => {
setInputValue(value || "");
}, [value]);
const triggerOnChange = (newValue) => {
if (onChange) {
const syntheticEvent = {
target: {
name: name,
value: newValue,
},
};
onChange(syntheticEvent);
}
};
const handleBlur = () => {
if (isChangeCommitted.current) {
isChangeCommitted.current = false;
return;
}
if (inputValue !== value) {
triggerOnChange(inputValue);
}
};
const handleChange = (event, newValue) => {
isChangeCommitted.current = true;
triggerOnChange(newValue);
};
const handleInputChange = (event, newInputValue) => {
isChangeCommitted.current = false;
setInputValue(newInputValue);
};
return (
<Autocomplete
value={value}
onChange={handleChange}
inputValue={inputValue}
onInputChange={handleInputChange}
onBlur={handleBlur}
{...rest}
renderInput={(params) => (
<TextField {...params} name={name} label={label} />
)}
/>
);
}

View File

@@ -10,7 +10,6 @@ import {
GLOBLA_RULE,
OPT_LANGS_FROM,
OPT_LANGS_TO,
OPT_TRANS_ALL,
OPT_STYLE_ALL,
OPT_STYLE_DIY,
OPT_STYLE_USE_COLOR,
@@ -58,19 +57,26 @@ import EditIcon from "@mui/icons-material/Edit";
import CancelIcon from "@mui/icons-material/Cancel";
import SaveIcon from "@mui/icons-material/Save";
import { kissLog } from "../../libs/log";
import { useApiList } from "../../hooks/Api";
function RuleFields({ rule, rules, setShow, setKeyword }) {
const initFormValues = {
...(rule?.pattern === "*" ? GLOBLA_RULE : DEFAULT_RULE),
...(rule || {}),
};
const editMode = !!rule;
const initFormValues = useMemo(
() => ({
...(rule?.pattern === "*" ? GLOBLA_RULE : DEFAULT_RULE),
...(rule || {}),
}),
[rule]
);
const editMode = useMemo(() => !!rule, [rule]);
const i18n = useI18n();
const [disabled, setDisabled] = useState(editMode);
const [errors, setErrors] = useState({});
const [formValues, setFormValues] = useState(initFormValues);
const [showMore, setShowMore] = useState(!rules);
const [isModified, setIsModified] = useState(false);
const { enabledApis } = useApiList();
const {
pattern,
selector,
@@ -82,7 +88,7 @@ function RuleFields({ rule, rules, setShow, setKeyword }) {
parentStyle = "",
injectJs = "",
injectCss = "",
translator,
apiSlug,
fromLang,
toLang,
textStyle,
@@ -106,6 +112,13 @@ function RuleFields({ rule, rules, setShow, setKeyword }) {
// transRemoveHook = "",
} = formValues;
useEffect(() => {
if (!initFormValues) return;
const hasChanged =
JSON.stringify(initFormValues) !== JSON.stringify(formValues);
setIsModified(hasChanged);
}, [initFormValues, formValues]);
const hasSamePattern = (str) => {
for (const item of rules.list) {
if (item.pattern === str && rule?.pattern !== str) {
@@ -417,16 +430,16 @@ function RuleFields({ rule, rules, setShow, setKeyword }) {
select
size="small"
fullWidth
name="translator"
value={translator}
name="apiSlug"
value={apiSlug}
label={i18n("translate_service")}
disabled={disabled}
onChange={handleChange}
>
{GlobalItem}
{OPT_TRANS_ALL.map((item) => (
<MenuItem key={item} value={item}>
{item}
{enabledApis.map((api) => (
<MenuItem key={api.apiSlug} value={api.apiSlug}>
{api.apiName}
</MenuItem>
))}
</TextField>
@@ -738,6 +751,7 @@ function RuleFields({ rule, rules, setShow, setKeyword }) {
variant="contained"
type="submit"
startIcon={<SaveIcon />}
disabled={!isModified}
>
{i18n("save")}
</Button>
@@ -842,7 +856,7 @@ function ShareButton({ rules, injectRules, selectedUrl }) {
window.open(url, "_blank");
} catch (err) {
alert.warning(i18n("error_got_some_wrong"));
kissLog(err, "share rules");
kissLog("share rules", err);
}
};
@@ -871,7 +885,7 @@ function UserRules({ subRules, rules }) {
try {
await rules.merge(JSON.parse(data));
} catch (err) {
kissLog(err, "import rules");
kissLog("import rules", err);
}
};
@@ -1004,7 +1018,7 @@ function SubRulesItem({
await delSubRules(url);
await deleteDataCache(url);
} catch (err) {
kissLog(err, "del subrules");
kissLog("del subrules", err);
}
};
@@ -1017,7 +1031,7 @@ function SubRulesItem({
}
await updateDataCache(url);
} catch (err) {
kissLog(err, "sync sub rules");
kissLog("sync sub rules", err);
} finally {
setLoading(false);
}
@@ -1096,7 +1110,7 @@ function SubRulesEdit({ subList, addSub, updateDataCache }) {
setShowInput(false);
setInputText("");
} catch (err) {
kissLog(err, "fetch rules");
kissLog("fetch rules", err);
setInputError(i18n("error_fetch_url"));
} finally {
setLoading(false);

View File

@@ -96,7 +96,7 @@ export default function Settings() {
caches.delete(CACHE_NAME);
alert.success(i18n("clear_success"));
} catch (err) {
kissLog(err, "clear cache");
kissLog("clear cache", err);
}
};
@@ -104,7 +104,7 @@ export default function Settings() {
try {
await updateSetting(JSON.parse(data));
} catch (err) {
kissLog(err, "import setting");
kissLog("import setting", err);
}
};
@@ -119,10 +119,10 @@ export default function Settings() {
touchTranslate = 2,
blacklist = DEFAULT_BLACKLIST.join(",\n"),
csplist = DEFAULT_CSPLIST.join(",\n"),
transInterval = 200,
transInterval = 100,
langDetector = OPT_TRANS_MICROSOFT,
} = setting;
const { isHide = false, fabClickAction = 0 } = fab || {};
const { isHide = false, fabClickAction = 0 } = fab || {};
return (
<Box>
@@ -163,7 +163,7 @@ export default function Settings() {
label={i18n("min_translate_length")}
type="number"
name="minLength"
defaultValue={minLength}
value={minLength}
onChange={handleChange}
/>
@@ -172,7 +172,7 @@ export default function Settings() {
label={i18n("max_translate_length")}
type="number"
name="maxLength"
defaultValue={maxLength}
value={maxLength}
onChange={handleChange}
/>
@@ -181,7 +181,7 @@ export default function Settings() {
label={i18n("num_of_newline_characters")}
type="number"
name="newlineLength"
defaultValue={newlineLength}
value={newlineLength}
onChange={handleChange}
/>
@@ -190,7 +190,7 @@ export default function Settings() {
label={i18n("translate_interval")}
type="number"
name="transInterval"
defaultValue={transInterval}
value={transInterval}
onChange={handleChange}
/>
<TextField
@@ -198,7 +198,7 @@ export default function Settings() {
label={i18n("http_timeout")}
type="number"
name="httpTimeout"
defaultValue={httpTimeout}
value={httpTimeout}
onChange={handleChange}
/>
<FormControl size="small">
@@ -236,9 +236,9 @@ export default function Settings() {
<InputLabel>{i18n("fab_click_action")}</InputLabel>
<Select
name="fabClickAction"
value={fabClickAction}
value={fabClickAction}
label={i18n("fab_click_action")}
onChange= {(e) => updateFab({ fabClickAction: e.target.value })}
onChange={(e) => updateFab({ fabClickAction: e.target.value })}
>
<MenuItem value={0}>{i18n("fab_click_menu")}</MenuItem>
<MenuItem value={1}>{i18n("fab_click_translate")}</MenuItem>
@@ -302,7 +302,7 @@ export default function Settings() {
i18n("pattern_helper") + " " + i18n("disabled_csplist_helper")
}
name="csplist"
defaultValue={csplist}
value={csplist}
onChange={handleChange}
multiline
/>
@@ -345,7 +345,7 @@ export default function Settings() {
label={i18n("translate_blacklist")}
helperText={i18n("pattern_helper")}
name="blacklist"
defaultValue={blacklist}
value={blacklist}
onChange={handleChange}
maxRows={10}
multiline

View File

@@ -2,32 +2,61 @@ import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import IconButton from "@mui/material/IconButton";
import EditIcon from "@mui/icons-material/Edit";
import CheckIcon from "@mui/icons-material/Check";
import { useEffect, useState, useRef } from "react";
import { shortcutListener } from "../../libs/shortcut";
import { useI18n } from "../../hooks/I18n";
export default function ShortcutInput({ value, onChange, label, helperText }) {
const [disabled, setDisabled] = useState(true);
export default function ShortcutInput({
value: keys,
onChange,
label,
helperText,
}) {
const [isEditing, setIsEditing] = useState(false);
const [editingKeys, setEditingKeys] = useState([]);
const inputRef = useRef(null);
const i18n = useI18n();
const commitChanges = () => {
if (editingKeys.length > 0) {
onChange(editingKeys);
}
setIsEditing(false);
};
const handleBlur = () => {
commitChanges();
};
const handleEditClick = () => {
setEditingKeys([]);
setIsEditing(true);
};
useEffect(() => {
if (disabled) {
if (!isEditing) {
return;
}
inputRef.current.focus();
onChange([]);
const clearShortcut = shortcutListener((curkeys, allkeys) => {
onChange(allkeys);
if (curkeys.length === 0) {
setDisabled(true);
}
}, inputRef.current);
const inputElement = inputRef.current;
if (inputElement) {
inputElement.focus();
}
const clearShortcut = shortcutListener((pressedKeys, event) => {
event.preventDefault();
event.stopPropagation();
setEditingKeys([...pressedKeys]);
});
return () => {
clearShortcut();
};
}, [disabled, onChange]);
}, [isEditing]);
const displayValue = isEditing ? editingKeys : keys;
const formattedValue = displayValue
.map((item) => (item === " " ? "Space" : item))
.join(" + ");
return (
<Stack direction="row" alignItems="flex-start">
@@ -35,22 +64,22 @@ export default function ShortcutInput({ value, onChange, label, helperText }) {
size="small"
label={label}
name={label}
value={value.map((item) => (item === " " ? "Space" : item)).join(" + ")}
value={formattedValue}
fullWidth
inputRef={inputRef}
disabled={disabled}
onBlur={() => {
setDisabled(true);
}}
helperText={helperText}
disabled={!isEditing}
onBlur={handleBlur}
helperText={isEditing ? i18n("pls_press_shortcut") : helperText}
/>
<IconButton
onClick={() => {
setDisabled(false);
}}
>
{<EditIcon />}
</IconButton>
{isEditing ? (
<IconButton onClick={commitChanges} color="primary">
<CheckIcon />
</IconButton>
) : (
<IconButton onClick={handleEditClick}>
<EditIcon />
</IconButton>
)}
</Stack>
);
}

View File

@@ -21,8 +21,8 @@ import { useAlert } from "../../hooks/Alert";
import { useSetting } from "../../hooks/Setting";
import { kissLog } from "../../libs/log";
import SyncIcon from "@mui/icons-material/Sync";
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import ContentPasteIcon from "@mui/icons-material/ContentPaste";
export default function SyncSetting() {
const i18n = useI18n();
@@ -44,10 +44,10 @@ export default function SyncSetting() {
try {
setLoading(true);
await syncSettingAndRules();
await reloadSetting();
reloadSetting();
alert.success(i18n("sync_success"));
} catch (err) {
kissLog(err, "sync all");
kissLog("sync all", err);
alert.error(i18n("sync_failed"));
} finally {
setLoading(false);
@@ -56,37 +56,37 @@ export default function SyncSetting() {
const handleGenerateShareString = async () => {
try {
const base64Config = btoa(JSON.stringify({
syncType: syncType,
syncUrl: syncUrl,
syncUser: syncUser,
syncKey: syncKey,
}));
const base64Config = btoa(
JSON.stringify({
syncType: syncType,
syncUrl: syncUrl,
syncUser: syncUser,
syncKey: syncKey,
})
);
const shareString = `${OPT_SYNCTOKEN_PERFIX}${base64Config}`;
await navigator.clipboard.writeText(shareString);
console.debug("Share string copied to clipboard", shareString);
kissLog("Share string copied to clipboard", shareString);
} catch (error) {
console.error("Failed to copy share string to clipboard", error);
kissLog("Failed to copy share string to clipboard", error);
}
};
const handleImportFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText();
console.debug('read_clipboard', text)
kissLog("read_clipboard", text);
if (text.startsWith(OPT_SYNCTOKEN_PERFIX)) {
const base64Config = text.slice(OPT_SYNCTOKEN_PERFIX.length);
const jsonString = atob(base64Config);
const updatedConfig = JSON.parse(jsonString);
if (!OPT_SYNCTYPE_ALL.includes(updatedConfig.syncType)) {
console.error('error syncType', updatedConfig.syncType)
kissLog("error syncType", updatedConfig.syncType);
return;
}
if (
updatedConfig.syncUrl
) {
if (updatedConfig.syncUrl) {
updateSync({
syncType: updatedConfig.syncType,
syncUrl: updatedConfig.syncUrl,
@@ -94,17 +94,16 @@ export default function SyncSetting() {
syncKey: updatedConfig.syncKey,
});
} else {
console.error("Invalid config structure");
kissLog("Invalid config structure");
}
} else {
console.error("Invalid share string", text);
kissLog("Invalid share string", text);
}
} catch (error) {
console.error("Failed to read from clipboard or parse JSON", error);
kissLog("Failed to read from clipboard or parse JSON", error);
}
};
if (!sync) {
return;
}

View File

@@ -4,7 +4,6 @@ import TextField from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem";
import { useI18n } from "../../hooks/I18n";
import {
OPT_TRANS_ALL,
OPT_LANGS_FROM,
OPT_LANGS_TO,
OPT_TRANBOX_TRIGGER_CLICK,
@@ -16,11 +15,13 @@ import { useCallback } from "react";
import { limitNumber } from "../../libs/utils";
import { useTranbox } from "../../hooks/Tranbox";
import { isExt } from "../../libs/client";
import { useApiList } from "../../hooks/Api";
import Alert from "@mui/material/Alert";
export default function Tranbox() {
const i18n = useI18n();
const { tranboxSetting, updateTranbox } = useTranbox();
const { enabledApis } = useApiList();
const handleChange = (e) => {
e.preventDefault();
@@ -47,7 +48,7 @@ export default function Tranbox() {
);
const {
translator,
apiSlug,
fromLang,
toLang,
toLang2 = "en",
@@ -72,14 +73,14 @@ export default function Tranbox() {
<TextField
select
size="small"
name="translator"
value={translator}
name="apiSlug"
value={apiSlug}
label={i18n("translate_service")}
onChange={handleChange}
>
{OPT_TRANS_ALL.map((item) => (
<MenuItem key={item} value={item}>
{item}
{enabledApis.map((api) => (
<MenuItem key={api.apiSlug} value={api.apiSlug}>
{api.apiName}
</MenuItem>
))}
</TextField>
@@ -147,7 +148,7 @@ export default function Tranbox() {
label={i18n("tranbtn_offset_x")}
type="number"
name="btnOffsetX"
defaultValue={btnOffsetX}
value={btnOffsetX}
onChange={handleChange}
/>
@@ -156,7 +157,7 @@ export default function Tranbox() {
label={i18n("tranbtn_offset_y")}
type="number"
name="btnOffsetY"
defaultValue={btnOffsetY}
value={btnOffsetY}
onChange={handleChange}
/>
@@ -165,7 +166,7 @@ export default function Tranbox() {
label={i18n("tranbox_offset_x")}
type="number"
name="boxOffsetX"
defaultValue={boxOffsetX}
value={boxOffsetX}
onChange={handleChange}
/>
@@ -174,7 +175,7 @@ export default function Tranbox() {
label={i18n("tranbox_offset_y")}
type="number"
name="boxOffsetY"
defaultValue={boxOffsetY}
value={boxOffsetY}
onChange={handleChange}
/>
@@ -245,7 +246,7 @@ export default function Tranbox() {
size="small"
label={i18n("extend_styles")}
name="extStyles"
defaultValue={extStyles}
value={extStyles}
onChange={handleChange}
maxRows={10}
multiline

View File

@@ -9,9 +9,9 @@ import ThemeProvider from "../../hooks/Theme";
import { useEffect, useState } from "react";
import { isGm } from "../../libs/client";
import { sleep } from "../../libs/utils";
import CircularProgress from "@mui/material/CircularProgress";
import { trySyncSettingAndRules } from "../../libs/sync";
import { AlertProvider } from "../../hooks/Alert";
import { ConfirmProvider } from "../../hooks/Confirm";
import Link from "@mui/material/Link";
import Divider from "@mui/material/Divider";
import Stack from "@mui/material/Stack";
@@ -22,6 +22,7 @@ import InputSetting from "./InputSetting";
import Tranbox from "./Tranbox";
import FavWords from "./FavWords";
import MouseHoverSetting from "./MouseHover";
import Loading from "../../hooks/Loading";
export default function Options() {
const [error, setError] = useState("");
@@ -91,37 +92,30 @@ export default function Options() {
}
if (!ready) {
return (
<center>
<Divider>
<Link
href={process.env.REACT_APP_HOMEPAGE}
>{`KISS Translator v${process.env.REACT_APP_VERSION}`}</Link>
</Divider>
<CircularProgress />
</center>
);
return <Loading />;
}
return (
<SettingProvider>
<ThemeProvider>
<AlertProvider>
<HashRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Setting />} />
<Route path="rules" element={<Rules />} />
<Route path="input" element={<InputSetting />} />
<Route path="tranbox" element={<Tranbox />} />
<Route path="mousehover" element={<MouseHoverSetting />} />
<Route path="apis" element={<Apis />} />
<Route path="sync" element={<SyncSetting />} />
<Route path="words" element={<FavWords />} />
<Route path="about" element={<About />} />
</Route>
</Routes>
</HashRouter>
<ConfirmProvider>
<HashRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Setting />} />
<Route path="rules" element={<Rules />} />
<Route path="input" element={<InputSetting />} />
<Route path="tranbox" element={<Tranbox />} />
<Route path="mousehover" element={<MouseHoverSetting />} />
<Route path="apis" element={<Apis />} />
<Route path="sync" element={<SyncSetting />} />
<Route path="words" element={<FavWords />} />
<Route path="about" element={<About />} />
</Route>
</Routes>
</HashRouter>
</ConfirmProvider>
</AlertProvider>
</ThemeProvider>
</SettingProvider>

View File

@@ -20,11 +20,9 @@ import {
MSG_OPEN_OPTIONS,
MSG_SAVE_RULE,
MSG_COMMAND_SHORTCUTS,
OPT_TRANS_ALL,
OPT_LANGS_FROM,
OPT_LANGS_TO,
OPT_STYLE_ALL,
DEFAULT_TRANS_APIS,
} from "../../config";
import { sendIframeMsg } from "../../libs/iframe";
import { saveRule } from "../../libs/rules";
@@ -33,14 +31,14 @@ import { kissLog } from "../../libs/log";
// 插件popup没有参数
// 网页弹框有
export default function Popup({ setShowPopup, translator: tran }) {
export default function Popup({ setShowPopup, translator }) {
const i18n = useI18n();
const [rule, setRule] = useState(tran?.rule);
const [transApis, setTransApis] = useState(tran?.setting?.transApis || []);
const [rule, setRule] = useState(translator?.rule);
const [transApis, setTransApis] = useState(translator?.setting?.transApis || []);
const [commands, setCommands] = useState({});
const handleOpenSetting = () => {
if (!tran) {
if (!translator) {
browser?.runtime.openOptionsPage();
} else if (isExt) {
sendBgMsg(MSG_OPEN_OPTIONS);
@@ -54,14 +52,14 @@ export default function Popup({ setShowPopup, translator: tran }) {
try {
setRule({ ...rule, transOpen: e.target.checked ? "true" : "false" });
if (!tran) {
if (!translator) {
await sendTabMsg(MSG_TRANS_TOGGLE);
} else {
tran.toggle();
translator.toggle();
sendIframeMsg(MSG_TRANS_TOGGLE);
}
} catch (err) {
kissLog(err, "toggle trans");
kissLog("toggle trans", err);
}
};
@@ -70,14 +68,14 @@ export default function Popup({ setShowPopup, translator: tran }) {
const { name, value } = e.target;
setRule((pre) => ({ ...pre, [name]: value }));
if (!tran) {
if (!translator) {
await sendTabMsg(MSG_TRANS_PUTRULE, { [name]: value });
} else {
tran.updateRule({ [name]: value });
translator.updateRule({ [name]: value });
sendIframeMsg(MSG_TRANS_PUTRULE, { [name]: value });
}
} catch (err) {
kissLog(err, "update rule");
kissLog("update rule", err);
}
};
@@ -88,23 +86,23 @@ export default function Popup({ setShowPopup, translator: tran }) {
const handleSaveRule = async () => {
try {
let href = window.location.href;
if (!tran) {
if (!translator) {
const tab = await getCurTab();
href = tab.url;
}
const newRule = { ...rule, pattern: href.split("/")[2] };
if (isExt && tran) {
if (isExt && translator) {
sendBgMsg(MSG_SAVE_RULE, newRule);
} else {
saveRule(newRule);
}
} catch (err) {
kissLog(err, "save rule");
kissLog("save rule", err);
}
};
useEffect(() => {
if (tran) {
if (translator) {
return;
}
(async () => {
@@ -115,10 +113,10 @@ export default function Popup({ setShowPopup, translator: tran }) {
setTransApis(res.setting.transApis);
}
} catch (err) {
kissLog(err, "query rule");
kissLog("query rule", err);
}
})();
}, [tran]);
}, [translator]);
useEffect(() => {
(async () => {
@@ -130,7 +128,7 @@ export default function Popup({ setShowPopup, translator: tran }) {
commands[name] = shortcut;
});
} else {
const shortcuts = tran.setting.shortcuts;
const shortcuts = translator.setting.shortcuts;
if (shortcuts) {
Object.entries(shortcuts).forEach(([key, val]) => {
commands[key] = val.join("+");
@@ -139,21 +137,18 @@ export default function Popup({ setShowPopup, translator: tran }) {
}
setCommands(commands);
} catch (err) {
kissLog(err, "query cmds");
kissLog("query cmds", err);
}
})();
}, [tran]);
}, [translator]);
const optApis = useMemo(
() =>
OPT_TRANS_ALL.map((key) => ({
...(transApis[key] || DEFAULT_TRANS_APIS[key]),
apiKey: key,
}))
.filter((item) => !item.isDisabled)
.map(({ apiKey, apiName }) => ({
key: apiKey,
name: apiName?.trim() || apiKey,
transApis
.filter((api) => !api.isDisabled)
.map((api) => ({
key: api.apiSlug,
name: api.apiName || api.apiSlug,
})),
[transApis]
);
@@ -161,7 +156,7 @@ export default function Popup({ setShowPopup, translator: tran }) {
if (!rule) {
return (
<Box minWidth={300}>
{!tran && (
{!translator && (
<>
<Header />
<Divider />
@@ -178,7 +173,7 @@ export default function Popup({ setShowPopup, translator: tran }) {
const {
transOpen,
translator,
apiSlug,
fromLang,
toLang,
textStyle,
@@ -190,7 +185,7 @@ export default function Popup({ setShowPopup, translator: tran }) {
return (
<Box width={320}>
{!tran && (
{!translator && (
<>
<Header />
<Divider />
@@ -275,8 +270,8 @@ export default function Popup({ setShowPopup, translator: tran }) {
select
SelectProps={{ MenuProps: { disablePortal: true } }}
size="small"
value={translator}
name="translator"
value={apiSlug}
name="apiSlug"
label={i18n("translate_service")}
onChange={handleChange}
>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import Stack from "@mui/material/Stack";
import FavBtn from "./FavBtn";
import Typography from "@mui/material/Typography";
@@ -26,10 +26,10 @@ export default function DictCont({ text }) {
return;
}
// todo
// todo: 修复
const dictRes = await apiTranslate({
text,
translator: OPT_TRANS_BAIDU,
apiSlug: OPT_TRANS_BAIDU,
fromLang: "en",
toLang: "zh-CN",
});
@@ -45,70 +45,74 @@ export default function DictCont({ text }) {
})();
}, [text]);
if (error) {
return <Alert severity="error">{error}</Alert>;
}
const copyText = useMemo(() => {
if (!dictResult) {
return text;
}
return [
dictResult.src,
dictResult.voice
?.map(Object.entries)
.map((item) => item[0])
.map(([key, val]) => `${PHONIC_MAP[key]?.[0] || key} ${val}`)
.join(" "),
dictResult.content[0].mean
.map(({ pre, cont }) => {
return `${pre ? `[${pre}] ` : ""}${Object.keys(cont).join("; ")}`;
})
.join("\n"),
].join("\n");
}, [text, dictResult]);
if (loading) {
return <CircularProgress size={16} />;
}
if (!text || !dictResult) {
return;
}
const copyText = [
dictResult.src,
dictResult.voice
?.map(Object.entries)
.map((item) => item[0])
.map(([key, val]) => `${PHONIC_MAP[key]?.[0] || key} ${val}`)
.join(" "),
dictResult.content[0].mean
.map(({ pre, cont }) => {
return `${pre ? `[${pre}] ` : ""}${Object.keys(cont).join("; ")}`;
})
.join("\n"),
].join("\n");
return (
<Stack className="KT-transbox-dict" spacing={1}>
<Stack direction="row" justifyContent="space-between">
<Typography variant="subtitle1" style={{ fontWeight: "bold" }}>
{dictResult.src}
</Typography>
{text && (
<Stack direction="row" justifyContent="space-between">
<CopyBtn text={copyText} />
<FavBtn word={dictResult.src} />
<Typography variant="subtitle1" style={{ fontWeight: "bold" }}>
{dictResult?.src || text}
</Typography>
<Stack direction="row" justifyContent="space-between">
<CopyBtn text={copyText} />
<FavBtn word={dictResult?.src || text} />
</Stack>
</Stack>
</Stack>
)}
<Typography component="div">
{error && <Alert severity="error">{error}</Alert>}
{dictResult && (
<Typography component="div">
{dictResult.voice
?.map(Object.entries)
.map((item) => item[0])
.map(([key, val]) => (
<Typography
component="div"
key={key}
style={{ display: "inline-block" }}
>
<Typography component="span">{`${PHONIC_MAP[key]?.[0] || key} ${val}`}</Typography>
<AudioBtn text={dictResult.src} lan={PHONIC_MAP[key]?.[1]} />
<Typography component="div">
{dictResult.voice
?.map(Object.entries)
.map((item) => item[0])
.map(([key, val]) => (
<Typography
component="div"
key={key}
style={{ display: "inline-block" }}
>
<Typography component="span">{`${PHONIC_MAP[key]?.[0] || key} ${val}`}</Typography>
<AudioBtn text={dictResult.src} lan={PHONIC_MAP[key]?.[1]} />
</Typography>
))}
</Typography>
<Typography component="ul">
{dictResult.content[0].mean.map(({ pre, cont }, idx) => (
<Typography component="li" key={idx}>
{pre && `[${pre}] `}
{Object.keys(cont).join("; ")}
</Typography>
))}
</Typography>
</Typography>
<Typography component="ul">
{dictResult.content[0].mean.map(({ pre, cont }, idx) => (
<Typography component="li" key={idx}>
{pre && `[${pre}] `}
{Object.keys(cont).join("; ")}
</Typography>
))}
</Typography>
</Typography>
)}
</Stack>
);
}

View File

@@ -9,12 +9,12 @@ export default function FavBtn({ word }) {
const { favWords, toggleFav } = useFavWords();
const [loading, setLoading] = useState(false);
const handleClick = async () => {
const handleClick = () => {
try {
setLoading(true);
await toggleFav(word);
toggleFav(word);
} catch (err) {
kissLog(err, "set fav");
kissLog("set fav", err);
} finally {
setLoading(false);
}

View File

@@ -18,12 +18,7 @@ import LockIcon from "@mui/icons-material/Lock";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import CloseIcon from "@mui/icons-material/Close";
import { useI18n } from "../../hooks/I18n";
import {
OPT_TRANS_ALL,
OPT_LANGS_FROM,
OPT_LANGS_TO,
DEFAULT_TRANS_APIS,
} from "../../config";
import { OPT_LANGS_FROM, OPT_LANGS_TO } from "../../config";
import { useState, useRef, useMemo } from "react";
import TranCont from "./TranCont";
import DictCont from "./DictCont";
@@ -119,21 +114,18 @@ function TranForm({
const [editMode, setEditMode] = useState(false);
const [editText, setEditText] = useState("");
const [translator, setTranslator] = useState(tranboxSetting.translator);
const [apiSlug, setApiSlug] = useState(tranboxSetting.apiSlug);
const [fromLang, setFromLang] = useState(tranboxSetting.fromLang);
const [toLang, setToLang] = useState(tranboxSetting.toLang);
const inputRef = useRef(null);
const optApis = useMemo(
() =>
OPT_TRANS_ALL.map((key) => ({
...(transApis[key] || DEFAULT_TRANS_APIS[key]),
apiKey: key,
}))
.filter((item) => !item.isDisabled)
.map(({ apiKey, apiName }) => ({
key: apiKey,
name: apiName?.trim() || apiKey,
transApis
.filter((api) => !api.isDisabled)
.map((api) => ({
key: api.apiSlug,
name: api.apiName || api.apiSlug,
})),
[transApis]
);
@@ -194,11 +186,11 @@ function TranForm({
SelectProps={{ MenuProps: { disablePortal: true } }}
fullWidth
size="small"
value={translator}
name="translator"
value={apiSlug}
name="apiSlug"
label={i18n("translate_service")}
onChange={(e) => {
setTranslator(e.target.value);
setApiSlug(e.target.value);
}}
>
{optApis.map(({ key, name }) => (
@@ -266,7 +258,7 @@ function TranForm({
enDict === "-") && (
<TranCont
text={text}
translator={translator}
apiSlug={apiSlug}
fromLang={fromLang}
toLang={toLang}
toLang2={tranboxSetting.toLang2}
@@ -307,6 +299,7 @@ export default function TranBox({
enDict,
}) {
const [mouseHover, setMouseHover] = useState(false);
// todo: 这里的 SettingProvider 不应和 background 的共用
return (
<SettingProvider>
<ThemeProvider styles={extStyles}>

View File

@@ -3,7 +3,7 @@ import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import Stack from "@mui/material/Stack";
import { useI18n } from "../../hooks/I18n";
import { DEFAULT_TRANS_APIS } from "../../config";
import { DEFAULT_API_SETTING } from "../../config";
import { useEffect, useState } from "react";
import { apiTranslate } from "../../apis";
import CopyBtn from "./CopyBtn";
@@ -13,7 +13,7 @@ import { tryDetectLang } from "../../libs";
export default function TranCont({
text,
translator,
apiSlug,
fromLang,
toLang,
toLang2 = "en",
@@ -42,10 +42,11 @@ export default function TranCont({
}
const apiSetting =
transApis[translator] || DEFAULT_TRANS_APIS[translator];
transApis.find((api) => api.apiSlug === apiSlug) ||
DEFAULT_API_SETTING;
const [trText] = await apiTranslate({
text,
translator,
apiSlug,
fromLang,
toLang: to,
apiSetting,
@@ -57,7 +58,7 @@ export default function TranCont({
setLoading(false);
}
})();
}, [text, translator, fromLang, toLang, toLang2, transApis, langDetector]);
}, [text, apiSlug, fromLang, toLang, toLang2, transApis, langDetector]);
if (simpleStyle) {
return (

View File

@@ -201,7 +201,7 @@ export default function Slection({
});
};
} catch (err) {
kissLog(err, "registerMenuCommand");
kissLog("registerMenuCommand", err);
}
}, [handleTranbox, contextMenuType, langMap]);