Compare commits
328 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5b3ee8709 | ||
|
|
4f1e01dde0 | ||
|
|
d42ff51de5 | ||
|
|
c39861b7b7 | ||
|
|
d39b9fd73e | ||
|
|
ecab4ab634 | ||
|
|
5e67e15842 | ||
|
|
2af1a8b72c | ||
|
|
2510ed0ebb | ||
|
|
6827985289 | ||
|
|
a095a2c01c | ||
|
|
2033ff6777 | ||
|
|
0c22288833 | ||
|
|
0576150067 | ||
|
|
9cdcf616f7 | ||
|
|
2de10364f3 | ||
|
|
562559a1b0 | ||
|
|
0f0b7313bb | ||
|
|
412fc87d1e | ||
|
|
0a1abab475 | ||
|
|
b63ef8c1aa | ||
|
|
c38a3d439d | ||
|
|
dd395e668f | ||
|
|
8a5ef441f9 | ||
|
|
d9acbe865f | ||
|
|
dc35fef873 | ||
|
|
4e9293ae0f | ||
|
|
36973b8693 | ||
|
|
0ab734d1a5 | ||
|
|
bfce9b525a | ||
|
|
f19b6ef02f | ||
|
|
1992908a85 | ||
|
|
a6bcafa8f6 | ||
|
|
eabcb06eeb | ||
|
|
21dcbfa4c4 | ||
|
|
b6c074a242 | ||
|
|
f6d095d533 | ||
|
|
7a36251d3f | ||
|
|
3fda4d0da9 | ||
|
|
2fa8917d5e | ||
|
|
67149af64b | ||
|
|
0104cb9f29 | ||
|
|
1afe976777 | ||
|
|
d9b4399c57 | ||
|
|
ac7b3b9824 | ||
|
|
359206630d | ||
|
|
96dfee90ab | ||
|
|
9ace600fce | ||
|
|
549b945d0f | ||
|
|
4528a79c87 | ||
|
|
951ce985b5 | ||
|
|
001f04a9ee | ||
|
|
3844d2eb75 | ||
|
|
e593221e02 | ||
|
|
8cc3801dc5 | ||
|
|
251e57ec61 | ||
|
|
769a4f00aa | ||
|
|
9bafc937d5 | ||
|
|
2d0ea09e06 | ||
|
|
aeec5e361c | ||
|
|
71b2d62c9f | ||
|
|
40b3072e5f | ||
|
|
cae9338274 | ||
|
|
4c2781b3b6 | ||
|
|
b2b5bef9f5 | ||
|
|
df8c96569a | ||
|
|
e562f0b851 | ||
|
|
7b2b48f0d1 | ||
|
|
c353c88db8 | ||
|
|
171dbb7509 | ||
|
|
65e8fabe7d | ||
|
|
389f0b6f82 | ||
|
|
039566ded5 | ||
|
|
d18b31692b | ||
|
|
c993c15c92 | ||
|
|
3c5ffc045f | ||
|
|
261bb7aa6f | ||
|
|
96a7a41759 | ||
|
|
d563521eb1 | ||
|
|
a08c42db8b | ||
|
|
8e026238ae | ||
|
|
7412b3a5c8 | ||
|
|
b60b770ed6 | ||
|
|
20c4d6f6eb | ||
|
|
2437c75d75 | ||
|
|
867c2209b1 | ||
|
|
fffa448425 | ||
|
|
b1142b88f1 | ||
|
|
6d95e7debc | ||
|
|
6bafcb0ec0 | ||
|
|
4935abcf33 | ||
|
|
14f74b76bb | ||
|
|
6b9a1a49bb | ||
|
|
533a0e2d5b | ||
|
|
393f1a29d5 | ||
|
|
1dabbfc4de | ||
|
|
7665f8c260 | ||
|
|
eef5e25a00 | ||
|
|
261f29c185 | ||
|
|
2a46939aa5 | ||
|
|
779c9fc850 | ||
|
|
a20a06320d | ||
|
|
943a9e86f0 | ||
|
|
563242c5f1 | ||
|
|
d39a016d5f | ||
|
|
fa87d87011 | ||
|
|
7dc847fca2 | ||
|
|
343edcdbad | ||
|
|
b631703aa6 | ||
|
|
6dd6b73c2f | ||
|
|
2b496bda31 | ||
|
|
17c8d198c3 | ||
|
|
86f8d9694d | ||
|
|
3948cb74ca | ||
|
|
4ebced1e71 | ||
|
|
d4e58fc925 | ||
|
|
2bfb27f346 | ||
|
|
c4fba1c905 | ||
|
|
4a5e6c2a23 | ||
|
|
5fb7157f57 | ||
|
|
a8c38d2a00 | ||
|
|
cbc82fff64 | ||
|
|
5c44ba1da8 | ||
|
|
fd2f0e513b | ||
|
|
7fd2a0f187 | ||
|
|
fd355eeeab | ||
|
|
c93e370370 | ||
|
|
57d218a17f | ||
|
|
ffc43a67f2 | ||
|
|
7deb5b885a | ||
|
|
5d5e23482f | ||
|
|
36c1e40d64 | ||
|
|
c6f4fe2b7b | ||
|
|
1a23627193 | ||
|
|
d73b2377bf | ||
|
|
4559ab7ec2 | ||
|
|
1d9679e516 | ||
|
|
4c30f6b012 | ||
|
|
511210939f | ||
|
|
d2addf58cb | ||
|
|
f7db410235 | ||
|
|
dd18b04cea | ||
|
|
58d8009e91 | ||
|
|
663407b95d | ||
|
|
f78901603e | ||
|
|
5e1101baeb | ||
|
|
2ba2441900 | ||
|
|
4446ae3dbd | ||
|
|
72668f0386 | ||
|
|
a9b858ec6f | ||
|
|
e1f902c203 | ||
|
|
be6e34ba52 | ||
|
|
39b3b00117 | ||
|
|
763019f0c5 | ||
|
|
d743271be8 | ||
|
|
992dad26aa | ||
|
|
9bd0e67474 | ||
|
|
5767a4afb2 | ||
|
|
9e4c510684 | ||
|
|
16607fb069 | ||
|
|
e047a06432 | ||
|
|
9e09fd898a | ||
|
|
799c32a871 | ||
|
|
483f33b5c9 | ||
|
|
d444fd4fba | ||
|
|
0aae93ba2e | ||
|
|
9608bea3bf | ||
|
|
a038a1ecdc | ||
|
|
c82cdd7f8f | ||
|
|
0c6d5c3c61 | ||
|
|
900426f359 | ||
|
|
1d760fc93a | ||
|
|
61571e0f61 | ||
|
|
c9eb423c89 | ||
|
|
45b294a121 | ||
|
|
3a3f1fabe1 | ||
|
|
03177a09b3 | ||
|
|
cae391f62b | ||
|
|
2a5e9db079 | ||
|
|
650d6e8b41 | ||
|
|
1daf134b31 | ||
|
|
e1dfa35c6c | ||
|
|
73f80692d3 | ||
|
|
42c7dae495 | ||
|
|
b2a1309caa | ||
|
|
94bf5f9580 | ||
|
|
704ebdc9d7 | ||
|
|
165da4e559 | ||
|
|
d3e3b484bf | ||
|
|
192f8faa5b | ||
|
|
579d5cb0a3 | ||
|
|
07fca5b9af | ||
|
|
866a63ab6c | ||
|
|
97b4935bc4 | ||
|
|
30129abef3 | ||
|
|
24d904b32c | ||
|
|
5f0ce57ead | ||
|
|
51f58d095a | ||
|
|
d22e3838c4 | ||
|
|
adbb421b7b | ||
|
|
eaa47af269 | ||
|
|
a6cb5544f8 | ||
|
|
9e91faa660 | ||
|
|
8636fadc72 | ||
|
|
0621957592 | ||
|
|
8ec06b0c84 | ||
|
|
d47f8d7ee9 | ||
|
|
24f8959525 | ||
|
|
983740578b | ||
|
|
b5f79ed7cd | ||
|
|
bbb0e79d4e | ||
|
|
471dc05897 | ||
|
|
7a772d2459 | ||
|
|
1d92421960 | ||
|
|
aeaaf429d7 | ||
|
|
84432e98ae | ||
|
|
77c6102de7 | ||
|
|
ab5dd82169 | ||
|
|
23e7b69dc5 | ||
|
|
3dc8f393f2 | ||
|
|
8a2144f263 | ||
|
|
c1c59caa10 | ||
|
|
d27ebd90b6 | ||
|
|
467745c1e9 | ||
|
|
537378a038 | ||
|
|
4b5ed30e5b | ||
|
|
52b7f6a225 | ||
|
|
f31675d8a2 | ||
|
|
dd46a8450c | ||
|
|
b0843f7d66 | ||
|
|
daadc0195c | ||
|
|
298dec6957 | ||
|
|
bf39d85dfa | ||
|
|
30a9de25a8 | ||
|
|
af1ecf0bd4 | ||
|
|
fe55a2cd3c | ||
|
|
5a33d4e57e | ||
|
|
7f46a9023c | ||
|
|
dfd943b621 | ||
|
|
7007d0d922 | ||
|
|
601678500d | ||
|
|
9bfb504381 | ||
|
|
8a03b0cf15 | ||
|
|
11ba89de0a | ||
|
|
bac7f62eea | ||
|
|
eef90ea02b | ||
|
|
0650df534a | ||
|
|
9ef8c8b823 | ||
|
|
d7e08da0b2 | ||
|
|
ef361e0798 | ||
|
|
6855332092 | ||
|
|
121d523e02 | ||
|
|
42a375c4c7 | ||
|
|
a1dd705d97 | ||
|
|
71f90b36ca | ||
|
|
37facdc3c1 | ||
|
|
66b4f547ff | ||
|
|
d27b9c7f2d | ||
|
|
278ff9c6bc | ||
|
|
d6fe1ce9d7 | ||
|
|
0bfa5256b8 | ||
|
|
72ccfc8aec | ||
|
|
d117c5dc10 | ||
|
|
9312783f44 | ||
|
|
e5b16ebfd3 | ||
|
|
5d1d65c2d3 | ||
|
|
9ca1309cec | ||
|
|
a03afc05f5 | ||
|
|
0198963584 | ||
|
|
58e745d967 | ||
|
|
377e347d68 | ||
|
|
bac0704d3d | ||
|
|
d2ff46edf6 | ||
|
|
f908372b4e | ||
|
|
5d44ff4913 | ||
|
|
4c9aa66048 | ||
|
|
b6a09b99ab | ||
|
|
3a0dcb1a52 | ||
|
|
5015503b4c | ||
|
|
16423feea4 | ||
|
|
9703514698 | ||
|
|
de7a97fb76 | ||
|
|
319aaf8132 | ||
|
|
74bc58ba91 | ||
|
|
d622db0d7c | ||
|
|
de1ddf2362 | ||
|
|
32c0fc860b | ||
|
|
1938f432dd | ||
|
|
a5cfb0ca1d | ||
|
|
a172234fb0 | ||
|
|
63f989b31a | ||
|
|
2ae5d01d5c | ||
|
|
130f1deed1 | ||
|
|
5880d85b48 | ||
|
|
9455670e80 | ||
|
|
e369321c66 | ||
|
|
efc51b0d46 | ||
|
|
d6f3b23b88 | ||
|
|
0a4fa7b9f8 | ||
|
|
2b3e4a8d25 | ||
|
|
bf3a16f96d | ||
|
|
b416e72820 | ||
|
|
ca84bdb227 | ||
|
|
148a4e97a6 | ||
|
|
a13493ebc2 | ||
|
|
ce4ac79e5f | ||
|
|
8f76ea49e7 | ||
|
|
923d3293cd | ||
|
|
7379ff8d15 | ||
|
|
18ebec350d | ||
|
|
3b0cbc53aa | ||
|
|
f00e8ffa4d | ||
|
|
d6f7aad1c3 | ||
|
|
092ea6e836 | ||
|
|
d565e2464a | ||
|
|
2f5d875c47 | ||
|
|
fdb2ddc5f7 | ||
|
|
7a12c5315a | ||
|
|
60d788288d | ||
|
|
dc3c510d57 | ||
|
|
ec6a49f01e | ||
|
|
2b9bfbc20d | ||
|
|
06a51df834 | ||
|
|
6fa183dc56 | ||
|
|
b3cb4049ed | ||
|
|
602b51b1f5 | ||
|
|
a83039577c | ||
|
|
1c77a289a6 |
16
.env
16
.env
@@ -2,26 +2,18 @@ GENERATE_SOURCEMAP=false
|
||||
|
||||
REACT_APP_NAME=KISS Translator
|
||||
REACT_APP_NAME_CN=简约翻译
|
||||
REACT_APP_VERSION=1.8.4
|
||||
REACT_APP_VERSION=2.0.1
|
||||
|
||||
REACT_APP_HOMEPAGE=https://github.com/fishjar/kiss-translator
|
||||
|
||||
REACT_APP_OPTIONSPAGE=https://fishjar.github.io/kiss-translator/options.html
|
||||
REACT_APP_OPTIONSPAGE2=https://kiss-translator.rayjar.com/options
|
||||
REACT_APP_OPTIONSPAGE_DEV=http://localhost:3000/options.html
|
||||
|
||||
REACT_APP_LOGOURL=https://fishjar.github.io/kiss-translator/images/logo192.png
|
||||
REACT_APP_LOGOURL2=https://kiss-translator.rayjar.com/images/logo192.png
|
||||
|
||||
REACT_APP_RULESURL=https://fishjar.github.io/kiss-rules/kiss-rules.json
|
||||
REACT_APP_RULESURL_ON=https://fishjar.github.io/kiss-rules/kiss-rules-on.json
|
||||
REACT_APP_RULESURL_OFF=https://fishjar.github.io/kiss-rules/kiss-rules-off.json
|
||||
|
||||
REACT_APP_VERSIONFILE=https://fishjar.github.io/kiss-translator/version.txt
|
||||
REACT_APP_VERSIONFILE2=https://kiss-translator.rayjar.com/version.txt
|
||||
REACT_APP_RULESURL=https://fishjar.github.io/kiss-rules/kiss-rules_v2.json
|
||||
REACT_APP_RULESURL_ON=https://fishjar.github.io/kiss-rules/kiss-rules-on_v2.json
|
||||
REACT_APP_RULESURL_OFF=https://fishjar.github.io/kiss-rules/kiss-rules-off_v2.json
|
||||
|
||||
REACT_APP_USERSCRIPT_DOWNLOADURL=https://fishjar.github.io/kiss-translator/kiss-translator.user.js
|
||||
REACT_APP_USERSCRIPT_DOWNLOADURL2=https://kiss-translator.rayjar.com/kiss-translator.user.js
|
||||
|
||||
REACT_APP_USERSCRIPT_IOS_DOWNLOADURL=https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js
|
||||
REACT_APP_USERSCRIPT_IOS_DOWNLOADURL2=https://kiss-translator.rayjar.com/kiss-translator-ios-safari.user.js
|
||||
|
||||
37
.github/workflows/release.yml
vendored
37
.github/workflows/release.yml
vendored
@@ -7,28 +7,28 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.7.6
|
||||
- uses: actions/setup-node@v3
|
||||
version: latest
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18.17.0"
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
- run: pnpm install
|
||||
- run: pnpm build
|
||||
- uses: actions/upload-artifact@v3
|
||||
- run: pnpm build+zip
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: build
|
||||
deploy-web:
|
||||
needs: build
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/download-artifact@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: build
|
||||
@@ -37,7 +37,8 @@ jobs:
|
||||
with:
|
||||
folder: build/web
|
||||
create-release:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
upload_url: ${{ steps.create-release.outputs.upload_url }}
|
||||
steps:
|
||||
@@ -54,18 +55,14 @@ jobs:
|
||||
needs: [build, create-release]
|
||||
strategy:
|
||||
matrix:
|
||||
client: ["chrome", "edge", "firefox", "userscript"]
|
||||
runs-on: ubuntu-22.04
|
||||
client: ["chrome", "edge", "firefox", "userscript", "thunderbird"]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/download-artifact@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: build
|
||||
- name: Zip Release
|
||||
run: |
|
||||
cd build
|
||||
zip -r ${{ matrix.client }}.zip ${{ matrix.client }}
|
||||
- uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
/.obsidian
|
||||
.pnp.js
|
||||
.yarn
|
||||
|
||||
|
||||
4
.prettierignore
Normal file
4
.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
build
|
||||
public
|
||||
package.json
|
||||
24
.prettierrc
Normal file
24
.prettierrc
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"arrowParens": "always",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "lf",
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"insertPragma": false,
|
||||
"singleAttributePerLine": false,
|
||||
"bracketSameLine": false,
|
||||
"jsxBracketSameLine": false,
|
||||
"jsxSingleQuote": false,
|
||||
"printWidth": 80,
|
||||
"proseWrap": "preserve",
|
||||
"quoteProps": "as-needed",
|
||||
"requirePragma": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"useTabs": false,
|
||||
"embeddedLanguageFormatting": "auto",
|
||||
"vueIndentScriptAndStyle": false,
|
||||
"experimentalTernaries": false,
|
||||
"parser": "babel"
|
||||
}
|
||||
117
README.en.md
117
README.en.md
@@ -1,5 +1,42 @@
|
||||
# KISS Translator
|
||||
|
||||
**New Version Preview:**
|
||||
|
||||
After a period of intermittent development, the planned features for the new version are essentially complete. The main new features are as follows:
|
||||
|
||||
* **Core Translation Logic Refactoring:**
|
||||
* Supports both automatic text detection and manual selection modes.
|
||||
* The automatic text detection mode enables complete translation for the vast majority of websites without the need to write specific rules.
|
||||
* The previous manual rule mode has been retained for meticulous optimization on specific websites.
|
||||
* Supports rich text translation, preserving links and other text styles from the original content as much as possible.
|
||||
* Optimize the display effect of showing only translated text (hiding original text).
|
||||
|
||||
* **API Refactoring:**
|
||||
* Supports adding and deleting an arbitrary number of APIs.
|
||||
* Supports aggregating text for sending, reducing the number of calls to the translation API and improving performance.
|
||||
* Supports the built-in Chrome AI translation API, enabling AI-powered translation without an internet connection.
|
||||
* Supports AI contextual conversation memory to enhance translation quality.
|
||||
* All APIs support advanced features such as hooks and custom parameters.
|
||||
* Added support for Azure AI translation interface.
|
||||
|
||||
* **Optimized YouTube Subtitle Support:**
|
||||
* Supports translating video subtitles with any translation service and displaying them bilingually.
|
||||
* Includes a built-in basic algorithm for subtitle merging and sentence splitting to improve translation results.
|
||||
* Supports an AI-powered sentence splitting function to further enhance translation quality.
|
||||
|
||||
* **English Dictionary Redundancy:**
|
||||
* Added Bing and Youdao dictionaries.
|
||||
* Fixed the vocabulary collection feature.
|
||||
|
||||
* **User Experience Optimization:**
|
||||
* The pop-up translation box for selected text now supports simultaneous translation by multiple services.
|
||||
* The translation control panel has been updated with many new quick-toggle functions.
|
||||
* Added a Playground page for convenient API debugging.
|
||||
|
||||
**Note:** Due to extensive refactoring, the configuration file for the new version is not backward compatible with the old version. Therefore, please back up your data manually before upgrading. Furthermore, **do not import old configuration files after upgrading to the new version.**
|
||||
|
||||
English | [简体中文](README.md)
|
||||
|
||||
A simple, open source [bilingual translation extension & Greasemonkey script](https://github.com/fishjar/kiss-translator).
|
||||
|
||||
[kiss-translator.webm](https://github.com/fishjar/kiss-translator/assets/1157624/f7ba8a5c-e4a8-4d5a-823a-5c5c67a0a47f)
|
||||
@@ -9,25 +46,44 @@ A simple, open source [bilingual translation extension & Greasemonkey script](ht
|
||||
- [x] Keep it simple, smart
|
||||
- [x] Open source
|
||||
- [x] Adapt to common browsers
|
||||
- [x] Chrome/Edge/Firefox/Kiwi
|
||||
- [ ] Safari
|
||||
- [x] Chrome/Edge
|
||||
- [x] Firefox
|
||||
- [x] Kiwi (Android)
|
||||
- [x] Orion (iOS)
|
||||
- [x] Safari
|
||||
- [x] Thunderbird
|
||||
- [x] Supports multiple translation services
|
||||
- [x] Google/Microsoft/DeepL/OpenAI/Gemini/CloudflareAI/Baidu/Tencent
|
||||
- [x] Google/Microsoft
|
||||
- [x] Tencent/Volcengine
|
||||
- [x] OpenAI/Gemini/Claude/Ollama/DeepSeek/OpenRouter
|
||||
- [x] DeepL/DeepLX/NiuTrans
|
||||
- [x] BuiltinAI/AzureAI/CloudflareAI
|
||||
- [x] Custom translation interface
|
||||
- [x] Covers common translation scenarios
|
||||
- [x] Web bilingual translation
|
||||
- [x] Input box translation
|
||||
- [x] Seletction translation
|
||||
- [x] Open the translation box on any page
|
||||
- [x] Favorite Words
|
||||
- [x] Mouseover translation
|
||||
- [x] YouTube subtitle translation
|
||||
- [x] Support for various translation effects
|
||||
- [x] Customizable text recognition and full-text translation
|
||||
- [x] Customizable translation styles
|
||||
- [x] Support for rich text translation and display
|
||||
- [x] Support for displaying only the translated text (hiding the original text)
|
||||
- [x] Advanced translation API features
|
||||
- [x] Aggregate and send translated texts in batches
|
||||
- [x] AI contextual conversation memory
|
||||
- [x] Customizable AI terminology dictionary
|
||||
- [x] AI-powered subtitle segmentation and translation
|
||||
- [x] Customizable hooks and parameters
|
||||
- [x] Cross-client data synchronization
|
||||
- [x] KISS-Worker(cloudflare/docker)
|
||||
- [x] WebDAV
|
||||
- [x] Custom translation rules
|
||||
- [x] Rule subscription/rule sharing
|
||||
- [x] Customized terminology
|
||||
- [x] Custom translation style
|
||||
- [x] Custom shortcut keys
|
||||
- `Alt+Q` Toggle Translation
|
||||
- `Alt+C` Toggle Styles
|
||||
@@ -44,13 +100,18 @@ A simple, open source [bilingual translation extension & Greasemonkey script](ht
|
||||
> - Grease Monkey script will encounter more usage problems (cross domain issues, script conflicts, etc.)
|
||||
|
||||
- [x] Browser extension
|
||||
- [x] Chrome/Kiwi [Installation address](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=zh-CN)
|
||||
- [x] Chrome [Installation address](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=zh-CN)
|
||||
- [x] Kiwi (Android)
|
||||
- [x] Orion (iOS)
|
||||
- [x] Edge [Installation address](https://microsoftedge.microsoft.com/addons/detail/%E7%AE%80%E7%BA%A6%E7%BF%BB%E8%AF%91/jemckldkclkinpjighnoilpbldbdmmlh?hl=zh-CN)
|
||||
- [x] Firefox [Installation address](https://addons.mozilla.org/zh-CN/firefox/addon/kiss-translator/)
|
||||
- [ ] Safari
|
||||
- [ ] Safari (Mac)
|
||||
- [ ] Safari (iOS)
|
||||
- [x] Thunderbird [Download address](https://github.com/fishjar/kiss-translator/releases)
|
||||
- [x] GreaseMonkey Script
|
||||
- [x] Chrome/Edge/Firefox ([Tampermonkey](https://www.tampermonkey.net/)/[Violentmonkey](https://violentmonkey.github.io/)) [Installation link](https://fishjar.github.io/kiss-translator/kiss-translator.user.js)
|
||||
- Greasy Fork [Installation address](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator)
|
||||
- [Greasy Fork](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator)
|
||||
- [x] iOS Safari ([Userscripts Safari](https://github.com/quoid/userscripts)) [Installation link](https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js)
|
||||
|
||||
## Associated Projects
|
||||
@@ -62,19 +123,49 @@ A simple, open source [bilingual translation extension & Greasemonkey script](ht
|
||||
- Community subscription rules: [https://github.com/fishjar/kiss-rules](https://github.com/fishjar/kiss-rules)
|
||||
- Provides the latest and most complete list of subscription rules maintained by the community.
|
||||
- Help with rules-related issues.
|
||||
- Translation interface agent: [https://github.com/fishjar/kiss-proxy](https://github.com/fishjar/kiss-proxy)
|
||||
- If you encounter network problems when accessing a certain translation interface, this proxy service may help you.
|
||||
- Deploy and manage by yourself.
|
||||
- Minimalistic Dictionary Plugin: [https://github.com/fishjar/kiss-dictionary](https://github.com/fishjar/kiss-dictionary)
|
||||
- A word-marking translation plug-in used with this project.
|
||||
- Supports query of English words, sentences and Chinese characters.
|
||||
- Supports history records and word collections.
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
### How to Set Keyboard Shortcuts
|
||||
|
||||
Set this in the extension management page, for example:
|
||||
|
||||
- chrome [chrome://extensions/shortcuts](chrome://extensions/shortcuts)
|
||||
- firefox [about:addons](about:addons)
|
||||
|
||||
### What is the priority order of rule settings?
|
||||
|
||||
Personal Rules > Subscription Rules > Global Rules
|
||||
|
||||
Among these, Global Rules have the lowest priority but are very important as they serve as the default rules.
|
||||
|
||||
### Local Ollama interface cannot be used
|
||||
|
||||
If encountering a 403 error, refer to: https://github.com/fishjar/kiss-translator/issues/174
|
||||
|
||||
### Custom API doesn't work in Tampermonkey scripts
|
||||
|
||||
Tampermonkey scripts require adding domains to the whitelist; otherwise, requests cannot be sent.
|
||||
|
||||
## Future Plans
|
||||
|
||||
This is a side project with no strict timeline. Community contributions are welcome. The following are preliminary feature directions:
|
||||
|
||||
- [x] **Batch Text Requests**: Optimize request strategy to reduce translation API calls and improve performance.
|
||||
- [x] **Enhanced Rich Text Translation**: Support accurate translation of complex page structures and rich text content.
|
||||
- [x] **Advanced Custom/AI Interfaces**: Add support for context memory, multi-turn conversations, and other advanced AI features.
|
||||
- [x] **Fallback English Dictionary**: When translation services fail, fall back to a local dictionary lookup.
|
||||
- [x] **Improved YouTube Subtitle Support**: Enhance merging and translation experience for streaming subtitles, reducing sentence fragmentation.
|
||||
- [ ] **Upgraded Rule Collaboration System**: Introduce more flexible rule sharing, version management, and community review processes.
|
||||
|
||||
If you're interested in any of these directions, feel free to discuss in [Issues](https://github.com/fishjar/kiss-translator/issues) or submit a PR!
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
```sh
|
||||
git clone https://github.com/fishjar/kiss-translator.git
|
||||
cd kiss-translator
|
||||
git checkout dev # Submit a PR suggestion to push to the dev branch
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
113
README.md
113
README.md
@@ -1,5 +1,38 @@
|
||||
# 简约翻译
|
||||
|
||||
> **新版预告**:
|
||||
>
|
||||
> 经过一段时间断续开发,新版的预期功能已基本完成,主要引入的新特性如下:
|
||||
>
|
||||
> - 核心翻译逻辑重构:
|
||||
> - 支持自动识别文本与手动选择两种模式。
|
||||
> - 自动识别文本模式使得绝大部分网站无需编写规则也能翻译完整。
|
||||
> - 保留之前的手动规则模式,可以针对特定网站极致优化。
|
||||
> - 支持富文本翻译,能够尽量保留原文中的链接及其他文本样式。
|
||||
> - 优化仅显示译文(隐藏原文)显示效果。
|
||||
> - 接口重构:
|
||||
> - 支持添加、删除任意数量的接口。
|
||||
> - 支持聚合发送文本,减少翻译接口调用次数,提升性能。
|
||||
> - 支持chrome内置AI翻译接口,无需通过网络即可实现AI翻译。
|
||||
> - 支持AI上下文会话记忆功能,提升翻译效果。
|
||||
> - 所有接口均支持Hook和自定义参数等高级功能。
|
||||
> - 新增Azure AI翻译接口支持
|
||||
> - 优化 YouTube 字幕支持:
|
||||
> - 支持任意翻译服务对视频字幕进行翻译并双语显示。
|
||||
> - 内置基础的字幕合并与断句算法,提升翻译效果。
|
||||
> - 支持AI断句功能,可进一步提升翻译质量。
|
||||
> - 英文词典备灾:
|
||||
> - 新增bing、有道词典。
|
||||
> - 修复词汇收藏功能。
|
||||
> - 用户操作优化:
|
||||
> - 划词翻译框支持多种翻译服务同时翻译。
|
||||
> - 翻译控制面板新增许多快捷切换功能。
|
||||
> - 新增Playground页面,方便调试接口。
|
||||
>
|
||||
> 注意:由于经过大量重构,使得新版配置文件很难与旧版兼容,因此在升级前请手动备份相关数据。并且,**升级新版后,勿再导入旧版配置**。
|
||||
|
||||
[English](README.en.md) | 简体中文
|
||||
|
||||
一个简约、开源的 [双语对照翻译扩展 & 油猴脚本](https://github.com/fishjar/kiss-translator)。
|
||||
|
||||
[kiss-translator.webm](https://github.com/fishjar/kiss-translator/assets/1157624/f7ba8a5c-e4a8-4d5a-823a-5c5c67a0a47f)
|
||||
@@ -9,25 +42,44 @@
|
||||
- [x] 保持简约
|
||||
- [x] 开放源代码
|
||||
- [x] 适配常见浏览器
|
||||
- [x] Chrome/Edge/Firefox/Kiwi
|
||||
- [ ] Safari
|
||||
- [x] Chrome/Edge
|
||||
- [x] Firefox
|
||||
- [x] Kiwi (Android)
|
||||
- [x] Orion (iOS)
|
||||
- [x] Safari
|
||||
- [x] Thunderbird
|
||||
- [x] 支持多种翻译服务
|
||||
- [x] Google/Microsoft/DeepL/OpenAI/Gemini/CloudflareAI/Baidu/Tencent
|
||||
- [x] Google/Microsoft
|
||||
- [x] Tencent/Volcengine
|
||||
- [x] OpenAI/Gemini/Claude/Ollama/DeepSeek/OpenRouter
|
||||
- [x] DeepL/DeepLX/NiuTrans
|
||||
- [x] BuiltinAI/AzureAI/CloudflareAI
|
||||
- [x] 自定义翻译接口
|
||||
- [x] 覆盖常见翻译场景
|
||||
- [x] 网页双语对照翻译
|
||||
- [x] 输入框翻译
|
||||
- [x] 划词翻译
|
||||
- [x] 任意页面打开翻译框
|
||||
- [x] 收藏词汇
|
||||
- [x] 鼠标悬停翻译
|
||||
- [x] YouTube 字幕翻译
|
||||
- [x] 支持多样翻译效果
|
||||
- [x] 自定识别文本,全文翻译
|
||||
- [x] 自定义译文样式
|
||||
- [x] 支持富文本翻译及显示
|
||||
- [x] 支持仅显示译文(隐藏原文)
|
||||
- [x] 翻译接口高级功能
|
||||
- [x] 聚合批量发送翻译文本
|
||||
- [x] AI上下文会话记忆
|
||||
- [x] 自定义AI术语词典
|
||||
- [x] 字幕文本AI智能断句及翻译
|
||||
- [x] 自定义Hook,自定义参数
|
||||
- [x] 跨客户端数据同步
|
||||
- [x] KISS-Worker(cloudflare/docker)
|
||||
- [x] WebDAV
|
||||
- [x] 自定义翻译规则
|
||||
- [x] 规则订阅/规则分享
|
||||
- [x] 自定义专业术语
|
||||
- [x] 自定义译文样式
|
||||
- [x] 自定义快捷键
|
||||
- `Alt+Q` 开启翻译
|
||||
- `Alt+C` 切换样式
|
||||
@@ -44,13 +96,18 @@
|
||||
> - 油猴脚本会遇到更多使用上的问题(跨域问题、脚本冲突等)
|
||||
|
||||
- [x] 浏览器扩展
|
||||
- [x] Chrome/Kiwi [安装地址](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=zh-CN)
|
||||
- [x] Chrome [安装地址](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=zh-CN)
|
||||
- [x] Kiwi (Android)
|
||||
- [x] Orion (iOS)
|
||||
- [x] Edge [安装地址](https://microsoftedge.microsoft.com/addons/detail/%E7%AE%80%E7%BA%A6%E7%BF%BB%E8%AF%91/jemckldkclkinpjighnoilpbldbdmmlh?hl=zh-CN)
|
||||
- [x] Firefox [安装地址](https://addons.mozilla.org/zh-CN/firefox/addon/kiss-translator/)
|
||||
- [ ] Safari
|
||||
- [ ] Safari (Mac)
|
||||
- [ ] Safari (iOS)
|
||||
- [x] Thunderbird [下载地址](https://github.com/fishjar/kiss-translator/releases)
|
||||
- [x] 油猴脚本
|
||||
- [x] Chrome/Edge/Firefox ([Tampermonkey](https://www.tampermonkey.net/)/[Violentmonkey](https://violentmonkey.github.io/)) [安装链接](https://fishjar.github.io/kiss-translator/kiss-translator.user.js)
|
||||
- Greasy Fork [安装地址](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator)
|
||||
- [Greasy Fork](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator)
|
||||
- [x] iOS Safari ([Userscripts Safari](https://github.com/quoid/userscripts)) [安装链接](https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js)
|
||||
|
||||
## 关联项目
|
||||
@@ -62,19 +119,49 @@
|
||||
- 社区订阅规则: [https://github.com/fishjar/kiss-rules](https://github.com/fishjar/kiss-rules)
|
||||
- 提供社区维护的,最新最全的订阅规则列表。
|
||||
- 求助规则相关的问题。
|
||||
- 翻译接口代理: [https://github.com/fishjar/kiss-proxy](https://github.com/fishjar/kiss-proxy)
|
||||
- 如果访问某个翻译接口遇到网络问题,这个代理服务也许可以帮到你。
|
||||
- 自己部署,自己管理。
|
||||
- 简约词典插件: [https://github.com/fishjar/kiss-dictionary](https://github.com/fishjar/kiss-dictionary)
|
||||
- 搭配本项目一起使用的划词翻译插件。
|
||||
- 支持英文单词、句子、汉字的查询。
|
||||
- 支持历史记录、单词收藏。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 如何设置快捷键
|
||||
|
||||
在插件管理那里设置,例如:
|
||||
|
||||
- chrome [chrome://extensions/shortcuts](chrome://extensions/shortcuts)
|
||||
- firefox [about:addons](about:addons)
|
||||
|
||||
### 规则设置的优先级是如何的
|
||||
|
||||
个人规则 > 订阅规则 > 全局规则
|
||||
|
||||
其中全局规则优先级最低,但非常重要,相当于兜底规则。
|
||||
|
||||
### 本地的Ollama接口不能使用
|
||||
|
||||
如果出现403的情况,参考:https://github.com/fishjar/kiss-translator/issues/174
|
||||
|
||||
### 填写的接口在油猴脚本不能使用
|
||||
|
||||
油猴脚本需要增加域名白名单,否则不能发出请求。
|
||||
|
||||
## 未来规划
|
||||
|
||||
本项目为业余开发,无严格时间表,欢迎社区共建。以下为初步设想的功能方向:
|
||||
|
||||
- [x] **聚合发送文本**:优化请求策略,减少翻译接口调用次数,提升性能。
|
||||
- [x] **增强富文本翻译**:支持更复杂的页面结构和富文本内容的准确翻译。
|
||||
- [x] **强化自定义/AI 接口**:支持上下文记忆、多轮对话等高级 AI 功能。
|
||||
- [x] **英文词典备灾机制**:当翻译服务失效时,可切换其他词典或 fallback 到本地词典查询。
|
||||
- [x] **优化 YouTube 字幕支持**:改进流式字幕的合并与翻译体验,减少断句。
|
||||
- [ ] **规则共建机制升级**:引入更灵活的规则分享、版本管理与社区评审流程。
|
||||
|
||||
如果你对某个方向感兴趣,欢迎在 [Issues](https://github.com/fishjar/kiss-translator/issues) 中讨论或提交 PR!
|
||||
|
||||
## 开发指引
|
||||
|
||||
```sh
|
||||
git clone https://github.com/fishjar/kiss-translator.git
|
||||
cd kiss-translator
|
||||
git checkout dev # 提交PR建议推送到dev分支
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
@@ -32,6 +32,7 @@ const extWebpack = (config, env) => {
|
||||
options: paths.appSrc + "/options.js",
|
||||
background: paths.appSrc + "/background.js",
|
||||
content: paths.appSrc + "/content.js",
|
||||
injector: paths.appSrc + "/injector.js",
|
||||
};
|
||||
|
||||
config.output.filename = "[name].js";
|
||||
@@ -92,8 +93,11 @@ const userscriptWebpack = (config, env) => {
|
||||
// @grant GM.info
|
||||
// @grant unsafeWindow
|
||||
// @connect translate.googleapis.com
|
||||
// @connect translate-pa.googleapis.com
|
||||
// @connect generativelanguage.googleapis.com
|
||||
// @connect api-edge.cognitive.microsofttranslator.com
|
||||
// @connect edge.microsoft.com
|
||||
// @connect bing.com
|
||||
// @connect api-free.deepl.com
|
||||
// @connect api.deepl.com
|
||||
// @connect www2.deepl.com
|
||||
@@ -102,16 +106,21 @@ const userscriptWebpack = (config, env) => {
|
||||
// @connect openai.azure.com
|
||||
// @connect workers.dev
|
||||
// @connect github.io
|
||||
// @connect github.com
|
||||
// @connect githubusercontent.com
|
||||
// @connect kiss-translator.rayjar.com
|
||||
// @connect ghproxy.com
|
||||
// @connect dav.jianguoyun.com
|
||||
// @connect fanyi.baidu.com
|
||||
// @connect transmart.qq.com
|
||||
// @connect localhost:3000
|
||||
// @connect 127.0.0.1:3000
|
||||
// @connect localhost:1188
|
||||
// @connect 127.0.0.1:1188
|
||||
// @connect niutrans.com
|
||||
// @connect translate.volcengine.com
|
||||
// @connect dict.youdao.com
|
||||
// @connect api.anthropic.com
|
||||
// @connect api.cloudflare.com
|
||||
// @connect openrouter.ai
|
||||
// @connect localhost
|
||||
// @connect 127.0.0.1
|
||||
// @run-at document-end
|
||||
// ==/UserScript==
|
||||
|
||||
|
||||
346
custom-api.md
Normal file
346
custom-api.md
Normal file
@@ -0,0 +1,346 @@
|
||||
# 自定义接口示例(本文档已过期,新版不再适用)
|
||||
|
||||
以下示例为网友提供,仅供学习参考。
|
||||
|
||||
## 本地运行 Seed-X-PPO-7B 量化模型
|
||||
|
||||
> 由网友 emptyghost6 提供,来源:https://linux.do/t/topic/828257
|
||||
|
||||
URL
|
||||
|
||||
```sh
|
||||
http://localhost:8000/v1/completions
|
||||
```
|
||||
|
||||
Request Hook
|
||||
|
||||
```js
|
||||
(text, from, to, url, key) => {
|
||||
// 模型支持的语言代码到完整名称的映射
|
||||
const langFullNameMap = {
|
||||
ar: 'Arabic', fr: 'French', ms: 'Malay', ru: 'Russian',
|
||||
cs: 'Czech', hr: 'Croatian', nb: 'Norwegian Bokmal', sv: 'Swedish',
|
||||
da: 'Danish', hu: 'Hungarian', nl: 'Dutch', th: 'Thai',
|
||||
de: 'German', id: 'Indonesian', no: 'Norwegian', tr: 'Turkish',
|
||||
en: 'English', it: 'Italian', pl: 'Polish', uk: 'Ukrainian',
|
||||
es: 'Spanish', ja: 'Japanese', pt: 'Portuguese', vi: 'Vietnamese',
|
||||
fi: 'Finnish', ko: 'Korean', ro: 'Romanian', zh: 'Chinese'
|
||||
};
|
||||
|
||||
// 将 Hook 系统的语言代码转换为模型 API 支持的代码
|
||||
const getModelLangCode = (lang) => {
|
||||
if (lang === 'zh-CN' || lang === 'zh-TW') return 'zh';
|
||||
return lang;
|
||||
};
|
||||
|
||||
const sourceLangCode = getModelLangCode(from);
|
||||
const targetLangCode = getModelLangCode(to);
|
||||
|
||||
const sourceLangName = langFullNameMap[sourceLangCode] || from;
|
||||
const targetLangName = langFullNameMap[targetLangCode] || to;
|
||||
|
||||
const prompt = `Translate it to ${targetLangName}:\n${text} <${targetLangCode}>`;
|
||||
|
||||
// 构建请求体对象
|
||||
const bodyObject = {
|
||||
model: "./ByteDance-Seed/Seed-X-PPO-7B-AWQ-Int4",
|
||||
prompt: prompt,
|
||||
max_tokens: 2048,
|
||||
temperature: 0.0,
|
||||
};
|
||||
|
||||
// 返回最终的请求配置
|
||||
return [url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
// 关键改动:将 JavaScript 对象转换为 JSON 字符串
|
||||
body: JSON.stringify(bodyObject),
|
||||
}];
|
||||
}
|
||||
```
|
||||
|
||||
Response Hook
|
||||
|
||||
```js
|
||||
(res, text, from, to) => {
|
||||
// 检查返回是否有效
|
||||
if (res && res.choices && res.choices.length > 0 && res.choices[0].text) {
|
||||
|
||||
// 提取译文并去除可能存在的前后空格
|
||||
const translatedText = res.choices[0].text.trim();
|
||||
|
||||
// 比较原文与译文,相同为 true,否则为 false。
|
||||
const areTextsIdentical = text.trim() === translatedText;
|
||||
|
||||
// 返回数组:[翻译后的文本, 是否与原文相同]
|
||||
return [translatedText, areTextsIdentical];
|
||||
}
|
||||
// 如果响应格式不正确或没有结果,则抛出错误
|
||||
throw new Error("Invalid API response format or no translation found.");
|
||||
}
|
||||
```
|
||||
|
||||
## 接入 openrouter
|
||||
|
||||
> 由网友 Rick Sanchez 提供
|
||||
|
||||
URL
|
||||
|
||||
```sh
|
||||
https://openrouter.ai/api/v1/chat/completions
|
||||
```
|
||||
|
||||
Request Hook
|
||||
|
||||
```js
|
||||
(text, from, to, url, key) => [url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${key}`,
|
||||
"Content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
"model": "deepseek/deepseek-chat-v3-0324:free", //可自定义你的模型
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": //可自定义你的提示词
|
||||
`You are a professional ${to} native translator. Your task is to produce a fluent, natural, and culturally appropriate translation of the following text from ${from} to ${to}, fully conveying the meaning, tone, and nuance of the original.
|
||||
|
||||
## Translation Rules
|
||||
1. Output only the final polished translation — no explanations, intermediate drafts, or notes.
|
||||
2. Translate in a way that reads naturally to a native ${to} audience, adapting idioms, cultural references, and tone when necessary.
|
||||
3. Preserve proper nouns, technical terms, brand names, and URLs exactly as in the original text unless a widely accepted ${to} equivalent exists.
|
||||
4. Keep any formatting (Markdown, HTML tags, bullet points, numbering) intact and positioned naturally within the translation.
|
||||
5. Adapt humor, metaphors, and figurative language to culturally relevant forms in ${to} while keeping the original intent.
|
||||
6. Maintain the same level of formality or informality as the original.
|
||||
|
||||
Source Text: ${text}
|
||||
|
||||
Translated Text:`
|
||||
}
|
||||
]
|
||||
})
|
||||
}]
|
||||
```
|
||||
|
||||
Response Hook
|
||||
|
||||
```js
|
||||
(res, text, from, to) => [
|
||||
res.choices?.[0]?.message?.content ?? "",
|
||||
false
|
||||
]
|
||||
```
|
||||
|
||||
## 接入 gemini-2.5-flash, 关闭思考模式, 去审查
|
||||
|
||||
> 由网友 Rick Sanchez 提供
|
||||
|
||||
URL
|
||||
|
||||
```sh
|
||||
https://generativelanguage.googleapis.com/v1beta/models
|
||||
```
|
||||
|
||||
Request Hook
|
||||
|
||||
```js
|
||||
(text, from, to, url, key) => [`${url}/gemini-2.5-flash:generateContent?key=${key}`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
"generationConfig": {
|
||||
"temperature": 0.8,
|
||||
"thinkingConfig": {
|
||||
"thinkingBudget": 0, //gemini-2.5-flash设为0关闭思考模式
|
||||
},
|
||||
},
|
||||
"safetySettings": [
|
||||
{
|
||||
"category": "HARM_CATEGORY_HARASSMENT",
|
||||
"threshold": "BLOCK_NONE",
|
||||
},
|
||||
{
|
||||
"category": "HARM_CATEGORY_HATE_SPEECH",
|
||||
"threshold": "BLOCK_NONE",
|
||||
},
|
||||
{
|
||||
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||
"threshold": "BLOCK_NONE",
|
||||
},
|
||||
{
|
||||
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
"threshold": "BLOCK_NONE",
|
||||
}
|
||||
],
|
||||
"contents": [{
|
||||
"parts": [{
|
||||
"text": `自定义提示词`
|
||||
}]
|
||||
}],
|
||||
}),
|
||||
}]
|
||||
```
|
||||
|
||||
Response Hook
|
||||
|
||||
```js
|
||||
(res, text, from, to) => [
|
||||
res.candidates?.[0]?.content?.parts?.[0]?.text ?? "",
|
||||
false
|
||||
]
|
||||
```
|
||||
|
||||
## 接入 Qwen-MT
|
||||
|
||||
> 由网友 atom 提供
|
||||
|
||||
URL
|
||||
|
||||
```sh
|
||||
https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions
|
||||
```
|
||||
|
||||
Request Hook
|
||||
|
||||
```js
|
||||
(text, from, to, url, key) => {
|
||||
const mapLanguageCode = (lang) => ({
|
||||
'zh-CN': 'zh',
|
||||
'zh-TW': 'zh_tw',
|
||||
})[lang] || lang;
|
||||
|
||||
const targetLang = mapLanguageCode(to);
|
||||
|
||||
return [
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${key}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
"model": "qwen-mt-turbo",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": text
|
||||
}
|
||||
],
|
||||
"translation_options": {
|
||||
"source_lang": "auto",
|
||||
"target_lang": targetLang
|
||||
}
|
||||
})
|
||||
}
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Response Hook
|
||||
|
||||
```js
|
||||
(res, text, from, to) => [res.choices?.[0]?.message?.content ?? "", false]
|
||||
```
|
||||
|
||||
|
||||
## 接入 deepl 接口
|
||||
|
||||
> 来源: https://github.com/fishjar/kiss-translator/issues/101#issuecomment-2123786236
|
||||
|
||||
Request Hook
|
||||
|
||||
```js
|
||||
(text, from, to, url, key) => [
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
target_lang: "ZH",
|
||||
source_lang: "auto",
|
||||
}),
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
Response Hook
|
||||
|
||||
```js
|
||||
(res, text, from, to) => [res.data, "ZH" === res.source_lang]
|
||||
```
|
||||
|
||||
## 接入智谱AI大模型
|
||||
|
||||
> 来源: https://github.com/fishjar/kiss-translator/issues/205#issuecomment-2642422679
|
||||
|
||||
Request Hook
|
||||
|
||||
```js
|
||||
(text, from, to, url, key) => [url, {
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"Content-type": "application/json",
|
||||
"Authorization": key
|
||||
},
|
||||
"body": JSON.stringify({
|
||||
"model": "glm-4-flash",
|
||||
"messages": [
|
||||
{
|
||||
"role":"system",
|
||||
"content": "You are a professional, authentic machine translation engine. You only return the translated text, without any explanations."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": `Translate the following text into ${to}. If translation is unnecessary (e.g. proper nouns, codes, etc.), return the original text. NO explanations. NO notes:\n\n ${text} `
|
||||
}
|
||||
]
|
||||
})
|
||||
}]
|
||||
```
|
||||
|
||||
## 接入谷歌新接口
|
||||
|
||||
> 由网友 Bush2021 提供,来源:https://github.com/fishjar/kiss-translator/issues/225#issuecomment-2810950717
|
||||
|
||||
URL
|
||||
|
||||
```sh
|
||||
https://translate-pa.googleapis.com/v1/translateHtml
|
||||
```
|
||||
|
||||
KEY
|
||||
|
||||
```sh
|
||||
AIzaSyATBXajvzQLTDHEQbcpq0Ihe0vWDHmO520
|
||||
```
|
||||
|
||||
Request Hook
|
||||
|
||||
```js
|
||||
(text, from, to, url, key) => [url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json+protobuf",
|
||||
"X-Goog-API-Key": key
|
||||
},
|
||||
body: JSON.stringify([[[text], from || "auto", to], "wt_lib"])
|
||||
}]
|
||||
```
|
||||
|
||||
Response Hook
|
||||
|
||||
```js
|
||||
(res, text, from, to) => [res?.[0]?.join(" ") || "Translation unavailable", to === res?.[1]?.[0]]
|
||||
```
|
||||
|
||||
|
||||
41
package.json
41
package.json
@@ -1,21 +1,24 @@
|
||||
{
|
||||
"name": "kiss-translator",
|
||||
"description": "A minimalist bilingual translation Extension & Greasemonkey Script",
|
||||
"version": "1.8.4",
|
||||
"version": "2.0.1",
|
||||
"author": "Gabe<yugang2002@gmail.com>",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/cache": "^11.11.0",
|
||||
"@emotion/css": "^11.13.5",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.10.8",
|
||||
"@mui/icons-material": "^5.11.11",
|
||||
"@mui/material": "^5.11.12",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.15",
|
||||
"@mui/lab": "5.0.0-alpha.170",
|
||||
"@mui/material": "^5.15.15",
|
||||
"query-string": "^8.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-router-dom": "^6.10.0",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"sval": "^0.5.2",
|
||||
"webdav": "^5.3.0",
|
||||
"webextension-polyfill": "^0.10.0"
|
||||
},
|
||||
@@ -23,14 +26,19 @@
|
||||
"start": "REACT_APP_CLIENT=web react-app-rewired start",
|
||||
"start:userscript": "REACT_APP_CLIENT=userscript react-app-rewired start",
|
||||
"build:chrome": "rm -rf build/chrome && BUILD_PATH=./build/chrome REACT_APP_CLIENT=chrome react-app-rewired build && rm build/chrome/content.html",
|
||||
"build:safari-output": "rm -rf build/safari && BUILD_PATH=./build/safari REACT_APP_CLIENT=safari react-app-rewired build && rm build/safari/content.html",
|
||||
"build:safari": "node src/scripts/build-safari.js",
|
||||
"build:edge": "rm -rf build/edge && cp -r build/chrome build/edge",
|
||||
"build:firefox": "rm -rf build/firefox && cp -r build/chrome build/firefox && cat ./build/firefox/manifest.firefox.json > ./build/firefox/manifest.json && rm build/*/manifest.firefox.json",
|
||||
"build:thunderbird": "rm -rf build/thunderbird && BUILD_PATH=./build/thunderbird REACT_APP_CLIENT=thunderbird react-app-rewired build && rm build/thunderbird/content.html && cp ./build/thunderbird/manifest.thunderbird.json ./build/thunderbird/manifest.json && rm build/*/manifest.thunderbird.json",
|
||||
"build:firefox": "rm -rf build/firefox && BUILD_PATH=./build/firefox REACT_APP_CLIENT=firefox react-app-rewired build && rm build/firefox/content.html && cat ./build/firefox/manifest.firefox.json > ./build/firefox/manifest.json && rm build/*/manifest.firefox.json",
|
||||
"build:web": "rm -rf build/web && BUILD_PATH=./build/web REACT_APP_CLIENT=userscript react-app-rewired build",
|
||||
"build:userscript-ios": "file1=build/web/kiss-translator.user.js file2=build/web/kiss-translator-ios-safari.user.js && cp $file1 $file2 && sed -i 's|// @grant unsafeWindow|// @inject-into content|g' $file2",
|
||||
"build:userscript": "rm -rf build/userscript && mkdir build/userscript && cp build/web/*.user.js build/userscript/",
|
||||
"build:rules": "babel-node src/rules.js",
|
||||
"build": "pnpm build:chrome && pnpm build:edge && pnpm build:firefox && pnpm build:web && pnpm build:userscript-ios && pnpm build:userscript && pnpm build:rules",
|
||||
"pack": "cd build && zip -r chrome.zip chrome && zip -r edge.zip edge && cd firefox && zip -r ../firefox.zip .",
|
||||
"build": "pnpm format && pnpm build:chrome && pnpm build:edge && pnpm build:thunderbird && pnpm build:firefox && pnpm build:web && pnpm build:userscript-ios && pnpm build:userscript && pnpm build:rules",
|
||||
"zip": "cd build && rm -f *.zip && zip -r chrome.zip chrome && zip -r edge.zip edge && zip -r userscript.zip userscript && (cd firefox && zip -r ../firefox.zip .) && (cd thunderbird && zip -r ../thunderbird.zip .)",
|
||||
"build+zip": "pnpm build && pnpm zip",
|
||||
"format": "prettier --write \"**/*.{js,json,html}\"",
|
||||
"test": "react-app-rewired test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
@@ -42,7 +50,10 @@
|
||||
"globals": {
|
||||
"GM": true,
|
||||
"unsafeWindow": true,
|
||||
"globalThis": true
|
||||
"globalThis": true,
|
||||
"messenger": true,
|
||||
"LanguageDetector": true,
|
||||
"Translator": true
|
||||
}
|
||||
},
|
||||
"browserslist": {
|
||||
@@ -58,10 +69,14 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.22.10",
|
||||
"@babel/node": "^7.22.10",
|
||||
"@babel/core": "^7.22.20",
|
||||
"@babel/node": "^7.22.19",
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@babel/preset-env": "^7.22.10",
|
||||
"react-app-rewired": "^2.2.1"
|
||||
"@babel/preset-env": "^7.22.20",
|
||||
"dotenv": "^17.2.1",
|
||||
"find-up": "^7.0.0",
|
||||
"prettier": "3.6.2",
|
||||
"react-app-rewired": "^2.2.1",
|
||||
"zx": "^8.8.1"
|
||||
}
|
||||
}
|
||||
|
||||
12446
pnpm-lock.yaml
generated
12446
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@@ -16,7 +16,7 @@
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// (() => {
|
||||
// var shadow = document.querySelector("#shadow1");
|
||||
// var root = shadow.attachShadow({ mode: "open" });
|
||||
@@ -54,8 +54,8 @@
|
||||
// }, 1000);
|
||||
|
||||
setTimeout(function () {
|
||||
var el = document.querySelector("h2>p>span");
|
||||
el.innerText = "hello world";
|
||||
var el = document.querySelector('h2>p>span');
|
||||
el.innerText = 'hello world';
|
||||
}, 1000);
|
||||
});
|
||||
</script>
|
||||
@@ -105,28 +105,26 @@
|
||||
<br />
|
||||
<div id="content">
|
||||
<p>You need to enable JavaScript to run <span>this app.</span></p>
|
||||
The <span>embargo</span> has just lifted to confirm that AmpereOne is
|
||||
coming to Google Cloud with the C3A instances.
|
||||
The <span>embargo</span> has just lifted to confirm that AmpereOne is coming to
|
||||
Google Cloud with the C3A instances.
|
||||
<br />
|
||||
But these upcoming instances for now are only in private preview form.
|
||||
<br />
|
||||
<br />
|
||||
Needless to say I also haven't had any AmpereOne access to check out the
|
||||
performance and power efficiency of these new Arm server processors from
|
||||
Ampere Computing.
|
||||
performance and power efficiency of these new Arm server processors from Ampere
|
||||
Computing.
|
||||
<br />
|
||||
</div>
|
||||
<h2>
|
||||
<p>
|
||||
<span
|
||||
>React is a JavaScript library for building user interfaces.</span
|
||||
>
|
||||
<span>React is a JavaScript library for building user interfaces.</span>
|
||||
</p>
|
||||
</h2>
|
||||
<hr />
|
||||
<input id="input1" style="width: 80%;" />
|
||||
<input id="input1" style="width: 80%" />
|
||||
<hr />
|
||||
<textarea id="textarea1" style="width: 80%;">test</textarea>
|
||||
<textarea id="textarea1" style="width: 80%">test</textarea>
|
||||
<hr />
|
||||
<div id="addtitle"></div>
|
||||
<h2>Shadow 1</h2>
|
||||
@@ -166,15 +164,47 @@
|
||||
<br />
|
||||
<br />
|
||||
<h2>
|
||||
React Server Components (or RSC) is a new application architecture
|
||||
designed by the React team.
|
||||
React Server Components (or RSC) is a new application architecture designed by the
|
||||
React team.
|
||||
</h2>
|
||||
<iframe
|
||||
id="iframe1"
|
||||
width="800px"
|
||||
height="600px"
|
||||
src="http://localhost:3000/index.html"
|
||||
></iframe>
|
||||
src="http://localhost:3000/index.html"></iframe>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<h2>We’ve first shared our research on RSC in an introductory talk and an RFC.</h2>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
@@ -208,52 +238,10 @@
|
||||
<br />
|
||||
<br />
|
||||
<h2>
|
||||
We’ve first shared our research on RSC in an introductory talk and an
|
||||
RFC.
|
||||
To recap them, we are introducing a new kind of component—Server Components—that
|
||||
run ahead of time and are excluded from your JavaScript bundle.
|
||||
</h2>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<h2>
|
||||
To recap them, we are introducing a new kind of component—Server
|
||||
Components—that run ahead of time and are excluded from your JavaScript
|
||||
bundle.
|
||||
</h2>
|
||||
<iframe
|
||||
id="iframe2"
|
||||
width="800px"
|
||||
height="600px"
|
||||
src="https://react.dev/"
|
||||
></iframe>
|
||||
<iframe id="iframe2" width="800px" height="600px" src="https://react.dev/"></iframe>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
@@ -288,15 +276,14 @@
|
||||
<br />
|
||||
<div class="cont cont1">
|
||||
<h2>
|
||||
Server Components can run during the build, letting you read from the
|
||||
filesystem or fetch static content.
|
||||
Server Components can run during the build, letting you read from the filesystem
|
||||
or fetch static content.
|
||||
</h2>
|
||||
<ul>
|
||||
<li>
|
||||
They can also run on the server, letting you access your data layer
|
||||
without having to build an API. You can pass data by props from
|
||||
Server Components to the interactive Client Components in the
|
||||
browser.
|
||||
They can also run on the server, letting you access your data layer without
|
||||
having to build an API. You can pass data by props from Server Components to
|
||||
the interactive Client Components in the browser.
|
||||
</li>
|
||||
<li>以声明式编写 UI,可以让你的代码更加可靠,且方便调试。</li>
|
||||
</ul>
|
||||
@@ -315,14 +302,14 @@
|
||||
<br />
|
||||
<div class="cont cont2">
|
||||
<h2>
|
||||
Since our last update, we have merged the React Server Components RFC
|
||||
to ratify the proposal.
|
||||
Since our last update, we have merged the React Server Components RFC to ratify
|
||||
the proposal.
|
||||
</h2>
|
||||
<ul>
|
||||
<li>
|
||||
RSC combines the simple “request/response” mental model of
|
||||
server-centric Multi-Page Apps with the seamless interactivity of
|
||||
client-centric Single-Page Apps, giving you the best of both worlds.
|
||||
RSC combines the simple “request/response” mental model of server-centric
|
||||
Multi-Page Apps with the seamless interactivity of client-centric Single-Page
|
||||
Apps, giving you the best of both worlds.
|
||||
</li>
|
||||
<li>
|
||||
React 使创建交互式 UI
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 2,
|
||||
"name": "__MSG_app_name__",
|
||||
"description": "__MSG_app_description__",
|
||||
"version": "1.8.4",
|
||||
"version": "2.0.1",
|
||||
"default_locale": "en",
|
||||
"author": "Gabe<yugang2002@gmail.com>",
|
||||
"homepage_url": "https://github.com/fishjar/kiss-translator",
|
||||
@@ -12,10 +12,13 @@
|
||||
"content_scripts": [
|
||||
{
|
||||
"js": ["content.js"],
|
||||
"matches": ["<all_urls>"],
|
||||
"matches": ["<all_urls>", "file://*/*"],
|
||||
"all_frames": true
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
"injector.js"
|
||||
],
|
||||
"commands": {
|
||||
"_execute_browser_action": {
|
||||
"suggested_key": {
|
||||
@@ -44,7 +47,13 @@
|
||||
"description": "__MSG_open_options__"
|
||||
}
|
||||
},
|
||||
"permissions": ["<all_urls>", "storage", "contextMenus", "scripting"],
|
||||
"permissions": [
|
||||
"<all_urls>",
|
||||
"storage",
|
||||
"contextMenus",
|
||||
"scripting",
|
||||
"declarativeNetRequest"
|
||||
],
|
||||
"icons": {
|
||||
"16": "images/logo16.png",
|
||||
"32": "images/logo32.png",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"name": "__MSG_app_name__",
|
||||
"description": "__MSG_app_description__",
|
||||
"version": "1.8.4",
|
||||
"version": "2.0.1",
|
||||
"default_locale": "en",
|
||||
"author": "Gabe<yugang2002@gmail.com>",
|
||||
"homepage_url": "https://github.com/fishjar/kiss-translator",
|
||||
@@ -13,10 +13,16 @@
|
||||
"content_scripts": [
|
||||
{
|
||||
"js": ["content.js"],
|
||||
"matches": ["<all_urls>"],
|
||||
"matches": ["<all_urls>", "file://*/*"],
|
||||
"all_frames": true
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["injector.js"],
|
||||
"matches": ["https://www.youtube.com/*"]
|
||||
}
|
||||
],
|
||||
"commands": {
|
||||
"_execute_action": {
|
||||
"suggested_key": {
|
||||
@@ -45,7 +51,7 @@
|
||||
"description": "__MSG_open_options__"
|
||||
}
|
||||
},
|
||||
"permissions": ["storage", "contextMenus", "scripting"],
|
||||
"permissions": ["storage", "contextMenus", "scripting", "declarativeNetRequest", "declarativeNetRequestWithHostAccess"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"icons": {
|
||||
"16": "images/logo16.png",
|
||||
|
||||
81
public/manifest.thunderbird.json
Normal file
81
public/manifest.thunderbird.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "__MSG_app_name__",
|
||||
"description": "__MSG_app_description__",
|
||||
"version": "2.0.1",
|
||||
"default_locale": "en",
|
||||
"author": "Gabe<yugang2002@gmail.com>",
|
||||
"homepage_url": "https://github.com/fishjar/kiss-translator",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "yugang2002@gmail.com",
|
||||
"strict_min_version": "78.0"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"scripts": ["background.js"]
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"js": ["content.js"],
|
||||
"matches": ["<all_urls>", "file://*/*"],
|
||||
"all_frames": true
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
"injector.js"
|
||||
],
|
||||
"commands": {
|
||||
"_execute_browser_action": {
|
||||
"suggested_key": {
|
||||
"default": "Alt+K"
|
||||
}
|
||||
},
|
||||
"toggleTranslate": {
|
||||
"suggested_key": {
|
||||
"default": "Alt+Q"
|
||||
},
|
||||
"description": "__MSG_toggle_translate__"
|
||||
},
|
||||
"openTranbox": {
|
||||
"suggested_key": {
|
||||
"default": "Alt+S"
|
||||
},
|
||||
"description": "__MSG_open_tranbox__"
|
||||
},
|
||||
"toggleStyle": {
|
||||
"suggested_key": {
|
||||
"default": "Alt+C"
|
||||
},
|
||||
"description": "__MSG_toggle_style__"
|
||||
},
|
||||
"openOptions": {
|
||||
"description": "__MSG_open_options__"
|
||||
}
|
||||
},
|
||||
"permissions": [
|
||||
"<all_urls>",
|
||||
"storage",
|
||||
"menus",
|
||||
"messagesModify",
|
||||
"scripting",
|
||||
"declarativeNetRequest"
|
||||
],
|
||||
"icons": {
|
||||
"16": "images/logo16.png",
|
||||
"32": "images/logo32.png",
|
||||
"48": "images/logo48.png",
|
||||
"128": "images/logo128.png"
|
||||
},
|
||||
"browser_action": {
|
||||
"default_icon": {
|
||||
"128": "images/logo128.png"
|
||||
},
|
||||
"default_title": "__MSG_app_name__",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "options.html",
|
||||
"open_in_tab": true
|
||||
}
|
||||
}
|
||||
@@ -1,258 +1,19 @@
|
||||
import queryString from "query-string";
|
||||
import { getBdauth, setBdauth } from "../libs/storage";
|
||||
import {
|
||||
URL_BAIDU_WEB,
|
||||
URL_BAIDU_TRANSAPI_V2,
|
||||
URL_BAIDU_TRANSAPI,
|
||||
} from "../config";
|
||||
import { fetchApi } from "../libs/fetch";
|
||||
import { DEFAULT_USER_AGENT } from "../config";
|
||||
|
||||
/* eslint-disable */
|
||||
function n(t, e) {
|
||||
for (var n = 0; n < e.length - 2; n += 3) {
|
||||
var r = e.charAt(n + 2);
|
||||
(r = "a" <= r ? r.charCodeAt(0) - 87 : Number(r)),
|
||||
(r = "+" === e.charAt(n + 1) ? t >>> r : t << r),
|
||||
(t = "+" === e.charAt(n) ? (t + r) & 4294967295 : t ^ r);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
function e(t, e) {
|
||||
(null == e || e > t.length) && (e = t.length);
|
||||
for (var n = 0, r = new Array(e); n < e; n++) r[n] = t[n];
|
||||
return r;
|
||||
}
|
||||
|
||||
/* eslint-disable */
|
||||
function getSign(t, gtk, r = null) {
|
||||
var o,
|
||||
i = t.match(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g);
|
||||
if (null === i) {
|
||||
var a = t.length;
|
||||
a > 30 &&
|
||||
(t = ""
|
||||
.concat(t.substr(0, 10))
|
||||
.concat(t.substr(Math.floor(a / 2) - 5, 10))
|
||||
.concat(t.substr(-10, 10)));
|
||||
} else {
|
||||
for (
|
||||
var s = t.split(/[\uD800-\uDBFF][\uDC00-\uDFFF]/),
|
||||
c = 0,
|
||||
u = s.length,
|
||||
l = [];
|
||||
c < u;
|
||||
c++
|
||||
)
|
||||
"" !== s[c] &&
|
||||
l.push.apply(
|
||||
l,
|
||||
(function (t) {
|
||||
if (Array.isArray(t)) return e(t);
|
||||
})((o = s[c].split(""))) ||
|
||||
(function (t) {
|
||||
if (
|
||||
("undefined" != typeof Symbol && null != t[Symbol.iterator]) ||
|
||||
null != t["@@iterator"]
|
||||
)
|
||||
return Array.from(t);
|
||||
})(o) ||
|
||||
(function (t, n) {
|
||||
if (t) {
|
||||
if ("string" == typeof t) return e(t, n);
|
||||
var r = Object.prototype.toString.call(t).slice(8, -1);
|
||||
return (
|
||||
"Object" === r && t.constructor && (r = t.constructor.name),
|
||||
"Map" === r || "Set" === r
|
||||
? Array.from(t)
|
||||
: "Arguments" === r ||
|
||||
/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)
|
||||
? e(t, n)
|
||||
: void 0
|
||||
);
|
||||
}
|
||||
})(o) ||
|
||||
(function () {
|
||||
throw new TypeError(
|
||||
"Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."
|
||||
);
|
||||
})()
|
||||
),
|
||||
c !== u - 1 && l.push(i[c]);
|
||||
var p = l.length;
|
||||
p > 30 &&
|
||||
(t =
|
||||
l.slice(0, 10).join("") +
|
||||
l.slice(Math.floor(p / 2) - 5, Math.floor(p / 2) + 5).join("") +
|
||||
l.slice(-10).join(""));
|
||||
}
|
||||
for (
|
||||
var d = ""
|
||||
.concat(String.fromCharCode(103))
|
||||
.concat(String.fromCharCode(116))
|
||||
.concat(String.fromCharCode(107)),
|
||||
h = (null !== r ? r : (r = gtk || "") || "").split("."),
|
||||
f = Number(h[0]) || 0,
|
||||
m = Number(h[1]) || 0,
|
||||
g = [],
|
||||
y = 0,
|
||||
v = 0;
|
||||
v < t.length;
|
||||
v++
|
||||
) {
|
||||
var _ = t.charCodeAt(v);
|
||||
_ < 128
|
||||
? (g[y++] = _)
|
||||
: (_ < 2048
|
||||
? (g[y++] = (_ >> 6) | 192)
|
||||
: (55296 == (64512 & _) &&
|
||||
v + 1 < t.length &&
|
||||
56320 == (64512 & t.charCodeAt(v + 1))
|
||||
? ((_ = 65536 + ((1023 & _) << 10) + (1023 & t.charCodeAt(++v))),
|
||||
(g[y++] = (_ >> 18) | 240),
|
||||
(g[y++] = ((_ >> 12) & 63) | 128))
|
||||
: (g[y++] = (_ >> 12) | 224),
|
||||
(g[y++] = ((_ >> 6) & 63) | 128)),
|
||||
(g[y++] = (63 & _) | 128));
|
||||
}
|
||||
for (
|
||||
var b = f,
|
||||
w =
|
||||
""
|
||||
.concat(String.fromCharCode(43))
|
||||
.concat(String.fromCharCode(45))
|
||||
.concat(String.fromCharCode(97)) +
|
||||
""
|
||||
.concat(String.fromCharCode(94))
|
||||
.concat(String.fromCharCode(43))
|
||||
.concat(String.fromCharCode(54)),
|
||||
k =
|
||||
""
|
||||
.concat(String.fromCharCode(43))
|
||||
.concat(String.fromCharCode(45))
|
||||
.concat(String.fromCharCode(51)) +
|
||||
""
|
||||
.concat(String.fromCharCode(94))
|
||||
.concat(String.fromCharCode(43))
|
||||
.concat(String.fromCharCode(98)) +
|
||||
""
|
||||
.concat(String.fromCharCode(43))
|
||||
.concat(String.fromCharCode(45))
|
||||
.concat(String.fromCharCode(102)),
|
||||
x = 0;
|
||||
x < g.length;
|
||||
x++
|
||||
)
|
||||
b = n((b += g[x]), w);
|
||||
return (
|
||||
(b = n(b, k)),
|
||||
(b ^= m) < 0 && (b = 2147483648 + (2147483647 & b)),
|
||||
"".concat((b %= 1e6).toString(), ".").concat(b ^ f)
|
||||
);
|
||||
}
|
||||
|
||||
const getToken = async () => {
|
||||
const res = await fetchApi({
|
||||
input: URL_BAIDU_WEB,
|
||||
init: {
|
||||
headers: {
|
||||
"Content-type": "text/html; charset=utf-8",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.statusText);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
const token = text.match(/token: '(.*)',/)[1];
|
||||
const gtk = text.match(/gtk = "(.*)";/)[1];
|
||||
const exp = Date.now() + 8 * 60 * 60 * 1000;
|
||||
|
||||
if (!token || !gtk) {
|
||||
throw new Error("[baidu] get token error");
|
||||
}
|
||||
|
||||
return { token, gtk, exp };
|
||||
};
|
||||
|
||||
/**
|
||||
* 闭包缓存token,减少对storage查询
|
||||
* @returns
|
||||
*/
|
||||
const _bdAuth = () => {
|
||||
let store;
|
||||
|
||||
return async () => {
|
||||
const now = Date.now();
|
||||
|
||||
// 查询内存缓存
|
||||
if (store && store.exp > now) {
|
||||
return store;
|
||||
}
|
||||
|
||||
// 查询storage缓存
|
||||
store = await getBdauth();
|
||||
if (store && store.exp > now) {
|
||||
return store;
|
||||
}
|
||||
|
||||
// 缓存没有或失效,查询接口
|
||||
store = await getToken();
|
||||
await setBdauth(store);
|
||||
return store;
|
||||
};
|
||||
};
|
||||
|
||||
const bdAuth = _bdAuth();
|
||||
|
||||
/**
|
||||
* 失效作废
|
||||
* @param {*} param0
|
||||
* @returns
|
||||
*/
|
||||
export const genBaiduV2 = async ({ text, from, to }) => {
|
||||
const { token, gtk } = await bdAuth();
|
||||
const sign = getSign(text, gtk);
|
||||
const data = {
|
||||
export const genBaidu = ({ texts, from, to }) => {
|
||||
const body = {
|
||||
from,
|
||||
to,
|
||||
query: text,
|
||||
simple_means_flag: 3,
|
||||
sign,
|
||||
token,
|
||||
domain: "common",
|
||||
ts: Date.now(),
|
||||
};
|
||||
|
||||
const input = `${URL_BAIDU_TRANSAPI_V2}?from=${from}&to=${to}`;
|
||||
const init = {
|
||||
headers: {
|
||||
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
},
|
||||
method: "POST",
|
||||
body: queryString.stringify(data),
|
||||
};
|
||||
|
||||
return [input, init];
|
||||
};
|
||||
|
||||
export const genBaidu = async ({ text, from, to }) => {
|
||||
const data = {
|
||||
from,
|
||||
to,
|
||||
query: text,
|
||||
query: texts.join(" "),
|
||||
source: "txt",
|
||||
};
|
||||
|
||||
const init = {
|
||||
headers: {
|
||||
const url = "https://fanyi.baidu.com/transapi";
|
||||
const headers = {
|
||||
// Origin: "https://fanyi.baidu.com",
|
||||
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
},
|
||||
method: "POST",
|
||||
body: queryString.stringify(data),
|
||||
"User-Agent": DEFAULT_USER_AGENT,
|
||||
};
|
||||
|
||||
return [URL_BAIDU_TRANSAPI, init];
|
||||
return { url, body, headers };
|
||||
};
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { URL_DEEPLFREE_TRAN } from "../config";
|
||||
|
||||
let id = 1e4 * Math.round(1e4 * Math.random());
|
||||
|
||||
export const genDeeplFree = ({ text, from, to }) => {
|
||||
export const genDeeplFree = ({ texts, from, to }) => {
|
||||
const text = texts.join(" ");
|
||||
const iCount = (text.match(/[i]/g) || []).length + 1;
|
||||
let timestamp = Date.now();
|
||||
timestamp = timestamp + (iCount - (timestamp % iCount));
|
||||
id++;
|
||||
|
||||
let body = JSON.stringify({
|
||||
const url = "https://www2.deepl.com/jsonrpc";
|
||||
|
||||
const body = {
|
||||
jsonrpc: "2.0",
|
||||
method: "LMT_handle_texts",
|
||||
params: {
|
||||
@@ -30,15 +31,9 @@ export const genDeeplFree = ({ text, from, to }) => {
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
body = body.replace(
|
||||
'method":"',
|
||||
(id + 3) % 13 === 0 || (id + 5) % 29 === 0 ? 'method" : "' : 'method": "'
|
||||
);
|
||||
|
||||
const init = {
|
||||
headers: {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "*/*",
|
||||
"x-app-os-name": "iOS",
|
||||
@@ -49,10 +44,7 @@ export const genDeeplFree = ({ text, from, to }) => {
|
||||
"User-Agent": "DeepL-iOS/2.9.1 iOS 16.3.0 (iPhone13,2)",
|
||||
"x-app-build": "510265",
|
||||
"x-app-version": "2.9.1",
|
||||
},
|
||||
method: "POST",
|
||||
body,
|
||||
};
|
||||
|
||||
return [URL_DEEPLFREE_TRAN, init];
|
||||
return { url, body, headers };
|
||||
};
|
||||
|
||||
39
src/apis/history.js
Normal file
39
src/apis/history.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { DEFAULT_CONTEXT_SIZE } from "../config";
|
||||
|
||||
const historyMap = new Map();
|
||||
|
||||
const MsgHistory = (maxSize = DEFAULT_CONTEXT_SIZE) => {
|
||||
const messages = [];
|
||||
|
||||
const add = (...msgs) => {
|
||||
messages.push(...msgs.filter(Boolean));
|
||||
const extra = messages.length - maxSize;
|
||||
if (extra > 0) {
|
||||
messages.splice(0, extra);
|
||||
}
|
||||
};
|
||||
|
||||
const getAll = () => {
|
||||
return [...messages];
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
messages.length = 0;
|
||||
};
|
||||
|
||||
return {
|
||||
add,
|
||||
getAll,
|
||||
clear,
|
||||
};
|
||||
};
|
||||
|
||||
export const getMsgHistory = (apiSlug, maxSize) => {
|
||||
if (historyMap.has(apiSlug)) {
|
||||
return historyMap.get(apiSlug);
|
||||
}
|
||||
|
||||
const msgHistory = MsgHistory(maxSize);
|
||||
historyMap.set(apiSlug, msgHistory);
|
||||
return msgHistory;
|
||||
};
|
||||
@@ -1,27 +1,33 @@
|
||||
import queryString from "query-string";
|
||||
import { fetchPolyfill } from "../libs/fetch";
|
||||
import { fetchData } from "../libs/fetch";
|
||||
import {
|
||||
OPT_TRANS_GOOGLE,
|
||||
OPT_TRANS_MICROSOFT,
|
||||
OPT_TRANS_DEEPL,
|
||||
OPT_TRANS_DEEPLFREE,
|
||||
OPT_TRANS_DEEPLX,
|
||||
OPT_TRANS_BAIDU,
|
||||
OPT_TRANS_TENCENT,
|
||||
OPT_TRANS_OPENAI,
|
||||
OPT_TRANS_GEMINI,
|
||||
OPT_TRANS_CLOUDFLAREAI,
|
||||
OPT_TRANS_CUSTOMIZE,
|
||||
URL_CACHE_TRAN,
|
||||
URL_CACHE_DELANG,
|
||||
URL_CACHE_BINGDICT,
|
||||
KV_SALT_SYNC,
|
||||
URL_BAIDU_LANGDETECT,
|
||||
URL_BAIDU_SUGGEST,
|
||||
OPT_LANGS_BAIDU,
|
||||
URL_TENCENT_TRANSMART,
|
||||
OPT_LANGS_TENCENT,
|
||||
OPT_LANGS_SPECIAL,
|
||||
OPT_LANGS_TO_SPEC,
|
||||
OPT_LANGS_SPEC_DEFAULT,
|
||||
API_SPE_TYPES,
|
||||
DEFAULT_API_SETTING,
|
||||
OPT_TRANS_MICROSOFT,
|
||||
MSG_BUILTINAI_DETECT,
|
||||
MSG_BUILTINAI_TRANSLATE,
|
||||
OPT_TRANS_BUILTINAI,
|
||||
URL_CACHE_SUBTITLE,
|
||||
} from "../config";
|
||||
import { sha256 } from "../libs/utils";
|
||||
import { sha256, withTimeout } from "../libs/utils";
|
||||
import { kissLog } from "../libs/log";
|
||||
import {
|
||||
handleTranslate,
|
||||
handleSubtitle,
|
||||
handleMicrosoftLangdetect,
|
||||
} from "./trans";
|
||||
import { getHttpCachePolyfill, putHttpCachePolyfill } from "../libs/cache";
|
||||
import { getBatchQueue } from "../libs/batchQueue";
|
||||
import { isBuiltinAIAvailable } from "../libs/browser";
|
||||
import { chromeDetect, chromeTranslate } from "../libs/builtinAI";
|
||||
import { fnPolyfill } from "../libs/fetch";
|
||||
import { getFetchPool } from "../libs/pool";
|
||||
|
||||
/**
|
||||
* 同步数据
|
||||
@@ -31,7 +37,7 @@ import { sha256 } from "../libs/utils";
|
||||
* @returns
|
||||
*/
|
||||
export const apiSyncData = async (url, key, data) =>
|
||||
fetchPolyfill(url, {
|
||||
fetchData(url, {
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
Authorization: `Bearer ${await sha256(key, KV_SALT_SYNC)}`,
|
||||
@@ -45,7 +51,135 @@ export const apiSyncData = async (url, key, data) =>
|
||||
* @param {*} url
|
||||
* @returns
|
||||
*/
|
||||
export const apiFetch = (url) => fetchPolyfill(url);
|
||||
export const apiFetch = (url) => fetchData(url);
|
||||
|
||||
/**
|
||||
* Microsoft token
|
||||
* @returns
|
||||
*/
|
||||
export const apiMsAuth = async () =>
|
||||
fetchData("https://edge.microsoft.com/translate/auth");
|
||||
|
||||
/**
|
||||
* Google语言识别
|
||||
* @param {*} text
|
||||
* @returns
|
||||
*/
|
||||
export const apiGoogleLangdetect = async (text) => {
|
||||
const params = {
|
||||
client: "gtx",
|
||||
dt: "t",
|
||||
dj: 1,
|
||||
ie: "UTF-8",
|
||||
sl: "auto",
|
||||
tl: "zh-CN",
|
||||
q: text,
|
||||
};
|
||||
const input = `https://translate.googleapis.com/translate_a/single?${queryString.stringify(params)}`;
|
||||
const init = {
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
},
|
||||
};
|
||||
const res = await fetchData(input, init, { useCache: true });
|
||||
|
||||
if (res?.src) {
|
||||
await putHttpCachePolyfill(input, init, res);
|
||||
return res.src;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Microsoft语言识别
|
||||
* @param {*} text
|
||||
* @returns
|
||||
*/
|
||||
export const apiMicrosoftLangdetect = async (text) => {
|
||||
const cacheOpts = { text, detector: OPT_TRANS_MICROSOFT };
|
||||
const cacheInput = `${URL_CACHE_DELANG}?${queryString.stringify(cacheOpts)}`;
|
||||
const cache = await getHttpCachePolyfill(cacheInput);
|
||||
if (cache) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
const key = `${URL_CACHE_DELANG}_${OPT_TRANS_MICROSOFT}`;
|
||||
const queue = getBatchQueue(key, handleMicrosoftLangdetect, {
|
||||
batchInterval: 500,
|
||||
batchSize: 20,
|
||||
batchLength: 100000,
|
||||
});
|
||||
const lang = await queue.addTask(text);
|
||||
|
||||
if (lang) {
|
||||
putHttpCachePolyfill(cacheInput, null, lang);
|
||||
return lang;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Microsoft词典
|
||||
* @param {*} text
|
||||
* @returns
|
||||
*/
|
||||
export const apiMicrosoftDict = async (text) => {
|
||||
const cacheOpts = { text };
|
||||
const cacheInput = `${URL_CACHE_BINGDICT}?${queryString.stringify(cacheOpts)}`;
|
||||
const cache = await getHttpCachePolyfill(cacheInput);
|
||||
if (cache) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
const host = "https://www.bing.com";
|
||||
const url = `${host}/dict/search?q=${text}&FORM=BDVSP6&cc=cn`;
|
||||
const str = await fetchData(
|
||||
url,
|
||||
{ credentials: "include" },
|
||||
{ useCache: false }
|
||||
);
|
||||
if (!str) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(str, "text/html");
|
||||
|
||||
const word = doc.querySelector("#headword > h1")?.textContent.trim();
|
||||
if (!word) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trs = [];
|
||||
doc.querySelectorAll("div.qdef > ul > li").forEach(($li) => {
|
||||
const pos = $li.querySelector(".pos")?.textContent?.trim();
|
||||
const def = $li.querySelector(".def")?.textContent?.trim();
|
||||
trs.push({ pos, def });
|
||||
});
|
||||
|
||||
const aus = [];
|
||||
const $audioUK = doc.querySelector("#bigaud_uk");
|
||||
const $audioUS = doc.querySelector("#bigaud_us");
|
||||
if ($audioUK) {
|
||||
const audioUK = host + $audioUK?.dataset?.mp3link;
|
||||
const $phoneticUK = $audioUK.parentElement?.previousElementSibling;
|
||||
const phoneticUK = $phoneticUK?.textContent?.trim();
|
||||
aus.push({ key: "UK", audio: audioUK, phonetic: phoneticUK });
|
||||
}
|
||||
if ($audioUS) {
|
||||
const audioUS = host + $audioUS?.dataset?.mp3link;
|
||||
const $phoneticUS = $audioUS.parentElement?.previousElementSibling;
|
||||
const phoneticUS = $phoneticUS?.textContent?.trim();
|
||||
aus.push({ key: "US", audio: audioUS, phonetic: phoneticUS });
|
||||
}
|
||||
|
||||
const res = { word, trs, aus };
|
||||
putHttpCachePolyfill(cacheInput, null, res);
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
/**
|
||||
* 百度语言识别
|
||||
@@ -53,7 +187,8 @@ export const apiFetch = (url) => fetchPolyfill(url);
|
||||
* @returns
|
||||
*/
|
||||
export const apiBaiduLangdetect = async (text) => {
|
||||
const res = await fetchPolyfill(URL_BAIDU_LANGDETECT, {
|
||||
const input = "https://fanyi.baidu.com/langdetect";
|
||||
const init = {
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
},
|
||||
@@ -61,11 +196,12 @@ export const apiBaiduLangdetect = async (text) => {
|
||||
body: JSON.stringify({
|
||||
query: text,
|
||||
}),
|
||||
useCache: true,
|
||||
});
|
||||
};
|
||||
const res = await fetchData(input, init, { useCache: true });
|
||||
|
||||
if (res.error === 0) {
|
||||
return OPT_LANGS_BAIDU.get(res.lan) ?? res.lan;
|
||||
if (res?.error === 0) {
|
||||
await putHttpCachePolyfill(input, init, res);
|
||||
return res.lan;
|
||||
}
|
||||
|
||||
return "";
|
||||
@@ -77,7 +213,8 @@ export const apiBaiduLangdetect = async (text) => {
|
||||
* @returns
|
||||
*/
|
||||
export const apiBaiduSuggest = async (text) => {
|
||||
const res = await fetchPolyfill(URL_BAIDU_SUGGEST, {
|
||||
const input = "https://fanyi.baidu.com/sug";
|
||||
const init = {
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
},
|
||||
@@ -85,39 +222,190 @@ export const apiBaiduSuggest = async (text) => {
|
||||
body: JSON.stringify({
|
||||
kw: text,
|
||||
}),
|
||||
useCache: true,
|
||||
});
|
||||
};
|
||||
const res = await fetchData(input, init, { useCache: true });
|
||||
|
||||
if (res.errno === 0) {
|
||||
if (res?.errno === 0) {
|
||||
await putHttpCachePolyfill(input, init, res);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 有道翻译建议
|
||||
* @param {*} text
|
||||
* @returns
|
||||
*/
|
||||
export const apiYoudaoSuggest = async (text) => {
|
||||
const params = {
|
||||
num: 5,
|
||||
ver: 3.0,
|
||||
doctype: "json",
|
||||
cache: false,
|
||||
le: "en",
|
||||
q: text,
|
||||
};
|
||||
const input = `https://dict.youdao.com/suggest?${queryString.stringify(params)}`;
|
||||
const init = {
|
||||
headers: {
|
||||
accept: "application/json, text/plain, */*",
|
||||
"accept-language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,ja;q=0.6",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method: "GET",
|
||||
};
|
||||
const res = await fetchData(input, init, { useCache: true });
|
||||
|
||||
if (res?.result?.code === 200) {
|
||||
await putHttpCachePolyfill(input, init, res);
|
||||
return res.data.entries;
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 有道词典
|
||||
* @param {*} text
|
||||
* @returns
|
||||
*/
|
||||
export const apiYoudaoDict = async (text) => {
|
||||
const params = {
|
||||
doctype: "json",
|
||||
jsonversion: 4,
|
||||
};
|
||||
const input = `https://dict.youdao.com/jsonapi_s?${queryString.stringify(params)}`;
|
||||
const body = queryString.stringify({
|
||||
q: text,
|
||||
le: "en",
|
||||
t: 3,
|
||||
client: "web",
|
||||
// sign: "",
|
||||
keyfrom: "webdict",
|
||||
});
|
||||
const init = {
|
||||
headers: {
|
||||
accept: "application/json, text/plain, */*",
|
||||
"accept-language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,ja;q=0.6",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method: "POST",
|
||||
body,
|
||||
};
|
||||
const res = await fetchData(input, init, { useCache: true });
|
||||
|
||||
if (res) {
|
||||
await putHttpCachePolyfill(input, init, res);
|
||||
return res;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 百度语音
|
||||
* @param {*} text
|
||||
* @param {*} lan
|
||||
* @param {*} spd
|
||||
* @returns
|
||||
*/
|
||||
export const apiBaiduTTS = (text, lan = "uk", spd = 3) => {
|
||||
const input = `https://fanyi.baidu.com/gettts?${queryString.stringify({ lan, text, spd })}`;
|
||||
return fetchData(input);
|
||||
};
|
||||
|
||||
/**
|
||||
* 腾讯语言识别
|
||||
* @param {*} text
|
||||
* @returns
|
||||
*/
|
||||
export const apiTencentLangdetect = async (text) => {
|
||||
const input = "https://transmart.qq.com/api/imt";
|
||||
const body = JSON.stringify({
|
||||
header: {
|
||||
fn: "text_analysis",
|
||||
client_key:
|
||||
"browser-chrome-110.0.0-Mac OS-df4bd4c5-a65d-44b2-a40f-42f34f3535f2-1677486696487",
|
||||
},
|
||||
text,
|
||||
});
|
||||
|
||||
const res = await fetchPolyfill(URL_TENCENT_TRANSMART, {
|
||||
const init = {
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36",
|
||||
referer: "https://transmart.qq.com/zh-CN/index",
|
||||
},
|
||||
method: "POST",
|
||||
body,
|
||||
useCache: true,
|
||||
});
|
||||
};
|
||||
const res = await fetchData(input, init, { useCache: true });
|
||||
|
||||
return OPT_LANGS_TENCENT.get(res.language) ?? res.language;
|
||||
if (res?.language) {
|
||||
await putHttpCachePolyfill(input, init, res);
|
||||
return res.language;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* 浏览器内置AI语言识别
|
||||
* @param {*} text
|
||||
* @returns
|
||||
*/
|
||||
export const apiBuiltinAIDetect = async (text) => {
|
||||
if (!isBuiltinAIAvailable) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [lang, error] = await fnPolyfill({
|
||||
fn: chromeDetect,
|
||||
msg: MSG_BUILTINAI_DETECT,
|
||||
text,
|
||||
});
|
||||
if (!error) {
|
||||
return lang;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* 浏览器内置AI翻译
|
||||
* @param {*} param0
|
||||
* @returns
|
||||
*/
|
||||
const apiBuiltinAITranslate = async ({ text, from, to, apiSetting }) => {
|
||||
if (!isBuiltinAIAvailable) {
|
||||
return ["", true];
|
||||
}
|
||||
|
||||
const { fetchInterval, fetchLimit, httpTimeout } = apiSetting;
|
||||
const fetchPool = getFetchPool(fetchInterval, fetchLimit);
|
||||
const result = await withTimeout(
|
||||
fetchPool.push(fnPolyfill, {
|
||||
fn: chromeTranslate,
|
||||
msg: MSG_BUILTINAI_TRANSLATE,
|
||||
text,
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
httpTimeout
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new Error("apiBuiltinAITranslate got null reault");
|
||||
}
|
||||
|
||||
const [trText, srLang, error] = result;
|
||||
if (error) {
|
||||
throw new Error("apiBuiltinAITranslate got error", error);
|
||||
}
|
||||
|
||||
return [trText, srLang];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -126,115 +414,143 @@ export const apiTencentLangdetect = async (text) => {
|
||||
* @returns
|
||||
*/
|
||||
export const apiTranslate = async ({
|
||||
translator,
|
||||
text,
|
||||
fromLang,
|
||||
fromLang = "auto",
|
||||
toLang,
|
||||
apiSetting = {},
|
||||
apiSetting = DEFAULT_API_SETTING,
|
||||
docInfo = {},
|
||||
glossary = {},
|
||||
useCache = true,
|
||||
usePool = true,
|
||||
}) => {
|
||||
let trText = "";
|
||||
let isSame = false;
|
||||
|
||||
if (!text) {
|
||||
return [trText, true];
|
||||
return ["", false];
|
||||
}
|
||||
|
||||
const from =
|
||||
OPT_LANGS_SPECIAL[translator].get(fromLang) ??
|
||||
OPT_LANGS_SPECIAL[translator].get("auto");
|
||||
const to = OPT_LANGS_SPECIAL[translator].get(toLang);
|
||||
const { apiType, apiSlug, useBatchFetch } = apiSetting;
|
||||
const langMap = OPT_LANGS_TO_SPEC[apiType] || OPT_LANGS_SPEC_DEFAULT;
|
||||
const from = langMap.get(fromLang);
|
||||
const to = langMap.get(toLang);
|
||||
if (!to) {
|
||||
console.log(`[trans] target lang: ${toLang} not support`);
|
||||
return [trText, isSame];
|
||||
kissLog(`target lang: ${toLang} not support`);
|
||||
return ["", false];
|
||||
}
|
||||
|
||||
// 版本号一/二位升级,旧缓存失效
|
||||
// todo: 优化缓存失效因素
|
||||
const [v1, v2] = process.env.REACT_APP_VERSION.split(".");
|
||||
const cacheOpts = {
|
||||
translator,
|
||||
apiSlug,
|
||||
text,
|
||||
fromLang,
|
||||
toLang,
|
||||
version: [v1, v2].join("."),
|
||||
};
|
||||
const cacheInput = `${URL_CACHE_TRAN}?${queryString.stringify(cacheOpts)}`;
|
||||
|
||||
const transOpts = {
|
||||
translator,
|
||||
// 查询缓存数据
|
||||
if (useCache) {
|
||||
const cache = await getHttpCachePolyfill(cacheInput);
|
||||
if (cache?.trText) {
|
||||
return [cache.trText, cache.isSame];
|
||||
}
|
||||
}
|
||||
|
||||
// 请求接口数据
|
||||
let tranlation = [];
|
||||
if (apiType === OPT_TRANS_BUILTINAI) {
|
||||
tranlation = await apiBuiltinAITranslate({
|
||||
text,
|
||||
from,
|
||||
to,
|
||||
};
|
||||
|
||||
const res = await fetchPolyfill(
|
||||
`${URL_CACHE_TRAN}?${queryString.stringify(cacheOpts)}`,
|
||||
{
|
||||
useCache,
|
||||
usePool,
|
||||
transOpts,
|
||||
apiSetting,
|
||||
}
|
||||
);
|
||||
|
||||
switch (translator) {
|
||||
case OPT_TRANS_GOOGLE:
|
||||
trText = res.sentences.map((item) => item.trans).join(" ");
|
||||
isSame = to === res.src;
|
||||
break;
|
||||
case OPT_TRANS_MICROSOFT:
|
||||
trText = res
|
||||
.map((item) => item.translations.map((item) => item.text).join(" "))
|
||||
.join(" ");
|
||||
isSame = text === trText;
|
||||
break;
|
||||
case OPT_TRANS_DEEPL:
|
||||
trText = res.translations.map((item) => item.text).join(" ");
|
||||
isSame = to === res.translations[0].detected_source_language;
|
||||
break;
|
||||
case OPT_TRANS_DEEPLFREE:
|
||||
trText = res.result?.texts.map((item) => item.text).join(" ");
|
||||
isSame = to === res.result?.lang;
|
||||
break;
|
||||
case OPT_TRANS_DEEPLX:
|
||||
trText = res.data;
|
||||
isSame = to === res.source_lang;
|
||||
break;
|
||||
case OPT_TRANS_BAIDU:
|
||||
// trText = res.trans_result?.data.map((item) => item.dst).join(" ");
|
||||
// isSame = res.trans_result?.to === res.trans_result?.from;
|
||||
if (res.type === 1) {
|
||||
trText = Object.keys(JSON.parse(res.result).content[0].mean[0].cont)[0];
|
||||
isSame = to === res.from;
|
||||
} else if (res.type === 2) {
|
||||
trText = res.data.map((item) => item.dst).join(" ");
|
||||
isSame = to === res.from;
|
||||
}
|
||||
break;
|
||||
case OPT_TRANS_TENCENT:
|
||||
trText = res.auto_translation;
|
||||
isSame = text === trText;
|
||||
break;
|
||||
case OPT_TRANS_OPENAI:
|
||||
trText = res?.choices?.map((item) => item.message.content).join(" ");
|
||||
isSame = text === trText;
|
||||
break;
|
||||
case OPT_TRANS_GEMINI:
|
||||
trText = res?.candidates
|
||||
?.map((item) => item.content?.parts.map((item) => item.text).join(" "))
|
||||
.join(" ");
|
||||
isSame = text === trText;
|
||||
break;
|
||||
case OPT_TRANS_CLOUDFLAREAI:
|
||||
trText = res?.result?.translated_text;
|
||||
isSame = text === trText;
|
||||
break;
|
||||
case OPT_TRANS_CUSTOMIZE:
|
||||
trText = res.text;
|
||||
isSame = to === res.from;
|
||||
break;
|
||||
default:
|
||||
});
|
||||
} else if (useBatchFetch && API_SPE_TYPES.batch.has(apiType)) {
|
||||
const { apiSlug, batchInterval, batchSize, batchLength } = apiSetting;
|
||||
const key = `${apiSlug}_${fromLang}_${toLang}`;
|
||||
const queue = getBatchQueue(key, handleTranslate, {
|
||||
batchInterval,
|
||||
batchSize,
|
||||
batchLength,
|
||||
});
|
||||
tranlation = await queue.addTask(text, {
|
||||
from,
|
||||
to,
|
||||
fromLang,
|
||||
toLang,
|
||||
langMap,
|
||||
docInfo,
|
||||
glossary,
|
||||
apiSetting,
|
||||
usePool,
|
||||
});
|
||||
} else {
|
||||
[tranlation] = await handleTranslate([text], {
|
||||
from,
|
||||
to,
|
||||
fromLang,
|
||||
toLang,
|
||||
langMap,
|
||||
docInfo,
|
||||
glossary,
|
||||
apiSetting,
|
||||
usePool,
|
||||
});
|
||||
}
|
||||
|
||||
return [trText, isSame, res];
|
||||
let trText = "";
|
||||
let srLang = "";
|
||||
if (Array.isArray(tranlation)) {
|
||||
[trText, srLang = ""] = tranlation;
|
||||
} else if (typeof tranlation === "string") {
|
||||
trText = tranlation;
|
||||
}
|
||||
|
||||
if (!trText) {
|
||||
throw new Error("tanslate api got empty trtext");
|
||||
}
|
||||
|
||||
const isSame = fromLang === "auto" && srLang === to;
|
||||
|
||||
// 插入缓存
|
||||
if (useCache) {
|
||||
putHttpCachePolyfill(cacheInput, null, { trText, isSame, srLang });
|
||||
}
|
||||
|
||||
return [trText, isSame];
|
||||
};
|
||||
|
||||
// 字幕处理/翻译
|
||||
export const apiSubtitle = async ({
|
||||
videoId,
|
||||
chunkSign,
|
||||
fromLang = "auto",
|
||||
toLang,
|
||||
events = [],
|
||||
apiSetting,
|
||||
}) => {
|
||||
const cacheOpts = {
|
||||
apiSlug: apiSetting.apiSlug,
|
||||
videoId,
|
||||
chunkSign,
|
||||
fromLang,
|
||||
toLang,
|
||||
};
|
||||
const cacheInput = `${URL_CACHE_SUBTITLE}?${queryString.stringify(cacheOpts)}`;
|
||||
const cache = await getHttpCachePolyfill(cacheInput);
|
||||
if (cache) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
const subtitles = await handleSubtitle({
|
||||
events,
|
||||
from: fromLang,
|
||||
to: toLang,
|
||||
apiSetting,
|
||||
});
|
||||
if (subtitles?.length) {
|
||||
putHttpCachePolyfill(cacheInput, null, subtitles);
|
||||
return subtitles;
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
1008
src/apis/trans.js
Normal file
1008
src/apis/trans.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import {
|
||||
MSG_FETCH,
|
||||
MSG_FETCH_LIMIT,
|
||||
MSG_FETCH_CLEAR,
|
||||
MSG_GET_HTTPCACHE,
|
||||
MSG_PUT_HTTPCACHE,
|
||||
MSG_TRANS_TOGGLE,
|
||||
MSG_OPEN_OPTIONS,
|
||||
MSG_SAVE_RULE,
|
||||
@@ -12,23 +12,42 @@ import {
|
||||
MSG_COMMAND_SHORTCUTS,
|
||||
MSG_INJECT_JS,
|
||||
MSG_INJECT_CSS,
|
||||
MSG_UPDATE_CSP,
|
||||
MSG_BUILTINAI_DETECT,
|
||||
MSG_BUILTINAI_TRANSLATE,
|
||||
DEFAULT_CSPLIST,
|
||||
DEFAULT_ORILIST,
|
||||
CMD_TOGGLE_TRANSLATE,
|
||||
CMD_TOGGLE_STYLE,
|
||||
CMD_OPEN_OPTIONS,
|
||||
CMD_OPEN_TRANBOX,
|
||||
CLIENT_THUNDERBIRD,
|
||||
MSG_SET_LOGLEVEL,
|
||||
MSG_CLEAR_CACHES,
|
||||
} from "./config";
|
||||
import { getSettingWithDefault, tryInitDefaultData } from "./libs/storage";
|
||||
import { trySyncSettingAndRules } from "./libs/sync";
|
||||
import { fetchData, fetchPool } from "./libs/fetch";
|
||||
import { fetchHandle } from "./libs/fetch";
|
||||
import { tryClearCaches, getHttpCache, putHttpCache } from "./libs/cache";
|
||||
import { sendTabMsg } from "./libs/msg";
|
||||
import { trySyncAllSubRules } from "./libs/subRules";
|
||||
import { tryClearCaches } from "./libs";
|
||||
import { saveRule } from "./libs/rules";
|
||||
import { getCurTabId } from "./libs/msg";
|
||||
import { injectInlineJs, injectInternalCss } from "./libs/injector";
|
||||
import { kissLog, logger } from "./libs/log";
|
||||
import { chromeDetect, chromeTranslate } from "./libs/builtinAI";
|
||||
|
||||
globalThis.ContextType = "BACKGROUND";
|
||||
|
||||
const CSP_RULE_START_ID = 1;
|
||||
const ORI_RULE_START_ID = 10000;
|
||||
const CSP_REMOVE_HEADERS = [
|
||||
`content-security-policy`,
|
||||
`content-security-policy-report-only`,
|
||||
`x-webkit-csp`,
|
||||
`x-content-security-policy`,
|
||||
];
|
||||
|
||||
/**
|
||||
* 添加右键菜单
|
||||
*/
|
||||
@@ -37,7 +56,7 @@ async function addContextMenus(contextMenuType = 1) {
|
||||
try {
|
||||
await browser.contextMenus.removeAll();
|
||||
} catch (err) {
|
||||
//
|
||||
kissLog("remove contextMenus", err);
|
||||
}
|
||||
|
||||
switch (contextMenuType) {
|
||||
@@ -79,76 +98,206 @@ async function addContextMenus(contextMenuType = 1) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新CSP策略
|
||||
* @param {*} csplist
|
||||
*/
|
||||
async function updateCspRules({ csplist, orilist }) {
|
||||
try {
|
||||
const oldRules = await browser.declarativeNetRequest.getDynamicRules();
|
||||
|
||||
const rulesToAdd = [];
|
||||
const idsToRemove = [];
|
||||
|
||||
if (csplist !== undefined) {
|
||||
let processedCspList = csplist;
|
||||
if (typeof processedCspList === "string") {
|
||||
processedCspList = processedCspList
|
||||
.split(/\n|,/)
|
||||
.map((url) => url.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const oldCspRuleIds = oldRules
|
||||
.filter(
|
||||
(rule) => rule.id >= CSP_RULE_START_ID && rule.id < ORI_RULE_START_ID
|
||||
)
|
||||
.map((rule) => rule.id);
|
||||
idsToRemove.push(...oldCspRuleIds);
|
||||
|
||||
const newCspRules = processedCspList.map((url, index) => ({
|
||||
id: CSP_RULE_START_ID + index,
|
||||
action: {
|
||||
type: "modifyHeaders",
|
||||
responseHeaders: CSP_REMOVE_HEADERS.map((header) => ({
|
||||
operation: "remove",
|
||||
header,
|
||||
})),
|
||||
},
|
||||
condition: {
|
||||
urlFilter: url,
|
||||
resourceTypes: ["main_frame", "sub_frame"],
|
||||
},
|
||||
}));
|
||||
rulesToAdd.push(...newCspRules);
|
||||
}
|
||||
|
||||
if (orilist !== undefined) {
|
||||
let processedOriList = orilist;
|
||||
if (typeof processedOriList === "string") {
|
||||
processedOriList = processedOriList
|
||||
.split(/\n|,/)
|
||||
.map((url) => url.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const oldOriRuleIds = oldRules
|
||||
.filter((rule) => rule.id >= ORI_RULE_START_ID)
|
||||
.map((rule) => rule.id);
|
||||
idsToRemove.push(...oldOriRuleIds);
|
||||
|
||||
const newOriRules = processedOriList.map((url, index) => ({
|
||||
id: ORI_RULE_START_ID + index,
|
||||
action: {
|
||||
type: "modifyHeaders",
|
||||
requestHeaders: [{ header: "Origin", operation: "set", value: url }],
|
||||
},
|
||||
condition: {
|
||||
urlFilter: url,
|
||||
resourceTypes: ["xmlhttprequest"],
|
||||
},
|
||||
}));
|
||||
rulesToAdd.push(...newOriRules);
|
||||
}
|
||||
|
||||
if (idsToRemove.length > 0 || rulesToAdd.length > 0) {
|
||||
await browser.declarativeNetRequest.updateDynamicRules({
|
||||
removeRuleIds: idsToRemove,
|
||||
addRules: rulesToAdd,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
kissLog("update csp rules", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册邮件显示脚本
|
||||
*/
|
||||
async function registerMsgDisplayScript() {
|
||||
await messenger.messageDisplayScripts.register({
|
||||
js: [{ file: "/content.js" }],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件安装
|
||||
*/
|
||||
browser.runtime.onInstalled.addListener(() => {
|
||||
tryInitDefaultData();
|
||||
|
||||
//在thunderbird中注册脚本
|
||||
if (process.env.REACT_APP_CLIENT === CLIENT_THUNDERBIRD) {
|
||||
registerMsgDisplayScript();
|
||||
}
|
||||
|
||||
// 右键菜单
|
||||
addContextMenus();
|
||||
|
||||
// 禁用CSP
|
||||
updateCspRules({ csplist: DEFAULT_CSPLIST, orilist: DEFAULT_ORILIST });
|
||||
});
|
||||
|
||||
/**
|
||||
* 浏览器启动
|
||||
*/
|
||||
browser.runtime.onStartup.addListener(async () => {
|
||||
// 同步数据
|
||||
await trySyncSettingAndRules();
|
||||
const {
|
||||
clearCache,
|
||||
contextMenuType,
|
||||
subrulesList,
|
||||
csplist,
|
||||
orilist,
|
||||
logLevel,
|
||||
} = await getSettingWithDefault();
|
||||
|
||||
const { clearCache, contextMenuType, subrulesList } =
|
||||
await getSettingWithDefault();
|
||||
// 设置日志
|
||||
logger.setLevel(logLevel);
|
||||
|
||||
// 清除缓存
|
||||
if (clearCache) {
|
||||
tryClearCaches();
|
||||
}
|
||||
|
||||
//在thunderbird中注册脚本
|
||||
if (process.env.REACT_APP_CLIENT === CLIENT_THUNDERBIRD) {
|
||||
registerMsgDisplayScript();
|
||||
}
|
||||
|
||||
// 右键菜单
|
||||
// firefox重启后菜单会消失,故重复添加
|
||||
addContextMenus(contextMenuType);
|
||||
|
||||
// 禁用CSP
|
||||
updateCspRules({ csplist, orilist });
|
||||
|
||||
// 同步数据
|
||||
trySyncSettingAndRules();
|
||||
|
||||
// 同步订阅规则
|
||||
trySyncAllSubRules({ subrulesList });
|
||||
});
|
||||
|
||||
/**
|
||||
* 向当前活动标签页注入脚本或CSS
|
||||
*/
|
||||
const injectToCurrentTab = async (func, args) => {
|
||||
const tabId = await getCurTabId();
|
||||
return browser.scripting.executeScript({
|
||||
target: { tabId, allFrames: true },
|
||||
func: func,
|
||||
args: [args],
|
||||
world: "MAIN",
|
||||
});
|
||||
};
|
||||
|
||||
// 动作处理器映射表
|
||||
const messageHandlers = {
|
||||
[MSG_FETCH]: (args) => fetchHandle(args),
|
||||
[MSG_GET_HTTPCACHE]: (args) => getHttpCache(args),
|
||||
[MSG_PUT_HTTPCACHE]: (args) => putHttpCache(args),
|
||||
[MSG_OPEN_OPTIONS]: () => browser.runtime.openOptionsPage(),
|
||||
[MSG_SAVE_RULE]: (args) => saveRule(args),
|
||||
[MSG_INJECT_JS]: (args) => injectToCurrentTab(injectInlineJs, args),
|
||||
[MSG_INJECT_CSS]: (args) => injectToCurrentTab(injectInternalCss, args),
|
||||
[MSG_UPDATE_CSP]: (args) => updateCspRules(args),
|
||||
[MSG_CONTEXT_MENUS]: (args) => addContextMenus(args),
|
||||
[MSG_COMMAND_SHORTCUTS]: () => browser.commands.getAll(),
|
||||
[MSG_BUILTINAI_DETECT]: (args) => chromeDetect(args),
|
||||
[MSG_BUILTINAI_TRANSLATE]: (args) => chromeTranslate(args),
|
||||
[MSG_SET_LOGLEVEL]: (args) => logger.setLevel(args),
|
||||
[MSG_CLEAR_CACHES]: () => tryClearCaches(),
|
||||
};
|
||||
|
||||
/**
|
||||
* 监听消息
|
||||
* todo: 返回含错误的结构化信息
|
||||
*/
|
||||
browser.runtime.onMessage.addListener(async ({ action, args }) => {
|
||||
switch (action) {
|
||||
case MSG_FETCH:
|
||||
const { input, opts } = args;
|
||||
return await fetchData(input, opts);
|
||||
case MSG_FETCH_LIMIT:
|
||||
const { interval, limit } = args;
|
||||
return fetchPool.update(interval, limit);
|
||||
case MSG_FETCH_CLEAR:
|
||||
return fetchPool.clear();
|
||||
case MSG_OPEN_OPTIONS:
|
||||
return await browser.runtime.openOptionsPage();
|
||||
case MSG_SAVE_RULE:
|
||||
return await saveRule(args);
|
||||
case MSG_INJECT_JS:
|
||||
return await browser.scripting.executeScript({
|
||||
target: { tabId: await getCurTabId(), allFrames: true },
|
||||
func: injectInlineJs,
|
||||
args: [args],
|
||||
world: "MAIN",
|
||||
});
|
||||
case MSG_INJECT_CSS:
|
||||
return await browser.scripting.executeScript({
|
||||
target: { tabId: await getCurTabId(), allFrames: true },
|
||||
func: injectInternalCss,
|
||||
args: [args],
|
||||
world: "MAIN",
|
||||
});
|
||||
case MSG_CONTEXT_MENUS:
|
||||
return await addContextMenus(args.contextMenuType);
|
||||
case MSG_COMMAND_SHORTCUTS:
|
||||
return await browser.commands.getAll();
|
||||
default:
|
||||
throw new Error(`message action is unavailable: ${action}`);
|
||||
const handler = messageHandlers[action];
|
||||
|
||||
if (!handler) {
|
||||
const errorMessage = `Message action is unavailable: ${action}`;
|
||||
kissLog("runtime onMessage", action, new Error(errorMessage));
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(args);
|
||||
return result;
|
||||
} catch (err) {
|
||||
kissLog("runtime onMessage", action, err);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
180
src/common.js
180
src/common.js
@@ -6,24 +6,21 @@ import { CacheProvider } from "@emotion/react";
|
||||
import {
|
||||
MSG_TRANS_TOGGLE,
|
||||
MSG_TRANS_TOGGLE_STYLE,
|
||||
MSG_TRANS_GETRULE,
|
||||
MSG_TRANS_PUTRULE,
|
||||
MSG_OPEN_TRANBOX,
|
||||
APP_LCNAME,
|
||||
DEFAULT_TRANBOX_SETTING,
|
||||
APP_CONSTS,
|
||||
} from "./config";
|
||||
import { getFabWithDefault, getSettingWithDefault } from "./libs/storage";
|
||||
import { Translator } from "./libs/translator";
|
||||
import { isIframe, sendIframeMsg } from "./libs/iframe";
|
||||
import Slection from "./views/Selection";
|
||||
import { touchTapListener } from "./libs/touch";
|
||||
import { debounce, genEventName } from "./libs/utils";
|
||||
import { handlePing, injectScript } from "./libs/gm";
|
||||
import { browser } from "./libs/browser";
|
||||
import { matchRule } from "./libs/rules";
|
||||
import { trySyncAllSubRules } from "./libs/subRules";
|
||||
import { isInBlacklist } from "./libs/blacklist";
|
||||
import inputTranslate from "./libs/inputTranslate";
|
||||
import { runSubtitle } from "./subtitle/subtitle";
|
||||
import { logger } from "./libs/log";
|
||||
import { injectInlineJs } from "./libs/injector";
|
||||
|
||||
/**
|
||||
* 油猴脚本设置页面
|
||||
@@ -39,43 +36,13 @@ function runSettingPage() {
|
||||
const ping = genEventName();
|
||||
window.addEventListener(ping, handlePing);
|
||||
// window.eval(`(${injectScript})("${ping}")`); // eslint-disable-line
|
||||
const script = document.createElement("script");
|
||||
script.textContent = `(${injectScript})("${ping}")`;
|
||||
document.head.append(script);
|
||||
injectInlineJs(
|
||||
`(${injectScript})("${ping}")`,
|
||||
"kiss-translator-options-injector"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件监听后端事件
|
||||
* @param {*} translator
|
||||
*/
|
||||
function runtimeListener(translator) {
|
||||
browser?.runtime.onMessage.addListener(async ({ action, args }) => {
|
||||
switch (action) {
|
||||
case MSG_TRANS_TOGGLE:
|
||||
translator.toggle();
|
||||
sendIframeMsg(MSG_TRANS_TOGGLE);
|
||||
break;
|
||||
case MSG_TRANS_TOGGLE_STYLE:
|
||||
translator.toggleStyle();
|
||||
sendIframeMsg(MSG_TRANS_TOGGLE_STYLE);
|
||||
break;
|
||||
case MSG_TRANS_GETRULE:
|
||||
break;
|
||||
case MSG_TRANS_PUTRULE:
|
||||
translator.updateRule(args);
|
||||
sendIframeMsg(MSG_TRANS_PUTRULE, args);
|
||||
break;
|
||||
case MSG_OPEN_TRANBOX:
|
||||
window.dispatchEvent(new CustomEvent(MSG_OPEN_TRANBOX));
|
||||
break;
|
||||
default:
|
||||
return { error: `message action is unavailable: ${action}` };
|
||||
}
|
||||
return { data: translator.rule };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* iframe 页面执行
|
||||
* @param {*} translator
|
||||
@@ -106,7 +73,8 @@ function runIframe(translator) {
|
||||
async function showFab(translator) {
|
||||
const fab = await getFabWithDefault();
|
||||
const $action = document.createElement("div");
|
||||
$action.setAttribute("id", APP_LCNAME);
|
||||
$action.id = APP_CONSTS.fabID;
|
||||
$action.className = "notranslate";
|
||||
$action.style.fontSize = "0";
|
||||
$action.style.width = "0";
|
||||
$action.style.height = "0";
|
||||
@@ -114,10 +82,11 @@ async function showFab(translator) {
|
||||
const shadowContainer = $action.attachShadow({ mode: "closed" });
|
||||
const emotionRoot = document.createElement("style");
|
||||
const shadowRootElement = document.createElement("div");
|
||||
shadowRootElement.className = `${APP_CONSTS.fabID}_warpper notranslate`;
|
||||
shadowContainer.appendChild(emotionRoot);
|
||||
shadowContainer.appendChild(shadowRootElement);
|
||||
const cache = createCache({
|
||||
key: APP_LCNAME,
|
||||
key: APP_CONSTS.fabID,
|
||||
prepend: true,
|
||||
container: emotionRoot,
|
||||
});
|
||||
@@ -130,58 +99,66 @@ async function showFab(translator) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 划词翻译
|
||||
* @param {*} param0
|
||||
* @returns
|
||||
*/
|
||||
function showTransbox({
|
||||
contextMenuType,
|
||||
tranboxSetting = DEFAULT_TRANBOX_SETTING,
|
||||
transApis,
|
||||
}) {
|
||||
if (!tranboxSetting?.transOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $tranbox = document.createElement("div");
|
||||
$tranbox.setAttribute("id", "kiss-transbox");
|
||||
$tranbox.style.fontSize = "0";
|
||||
$tranbox.style.width = "0";
|
||||
$tranbox.style.height = "0";
|
||||
document.body.parentElement.appendChild($tranbox);
|
||||
const shadowContainer = $tranbox.attachShadow({ mode: "closed" });
|
||||
const emotionRoot = document.createElement("style");
|
||||
const shadowRootElement = document.createElement("div");
|
||||
shadowContainer.appendChild(emotionRoot);
|
||||
shadowContainer.appendChild(shadowRootElement);
|
||||
const cache = createCache({
|
||||
key: "kiss-transbox",
|
||||
prepend: true,
|
||||
container: emotionRoot,
|
||||
});
|
||||
ReactDOM.createRoot(shadowRootElement).render(
|
||||
<React.StrictMode>
|
||||
<CacheProvider value={cache}>
|
||||
<Slection
|
||||
contextMenuType={contextMenuType}
|
||||
tranboxSetting={tranboxSetting}
|
||||
transApis={transApis}
|
||||
/>
|
||||
</CacheProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示错误信息到页面顶部
|
||||
* @param {*} message
|
||||
*/
|
||||
function showErr(message) {
|
||||
const $err = document.createElement("div");
|
||||
$err.innerText = `KISS-Translator: ${message}`;
|
||||
$err.style.cssText = "background:red; color:#fff;";
|
||||
document.body.prepend($err);
|
||||
const bannerId = "KISS-Translator-Message";
|
||||
const existingBanner = document.getElementById(bannerId);
|
||||
if (existingBanner) {
|
||||
existingBanner.remove();
|
||||
}
|
||||
|
||||
const banner = document.createElement("div");
|
||||
banner.id = bannerId;
|
||||
|
||||
Object.assign(banner.style, {
|
||||
position: "fixed",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "100%",
|
||||
backgroundColor: "#f44336",
|
||||
color: "white",
|
||||
textAlign: "center",
|
||||
padding: "8px 16px",
|
||||
zIndex: "1001",
|
||||
boxSizing: "border-box",
|
||||
fontSize: "16px",
|
||||
boxShadow: "0 2px 5px rgba(0,0,0,0.2)",
|
||||
});
|
||||
|
||||
const closeButton = document.createElement("span");
|
||||
closeButton.textContent = "×";
|
||||
|
||||
Object.assign(closeButton.style, {
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
right: "20px",
|
||||
transform: "translateY(-50%)",
|
||||
cursor: "pointer",
|
||||
fontSize: "22px",
|
||||
fontWeight: "bold",
|
||||
});
|
||||
|
||||
const messageText = document.createTextNode(`KISS-Translator: ${message}`);
|
||||
banner.appendChild(messageText);
|
||||
banner.appendChild(closeButton);
|
||||
|
||||
document.body.appendChild(banner);
|
||||
|
||||
const removeBanner = () => {
|
||||
banner.style.transition = "opacity 0.5s ease";
|
||||
banner.style.opacity = "0";
|
||||
setTimeout(() => {
|
||||
if (banner && banner.parentNode) {
|
||||
banner.parentNode.removeChild(banner);
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
closeButton.onclick = removeBanner;
|
||||
setTimeout(removeBanner, 10000);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,22 +184,24 @@ function touchOperation(translator) {
|
||||
*/
|
||||
export async function run(isUserscript = false) {
|
||||
try {
|
||||
// 读取设置信息
|
||||
const setting = await getSettingWithDefault();
|
||||
|
||||
// 日志
|
||||
logger.setLevel(setting.logLevel);
|
||||
|
||||
const href = document.location.href;
|
||||
|
||||
// 设置页面
|
||||
if (
|
||||
isUserscript &&
|
||||
(href.includes(process.env.REACT_APP_OPTIONSPAGE_DEV) ||
|
||||
href.includes(process.env.REACT_APP_OPTIONSPAGE) ||
|
||||
href.includes(process.env.REACT_APP_OPTIONSPAGE2))
|
||||
href.includes(process.env.REACT_APP_OPTIONSPAGE))
|
||||
) {
|
||||
runSettingPage();
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取设置信息
|
||||
const setting = await getSettingWithDefault();
|
||||
|
||||
// 黑名单
|
||||
if (isInBlacklist(href, setting)) {
|
||||
return;
|
||||
@@ -230,7 +209,7 @@ export async function run(isUserscript = false) {
|
||||
|
||||
// 翻译网页
|
||||
const rule = await matchRule(href, setting);
|
||||
const translator = new Translator(rule, setting);
|
||||
const translator = new Translator(rule, setting, isUserscript);
|
||||
|
||||
// 适配iframe
|
||||
if (isIframe) {
|
||||
@@ -238,14 +217,17 @@ export async function run(isUserscript = false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 字幕翻译
|
||||
runSubtitle({ href, setting, rule, isUserscript });
|
||||
|
||||
// 监听消息
|
||||
!isUserscript && runtimeListener(translator);
|
||||
// !isUserscript && runtimeListener(translator);
|
||||
|
||||
// 输入框翻译
|
||||
inputTranslate(setting);
|
||||
// inputTranslate(setting);
|
||||
|
||||
// 划词翻译
|
||||
showTransbox(setting);
|
||||
// showTransbox(setting, rule);
|
||||
|
||||
// 浮球按钮
|
||||
await showFab(translator);
|
||||
|
||||
568
src/config/api.js
Normal file
568
src/config/api.js
Normal file
@@ -0,0 +1,568 @@
|
||||
export const DEFAULT_HTTP_TIMEOUT = 10000; // 调用超时时间
|
||||
export const DEFAULT_FETCH_LIMIT = 10; // 默认最大任务数量
|
||||
export const DEFAULT_FETCH_INTERVAL = 100; // 默认任务间隔时间
|
||||
export const DEFAULT_BATCH_INTERVAL = 1000; // 批处理请求间隔时间
|
||||
export const DEFAULT_BATCH_SIZE = 10; // 每次最多发送段落数量
|
||||
export const DEFAULT_BATCH_LENGTH = 10000; // 每次发送最大文字数量
|
||||
export const DEFAULT_CONTEXT_SIZE = 3; // 上下文会话数量
|
||||
|
||||
export const INPUT_PLACE_URL = "{{url}}"; // 占位符
|
||||
export const INPUT_PLACE_FROM = "{{from}}"; // 占位符
|
||||
export const INPUT_PLACE_TO = "{{to}}"; // 占位符
|
||||
export const INPUT_PLACE_TEXT = "{{text}}"; // 占位符
|
||||
export const INPUT_PLACE_KEY = "{{key}}"; // 占位符
|
||||
export const INPUT_PLACE_MODEL = "{{model}}"; // 占位符
|
||||
|
||||
// export const OPT_DICT_BAIDU = "Baidu";
|
||||
export const OPT_DICT_BING = "Bing";
|
||||
export const OPT_DICT_YOUDAO = "Youdao";
|
||||
export const OPT_DICT_ALL = [OPT_DICT_BING, OPT_DICT_YOUDAO];
|
||||
export const OPT_DICT_MAP = new Set(OPT_DICT_ALL);
|
||||
|
||||
export const OPT_SUG_BAIDU = "Baidu";
|
||||
export const OPT_SUG_YOUDAO = "Youdao";
|
||||
export const OPT_SUG_ALL = [OPT_SUG_BAIDU, OPT_SUG_YOUDAO];
|
||||
export const OPT_SUG_MAP = new Set(OPT_SUG_ALL);
|
||||
|
||||
export const OPT_TRANS_BUILTINAI = "BuiltinAI";
|
||||
export const OPT_TRANS_GOOGLE = "Google";
|
||||
export const OPT_TRANS_GOOGLE_2 = "Google2";
|
||||
export const OPT_TRANS_MICROSOFT = "Microsoft";
|
||||
export const OPT_TRANS_AZUREAI = "AzureAI";
|
||||
export const OPT_TRANS_DEEPL = "DeepL";
|
||||
export const OPT_TRANS_DEEPLX = "DeepLX";
|
||||
export const OPT_TRANS_DEEPLFREE = "DeepLFree";
|
||||
export const OPT_TRANS_NIUTRANS = "NiuTrans";
|
||||
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_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_OPENROUTER = "OpenRouter";
|
||||
export const OPT_TRANS_CUSTOMIZE = "Custom";
|
||||
|
||||
// 内置支持的翻译引擎
|
||||
export const OPT_ALL_TYPES = [
|
||||
OPT_TRANS_BUILTINAI,
|
||||
OPT_TRANS_GOOGLE,
|
||||
OPT_TRANS_GOOGLE_2,
|
||||
OPT_TRANS_MICROSOFT,
|
||||
OPT_TRANS_AZUREAI,
|
||||
// OPT_TRANS_BAIDU,
|
||||
OPT_TRANS_TENCENT,
|
||||
OPT_TRANS_VOLCENGINE,
|
||||
OPT_TRANS_DEEPL,
|
||||
OPT_TRANS_DEEPLFREE,
|
||||
OPT_TRANS_DEEPLX,
|
||||
OPT_TRANS_NIUTRANS,
|
||||
OPT_TRANS_OPENAI,
|
||||
OPT_TRANS_GEMINI,
|
||||
OPT_TRANS_GEMINI_2,
|
||||
OPT_TRANS_CLAUDE,
|
||||
OPT_TRANS_CLOUDFLAREAI,
|
||||
OPT_TRANS_OLLAMA,
|
||||
OPT_TRANS_OPENROUTER,
|
||||
OPT_TRANS_CUSTOMIZE,
|
||||
];
|
||||
|
||||
export const OPT_LANGDETECTOR_ALL = [
|
||||
OPT_TRANS_BUILTINAI,
|
||||
OPT_TRANS_GOOGLE,
|
||||
OPT_TRANS_MICROSOFT,
|
||||
OPT_TRANS_BAIDU,
|
||||
OPT_TRANS_TENCENT,
|
||||
];
|
||||
|
||||
export const OPT_LANGDETECTOR_MAP = new Set(OPT_LANGDETECTOR_ALL);
|
||||
|
||||
// 翻译引擎特殊集合
|
||||
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_AZUREAI,
|
||||
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_AZUREAI,
|
||||
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 BUILTIN_STONES = [
|
||||
"formal", // 正式风格
|
||||
"casual", // 口语风格
|
||||
"neutral", // 中性风格
|
||||
"technical", // 技术风格
|
||||
"marketing", // 营销风格
|
||||
"Literary", // 文学风格
|
||||
"academic", // 学术风格
|
||||
"legal", // 法律风格
|
||||
"literal", // 直译风格
|
||||
"ldiomatic", // 意译风格
|
||||
"transcreation", // 创译风格
|
||||
"machine-like", // 机器风格
|
||||
"concise", // 简明风格
|
||||
];
|
||||
export const BUILTIN_PLACEHOLDERS = ["{ }", "{{ }}", "[ ]", "[[ ]]"];
|
||||
export const BUILTIN_PLACETAGS = ["i", "a", "b", "x"];
|
||||
|
||||
export const OPT_LANGS_TO = [
|
||||
["en", "English - English"],
|
||||
["zh-CN", "Simplified Chinese - 简体中文"],
|
||||
["zh-TW", "Traditional Chinese - 繁體中文"],
|
||||
["ar", "Arabic - العربية"],
|
||||
["bg", "Bulgarian - Български"],
|
||||
["ca", "Catalan - Català"],
|
||||
["hr", "Croatian - Hrvatski"],
|
||||
["cs", "Czech - Čeština"],
|
||||
["da", "Danish - Dansk"],
|
||||
["nl", "Dutch - Nederlands"],
|
||||
["fi", "Finnish - Suomi"],
|
||||
["fr", "French - Français"],
|
||||
["de", "German - Deutsch"],
|
||||
["el", "Greek - Ελληνικά"],
|
||||
["hi", "Hindi - हिन्दी"],
|
||||
["hu", "Hungarian - Magyar"],
|
||||
["id", "Indonesian - Indonesia"],
|
||||
["it", "Italian - Italiano"],
|
||||
["ja", "Japanese - 日本語"],
|
||||
["ko", "Korean - 한국어"],
|
||||
["ms", "Malay - Melayu"],
|
||||
["mt", "Maltese - Malti"],
|
||||
["nb", "Norwegian - Norsk Bokmål"],
|
||||
["pl", "Polish - Polski"],
|
||||
["pt", "Portuguese - Português"],
|
||||
["ro", "Romanian - Română"],
|
||||
["ru", "Russian - Русский"],
|
||||
["sk", "Slovak - Slovenčina"],
|
||||
["sl", "Slovenian - Slovenščina"],
|
||||
["es", "Spanish - Español"],
|
||||
["sv", "Swedish - Svenska"],
|
||||
["ta", "Tamil - தமிழ்"],
|
||||
["te", "Telugu - తెలుగు"],
|
||||
["th", "Thai - ไทย"],
|
||||
["tr", "Turkish - Türkçe"],
|
||||
["uk", "Ukrainian - Українська"],
|
||||
["vi", "Vietnamese - Tiếng Việt"],
|
||||
];
|
||||
export const OPT_LANGS_LIST = OPT_LANGS_TO.map(([lang]) => lang);
|
||||
export const OPT_LANGS_FROM = [["auto", "Auto-detect"], ...OPT_LANGS_TO];
|
||||
export const OPT_LANGS_MAP = new Map(OPT_LANGS_TO);
|
||||
|
||||
// CODE->名称
|
||||
export const OPT_LANGS_SPEC_NAME = new Map(
|
||||
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
|
||||
);
|
||||
export const OPT_LANGS_SPEC_DEFAULT = new Map(
|
||||
OPT_LANGS_FROM.map(([key]) => [key, key])
|
||||
);
|
||||
export const OPT_LANGS_SPEC_DEFAULT_UC = new Map(
|
||||
OPT_LANGS_FROM.map(([key]) => [key, key.toUpperCase()])
|
||||
);
|
||||
export const OPT_LANGS_TO_SPEC = {
|
||||
[OPT_TRANS_BUILTINAI]: new Map([
|
||||
...OPT_LANGS_SPEC_DEFAULT,
|
||||
["zh-CN", "zh"],
|
||||
["zh-TW", "zh"],
|
||||
]),
|
||||
[OPT_TRANS_GOOGLE]: OPT_LANGS_SPEC_DEFAULT,
|
||||
[OPT_TRANS_GOOGLE_2]: OPT_LANGS_SPEC_DEFAULT,
|
||||
[OPT_TRANS_MICROSOFT]: new Map([
|
||||
...OPT_LANGS_SPEC_DEFAULT,
|
||||
["auto", ""],
|
||||
["zh-CN", "zh-Hans"],
|
||||
["zh-TW", "zh-Hant"],
|
||||
]),
|
||||
[OPT_TRANS_AZUREAI]: new Map([
|
||||
...OPT_LANGS_SPEC_DEFAULT,
|
||||
["auto", ""],
|
||||
["zh-CN", "zh-Hans"],
|
||||
["zh-TW", "zh-Hant"],
|
||||
]),
|
||||
[OPT_TRANS_DEEPL]: new Map([
|
||||
...OPT_LANGS_SPEC_DEFAULT_UC,
|
||||
["auto", ""],
|
||||
["zh-CN", "ZH"],
|
||||
["zh-TW", "ZH"],
|
||||
]),
|
||||
[OPT_TRANS_DEEPLFREE]: new Map([
|
||||
...OPT_LANGS_SPEC_DEFAULT_UC,
|
||||
["auto", "auto"],
|
||||
["zh-CN", "ZH"],
|
||||
["zh-TW", "ZH"],
|
||||
]),
|
||||
[OPT_TRANS_DEEPLX]: new Map([
|
||||
...OPT_LANGS_SPEC_DEFAULT_UC,
|
||||
["auto", "auto"],
|
||||
["zh-CN", "ZH"],
|
||||
["zh-TW", "ZH"],
|
||||
]),
|
||||
[OPT_TRANS_NIUTRANS]: new Map([
|
||||
...OPT_LANGS_SPEC_DEFAULT,
|
||||
["auto", "auto"],
|
||||
["zh-CN", "zh"],
|
||||
["zh-TW", "cht"],
|
||||
]),
|
||||
[OPT_TRANS_VOLCENGINE]: new Map([
|
||||
...OPT_LANGS_SPEC_DEFAULT,
|
||||
["auto", "auto"],
|
||||
["zh-CN", "zh"],
|
||||
["zh-TW", "zh-Hant"],
|
||||
]),
|
||||
[OPT_TRANS_BAIDU]: new Map([
|
||||
...OPT_LANGS_SPEC_DEFAULT,
|
||||
["zh-CN", "zh"],
|
||||
["zh-TW", "cht"],
|
||||
["ar", "ara"],
|
||||
["bg", "bul"],
|
||||
["ca", "cat"],
|
||||
["hr", "hrv"],
|
||||
["da", "dan"],
|
||||
["fi", "fin"],
|
||||
["fr", "fra"],
|
||||
["hi", "mai"],
|
||||
["ja", "jp"],
|
||||
["ko", "kor"],
|
||||
["ms", "may"],
|
||||
["mt", "mlt"],
|
||||
["nb", "nor"],
|
||||
["ro", "rom"],
|
||||
["ru", "ru"],
|
||||
["sl", "slo"],
|
||||
["es", "spa"],
|
||||
["sv", "swe"],
|
||||
["ta", "tam"],
|
||||
["te", "tel"],
|
||||
["uk", "ukr"],
|
||||
["vi", "vie"],
|
||||
]),
|
||||
[OPT_TRANS_TENCENT]: new Map([
|
||||
["auto", "auto"],
|
||||
["zh-CN", "zh"],
|
||||
["zh-TW", "zh"],
|
||||
["en", "en"],
|
||||
["ar", "ar"],
|
||||
["de", "de"],
|
||||
["ru", "ru"],
|
||||
["fr", "fr"],
|
||||
["fi", "fil"],
|
||||
["ko", "ko"],
|
||||
["ms", "ms"],
|
||||
["pt", "pt"],
|
||||
["ja", "ja"],
|
||||
["th", "th"],
|
||||
["tr", "tr"],
|
||||
["es", "es"],
|
||||
["it", "it"],
|
||||
["hi", "hi"],
|
||||
["id", "id"],
|
||||
["vi", "vi"],
|
||||
]),
|
||||
[OPT_TRANS_OPENAI]: OPT_LANGS_SPEC_DEFAULT,
|
||||
[OPT_TRANS_GEMINI]: OPT_LANGS_SPEC_DEFAULT,
|
||||
[OPT_TRANS_GEMINI_2]: OPT_LANGS_SPEC_DEFAULT,
|
||||
[OPT_TRANS_CLAUDE]: OPT_LANGS_SPEC_DEFAULT,
|
||||
[OPT_TRANS_OLLAMA]: OPT_LANGS_SPEC_DEFAULT,
|
||||
[OPT_TRANS_OPENROUTER]: OPT_LANGS_SPEC_DEFAULT,
|
||||
[OPT_TRANS_CLOUDFLAREAI]: OPT_LANGS_SPEC_DEFAULT,
|
||||
[OPT_TRANS_CUSTOMIZE]: OPT_LANGS_SPEC_DEFAULT,
|
||||
};
|
||||
|
||||
const specToCode = (m) =>
|
||||
new Map(
|
||||
Array.from(m.entries()).map(([k, v]) => {
|
||||
if (v === "") {
|
||||
return ["auto", "auto"];
|
||||
}
|
||||
if (v === "zh" || v === "ZH") {
|
||||
return [v, "zh-CN"];
|
||||
}
|
||||
return [v, k];
|
||||
})
|
||||
);
|
||||
|
||||
// 名称->CODE
|
||||
export const OPT_LANGS_TO_CODE = {};
|
||||
Object.entries(OPT_LANGS_TO_SPEC).forEach(([t, m]) => {
|
||||
OPT_LANGS_TO_CODE[t] = specToCode(m);
|
||||
});
|
||||
|
||||
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 defaultSubtitlePrompt = `Goal: Convert raw subtitle event JSON into a clean, sentence-based JSON array.
|
||||
|
||||
// Output (valid JSON array, output ONLY this array):
|
||||
// [{
|
||||
// "text": "string", // Full sentence with correct punctuation
|
||||
// "translation": "string", // Translation in ${INPUT_PLACE_TO}
|
||||
// "start": int, // Start time (ms)
|
||||
// "end": int, // End time (ms)
|
||||
// }]
|
||||
|
||||
// Guidelines:
|
||||
// 1. **Segmentation**: Merge sequential 'utf8' strings from 'segs' into full sentences, merging groups logically.
|
||||
// 2. **Punctuation**: Ensure proper sentence-final punctuation (., ?, !); add if missing.
|
||||
// 3. **Translation**: Translate 'text' into ${INPUT_PLACE_TO}, place result in 'translation'.
|
||||
// 4. **Special Cases**: '[Music]' (and similar cues) are standalone entries. Translate appropriately (e.g., '[音乐]', '[Musique]').
|
||||
// `;
|
||||
|
||||
const defaultSubtitlePrompt = `You are an expert AI for subtitle generation. Convert a JSON array of word-level timestamps into a bilingual VTT file.
|
||||
|
||||
**Workflow:**
|
||||
1. Merge \`text\` fields into complete sentences; ignore empty text.
|
||||
2. Split long sentences into smaller, manageable subtitle cues (one sentence per cue).
|
||||
3. Translate each cue into ${INPUT_PLACE_TO}.
|
||||
4. Format as VTT:
|
||||
- Start with \`WEBVTT\`.
|
||||
- Each cue: timestamps (\`start --> end\` in milliseconds), original text, translated text.
|
||||
- Keep non-speech text (e.g., \`[Music]\`) untranslated.
|
||||
- Separate cues with a blank line.
|
||||
|
||||
**Output:** Only the pure VTT content.
|
||||
|
||||
**Example:**
|
||||
\`\`\`vtt
|
||||
WEBVTT
|
||||
|
||||
1000 --> 3500
|
||||
Hello world!
|
||||
你好,世界!
|
||||
|
||||
4000 --> 6000
|
||||
Good morning.
|
||||
早上好。
|
||||
\`\`\``;
|
||||
|
||||
const defaultRequestHook = `async (args, { url, body, headers, userMsg, method } = {}) => {
|
||||
console.log("request hook args:", args);
|
||||
// return { url, body, headers, userMsg, method };
|
||||
}`;
|
||||
|
||||
const defaultResponseHook = `async ({ res, ...args }) => {
|
||||
console.log("reaponse hook args:", res, args);
|
||||
// const translations = [["你好", "zh"]];
|
||||
// const modelMsg = "";
|
||||
// return { translations, modelMsg };
|
||||
}`;
|
||||
|
||||
// 翻译接口默认参数
|
||||
const defaultApi = {
|
||||
apiSlug: "", // 唯一标识
|
||||
apiName: "", // 接口名称
|
||||
apiType: "", // 接口类型
|
||||
url: "",
|
||||
key: "",
|
||||
model: "", // 模型名称
|
||||
systemPrompt: defaultSystemPrompt,
|
||||
subtitlePrompt: defaultSubtitlePrompt,
|
||||
userPrompt: "",
|
||||
tone: BUILTIN_STONES[0], // 翻译风格
|
||||
placeholder: BUILTIN_PLACEHOLDERS[0], // 占位符
|
||||
placetag: [BUILTIN_PLACETAGS[0]], // 占位标签
|
||||
// aiTerms: false, // AI智能专业术语 (todo: 备用)
|
||||
customHeader: "",
|
||||
customBody: "",
|
||||
reqHook: "", // request 钩子函数
|
||||
resHook: "", // response 钩子函数
|
||||
fetchLimit: DEFAULT_FETCH_LIMIT, // 最大请求数量
|
||||
fetchInterval: DEFAULT_FETCH_INTERVAL, // 请求间隔时间
|
||||
httpTimeout: DEFAULT_HTTP_TIMEOUT * 30, // 请求超时时间
|
||||
batchInterval: DEFAULT_BATCH_INTERVAL, // 批处理请求间隔时间
|
||||
batchSize: DEFAULT_BATCH_SIZE, // 每次最多发送段落数量
|
||||
batchLength: DEFAULT_BATCH_LENGTH, // 每次发送最大文字数量
|
||||
useBatchFetch: false, // 是否启用聚合发送请求
|
||||
useContext: false, // 是否启用智能上下文
|
||||
contextSize: DEFAULT_CONTEXT_SIZE, // 智能上下文保留会话数
|
||||
temperature: 0.0,
|
||||
maxTokens: 20480,
|
||||
think: false,
|
||||
thinkIgnore: "qwen3,deepseek-r1",
|
||||
isDisabled: false, // 是否不显示,
|
||||
region: "", // Azure 专用
|
||||
};
|
||||
|
||||
const defaultApiOpts = {
|
||||
[OPT_TRANS_BUILTINAI]: defaultApi,
|
||||
[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_AZUREAI]: {
|
||||
...defaultApi,
|
||||
url: "https://api.cognitive.microsofttranslator.com/translate?api-version=3.0",
|
||||
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: defaultRequestHook,
|
||||
resHook: defaultResponseHook,
|
||||
},
|
||||
};
|
||||
|
||||
// 内置翻译接口列表(带参数)
|
||||
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];
|
||||
@@ -2,3 +2,12 @@ export const APP_NAME = process.env.REACT_APP_NAME.trim()
|
||||
.split(/\s+/)
|
||||
.join("-");
|
||||
export const APP_LCNAME = APP_NAME.toLowerCase();
|
||||
export const APP_CONSTS = {
|
||||
fabID: `${APP_LCNAME}-fab`,
|
||||
boxID: `${APP_LCNAME}-box`,
|
||||
};
|
||||
|
||||
export const APP_VERSION = process.env.REACT_APP_VERSION.split(".");
|
||||
|
||||
export const THEME_LIGHT = "light";
|
||||
export const THEME_DARK = "dark";
|
||||
|
||||
15
src/config/client.js
Normal file
15
src/config/client.js
Normal file
@@ -0,0 +1,15 @@
|
||||
export const CLIENT_WEB = "web";
|
||||
export const CLIENT_CHROME = "chrome";
|
||||
export const CLIENT_EDGE = "edge";
|
||||
export const CLIENT_FIREFOX = "firefox";
|
||||
export const CLIENT_USERSCRIPT = "userscript";
|
||||
export const CLIENT_THUNDERBIRD = "thunderbird";
|
||||
export const CLIENT_EXTS = [
|
||||
CLIENT_CHROME,
|
||||
CLIENT_EDGE,
|
||||
CLIENT_FIREFOX,
|
||||
CLIENT_THUNDERBIRD,
|
||||
];
|
||||
|
||||
export const DEFAULT_USER_AGENT =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,533 +1,9 @@
|
||||
import {
|
||||
DEFAULT_SELECTOR,
|
||||
DEFAULT_KEEP_SELECTOR,
|
||||
GLOBAL_KEY,
|
||||
REMAIN_KEY,
|
||||
SHADOW_KEY,
|
||||
DEFAULT_RULE,
|
||||
DEFAULT_OW_RULE,
|
||||
BUILTIN_RULES,
|
||||
} from "./rules";
|
||||
import { APP_NAME, APP_LCNAME } from "./app";
|
||||
export { I18N, UI_LANGS } from "./i18n";
|
||||
export {
|
||||
GLOBAL_KEY,
|
||||
REMAIN_KEY,
|
||||
SHADOW_KEY,
|
||||
DEFAULT_RULE,
|
||||
DEFAULT_OW_RULE,
|
||||
BUILTIN_RULES,
|
||||
APP_LCNAME,
|
||||
};
|
||||
|
||||
export const STOKEY_MSAUTH = `${APP_NAME}_msauth`;
|
||||
export const STOKEY_BDAUTH = `${APP_NAME}_bdauth`;
|
||||
export const STOKEY_SETTING = `${APP_NAME}_setting`;
|
||||
export const STOKEY_RULES = `${APP_NAME}_rules`;
|
||||
export const STOKEY_WORDS = `${APP_NAME}_words`;
|
||||
export const STOKEY_SYNC = `${APP_NAME}_sync`;
|
||||
export const STOKEY_FAB = `${APP_NAME}_fab`;
|
||||
export const STOKEY_RULESCACHE_PREFIX = `${APP_NAME}_rulescache_`;
|
||||
|
||||
export const CMD_TOGGLE_TRANSLATE = "toggleTranslate";
|
||||
export const CMD_TOGGLE_STYLE = "toggleStyle";
|
||||
export const CMD_OPEN_OPTIONS = "openOptions";
|
||||
export const CMD_OPEN_TRANBOX = "openTranbox";
|
||||
|
||||
export const CLIENT_WEB = "web";
|
||||
export const CLIENT_CHROME = "chrome";
|
||||
export const CLIENT_EDGE = "edge";
|
||||
export const CLIENT_FIREFOX = "firefox";
|
||||
export const CLIENT_USERSCRIPT = "userscript";
|
||||
export const CLIENT_EXTS = [CLIENT_CHROME, CLIENT_EDGE, CLIENT_FIREFOX];
|
||||
|
||||
export const KV_RULES_KEY = "kiss-rules.json";
|
||||
export const KV_WORDS_KEY = "kiss-words.json";
|
||||
export const KV_RULES_SHARE_KEY = "kiss-rules-share.json";
|
||||
export const KV_SETTING_KEY = "kiss-setting.json";
|
||||
export const KV_SALT_SYNC = "KISS-Translator-SYNC";
|
||||
export const KV_SALT_SHARE = "KISS-Translator-SHARE";
|
||||
|
||||
export const CACHE_NAME = `${APP_NAME}_cache`;
|
||||
|
||||
export const MSG_FETCH = "fetch";
|
||||
export const MSG_FETCH_LIMIT = "fetch_limit";
|
||||
export const MSG_FETCH_CLEAR = "fetch_clear";
|
||||
export const MSG_OPEN_OPTIONS = "open_options";
|
||||
export const MSG_SAVE_RULE = "save_rule";
|
||||
export const MSG_TRANS_TOGGLE = "trans_toggle";
|
||||
export const MSG_TRANS_TOGGLE_STYLE = "trans_toggle_style";
|
||||
export const MSG_OPEN_TRANBOX = "open_tranbox";
|
||||
export const MSG_TRANS_GETRULE = "trans_getrule";
|
||||
export const MSG_TRANS_PUTRULE = "trans_putrule";
|
||||
export const MSG_TRANS_CURRULE = "trans_currule";
|
||||
export const MSG_CONTEXT_MENUS = "context_menus";
|
||||
export const MSG_COMMAND_SHORTCUTS = "command_shortcuts";
|
||||
export const MSG_INJECT_JS = "inject_js";
|
||||
export const MSG_INJECT_CSS = "inject_css";
|
||||
|
||||
export const THEME_LIGHT = "light";
|
||||
export const THEME_DARK = "dark";
|
||||
|
||||
export const URL_KISS_WORKER = "https://github.com/fishjar/kiss-worker";
|
||||
export const URL_KISS_PROXY = "https://github.com/fishjar/kiss-proxy";
|
||||
export const URL_KISS_RULES = "https://github.com/fishjar/kiss-rules";
|
||||
export const URL_KISS_RULES_NEW_ISSUE =
|
||||
"https://github.com/fishjar/kiss-rules/issues/new";
|
||||
export const URL_RAW_PREFIX =
|
||||
"https://raw.githubusercontent.com/fishjar/kiss-translator/master";
|
||||
|
||||
export const URL_CACHE_TRAN = `https://${APP_LCNAME}/translate`;
|
||||
export const URL_MICROSOFT_TRAN =
|
||||
"https://api-edge.cognitive.microsofttranslator.com/translate";
|
||||
export const URL_MICROSOFT_AUTH = "https://edge.microsoft.com/translate/auth";
|
||||
export const URL_BAIDU_LANGDETECT = "https://fanyi.baidu.com/langdetect";
|
||||
export const URL_BAIDU_SUGGEST = "https://fanyi.baidu.com/sug";
|
||||
export const URL_BAIDU_WEB = "https://fanyi.baidu.com/";
|
||||
export const URL_BAIDU_TRANSAPI = "https://fanyi.baidu.com/transapi";
|
||||
export const URL_BAIDU_TRANSAPI_V2 = "https://fanyi.baidu.com/v2transapi";
|
||||
export const URL_DEEPLFREE_TRAN = "https://www2.deepl.com/jsonrpc";
|
||||
export const URL_TENCENT_TRANSMART = "https://transmart.qq.com/api/imt";
|
||||
|
||||
export const OPT_TRANS_GOOGLE = "Google";
|
||||
export const OPT_TRANS_MICROSOFT = "Microsoft";
|
||||
export const OPT_TRANS_DEEPL = "DeepL";
|
||||
export const OPT_TRANS_DEEPLX = "DeepLX";
|
||||
export const OPT_TRANS_DEEPLFREE = "DeepLFree";
|
||||
export const OPT_TRANS_BAIDU = "Baidu";
|
||||
export const OPT_TRANS_TENCENT = "Tencent";
|
||||
export const OPT_TRANS_OPENAI = "OpenAI";
|
||||
export const OPT_TRANS_GEMINI = "Gemini";
|
||||
export const OPT_TRANS_CLOUDFLAREAI = "CloudflareAI";
|
||||
export const OPT_TRANS_CUSTOMIZE = "Custom";
|
||||
export const OPT_TRANS_ALL = [
|
||||
OPT_TRANS_GOOGLE,
|
||||
OPT_TRANS_MICROSOFT,
|
||||
OPT_TRANS_BAIDU,
|
||||
OPT_TRANS_TENCENT,
|
||||
OPT_TRANS_DEEPL,
|
||||
OPT_TRANS_DEEPLFREE,
|
||||
OPT_TRANS_DEEPLX,
|
||||
OPT_TRANS_OPENAI,
|
||||
OPT_TRANS_GEMINI,
|
||||
OPT_TRANS_CLOUDFLAREAI,
|
||||
OPT_TRANS_CUSTOMIZE,
|
||||
];
|
||||
|
||||
export const OPT_LANGS_TO = [
|
||||
["en", "English - English"],
|
||||
["zh-CN", "Simplified Chinese - 简体中文"],
|
||||
["zh-TW", "Traditional Chinese - 繁體中文"],
|
||||
["ar", "Arabic - العربية"],
|
||||
["bg", "Bulgarian - Български"],
|
||||
["ca", "Catalan - Català"],
|
||||
["hr", "Croatian - Hrvatski"],
|
||||
["cs", "Czech - Čeština"],
|
||||
["da", "Danish - Dansk"],
|
||||
["nl", "Dutch - Nederlands"],
|
||||
["fi", "Finnish - Suomi"],
|
||||
["fr", "French - Français"],
|
||||
["de", "German - Deutsch"],
|
||||
["el", "Greek - Ελληνικά"],
|
||||
["hi", "Hindi - हिन्दी"],
|
||||
["hu", "Hungarian - Magyar"],
|
||||
["id", "Indonesian - Indonesia"],
|
||||
["it", "Italian - Italiano"],
|
||||
["ja", "Japanese - 日本語"],
|
||||
["ko", "Korean - 한국어"],
|
||||
["ms", "Malay - Melayu"],
|
||||
["mt", "Maltese - Malti"],
|
||||
["nb", "Norwegian - Norsk Bokmål"],
|
||||
["pl", "Polish - Polski"],
|
||||
["pt", "Portuguese - Português"],
|
||||
["ro", "Romanian - Română"],
|
||||
["ru", "Russian - Русский"],
|
||||
["sk", "Slovak - Slovenčina"],
|
||||
["sl", "Slovenian - Slovenščina"],
|
||||
["es", "Spanish - Español"],
|
||||
["sv", "Swedish - Svenska"],
|
||||
["ta", "Tamil - தமிழ்"],
|
||||
["te", "Telugu - తెలుగు"],
|
||||
["th", "Thai - ไทย"],
|
||||
["tr", "Turkish - Türkçe"],
|
||||
["uk", "Ukrainian - Українська"],
|
||||
["vi", "Vietnamese - Tiếng Việt"],
|
||||
];
|
||||
export const OPT_LANGS_FROM = [["auto", "Auto-detect"], ...OPT_LANGS_TO];
|
||||
export const OPT_LANGS_SPECIAL = {
|
||||
[OPT_TRANS_GOOGLE]: new Map(OPT_LANGS_FROM.map(([key]) => [key, key])),
|
||||
[OPT_TRANS_MICROSOFT]: new Map([
|
||||
...OPT_LANGS_FROM.map(([key]) => [key, key]),
|
||||
["auto", ""],
|
||||
["zh-CN", "zh-Hans"],
|
||||
["zh-TW", "zh-Hant"],
|
||||
]),
|
||||
[OPT_TRANS_DEEPL]: new Map([
|
||||
...OPT_LANGS_FROM.map(([key]) => [key, key.toUpperCase()]),
|
||||
["auto", ""],
|
||||
["zh-CN", "ZH"],
|
||||
["zh-TW", "ZH"],
|
||||
]),
|
||||
[OPT_TRANS_DEEPLFREE]: new Map([
|
||||
...OPT_LANGS_FROM.map(([key]) => [key, key.toUpperCase()]),
|
||||
["auto", "auto"],
|
||||
["zh-CN", "ZH"],
|
||||
["zh-TW", "ZH"],
|
||||
]),
|
||||
[OPT_TRANS_DEEPLX]: new Map([
|
||||
...OPT_LANGS_FROM.map(([key]) => [key, key.toUpperCase()]),
|
||||
["auto", ""],
|
||||
["zh-CN", "ZH"],
|
||||
["zh-TW", "ZH"],
|
||||
]),
|
||||
[OPT_TRANS_BAIDU]: new Map([
|
||||
...OPT_LANGS_FROM.map(([key]) => [key, key]),
|
||||
["zh-CN", "zh"],
|
||||
["zh-TW", "cht"],
|
||||
["ar", "ara"],
|
||||
["bg", "bul"],
|
||||
["ca", "cat"],
|
||||
["hr", "hrv"],
|
||||
["da", "dan"],
|
||||
["fi", "fin"],
|
||||
["fr", "fra"],
|
||||
["hi", "mai"],
|
||||
["ja", "jp"],
|
||||
["ko", "kor"],
|
||||
["ms", "may"],
|
||||
["mt", "mlt"],
|
||||
["nb", "nor"],
|
||||
["ro", "rom"],
|
||||
["ru", "ru"],
|
||||
["sl", "slo"],
|
||||
["es", "spa"],
|
||||
["sv", "swe"],
|
||||
["ta", "tam"],
|
||||
["te", "tel"],
|
||||
["uk", "ukr"],
|
||||
["vi", "vie"],
|
||||
]),
|
||||
[OPT_TRANS_TENCENT]: new Map([
|
||||
["auto", "auto"],
|
||||
["zh-CN", "zh"],
|
||||
["zh-TW", "zh"],
|
||||
["en", "en"],
|
||||
["ar", "ar"],
|
||||
["de", "de"],
|
||||
["ru", "ru"],
|
||||
["fr", "fr"],
|
||||
["fi", "fil"],
|
||||
["ko", "ko"],
|
||||
["ms", "ms"],
|
||||
["pt", "pt"],
|
||||
["ja", "ja"],
|
||||
["th", "th"],
|
||||
["tr", "tr"],
|
||||
["es", "es"],
|
||||
["it", "it"],
|
||||
["hi", "hi"],
|
||||
["id", "id"],
|
||||
["vi", "vi"],
|
||||
]),
|
||||
[OPT_TRANS_OPENAI]: 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]])
|
||||
),
|
||||
[OPT_TRANS_CLOUDFLAREAI]: new Map([
|
||||
["auto", ""],
|
||||
["zh-CN", "chinese"],
|
||||
["zh-TW", "chinese"],
|
||||
["en", "english"],
|
||||
["ar", "arabic"],
|
||||
["de", "german"],
|
||||
["ru", "russian"],
|
||||
["fr", "french"],
|
||||
["pt", "portuguese"],
|
||||
["ja", "japanese"],
|
||||
["es", "spanish"],
|
||||
["hi", "hindi"],
|
||||
]),
|
||||
[OPT_TRANS_CUSTOMIZE]: new Map([
|
||||
...OPT_LANGS_FROM.map(([key]) => [key, key]),
|
||||
["auto", ""],
|
||||
]),
|
||||
};
|
||||
export const OPT_LANGS_LIST = OPT_LANGS_TO.map(([lang]) => lang);
|
||||
export const OPT_LANGS_BAIDU = new Map(
|
||||
Array.from(OPT_LANGS_SPECIAL[OPT_TRANS_BAIDU].entries()).map(([k, v]) => [
|
||||
v,
|
||||
k,
|
||||
])
|
||||
);
|
||||
export const OPT_LANGS_TENCENT = new Map(
|
||||
Array.from(OPT_LANGS_SPECIAL[OPT_TRANS_TENCENT].entries()).map(([k, v]) => [
|
||||
v,
|
||||
k,
|
||||
])
|
||||
);
|
||||
OPT_LANGS_TENCENT.set("zh", "zh-CN");
|
||||
|
||||
export const OPT_STYLE_NONE = "style_none"; // 无
|
||||
export const OPT_STYLE_LINE = "under_line"; // 下划线
|
||||
export const OPT_STYLE_DOTLINE = "dot_line"; // 点状线
|
||||
export const OPT_STYLE_DASHLINE = "dash_line"; // 虚线
|
||||
export const OPT_STYLE_WAVYLINE = "wavy_line"; // 波浪线
|
||||
export const OPT_STYLE_FUZZY = "fuzzy"; // 模糊
|
||||
export const OPT_STYLE_HIGHLIGHT = "highlight"; // 高亮
|
||||
export const OPT_STYLE_BLOCKQUOTE = "blockquote"; // 引用
|
||||
export const OPT_STYLE_DIY = "diy_style"; // 自定义样式
|
||||
export const OPT_STYLE_ALL = [
|
||||
OPT_STYLE_NONE,
|
||||
OPT_STYLE_LINE,
|
||||
OPT_STYLE_DOTLINE,
|
||||
OPT_STYLE_DASHLINE,
|
||||
OPT_STYLE_WAVYLINE,
|
||||
OPT_STYLE_FUZZY,
|
||||
OPT_STYLE_HIGHLIGHT,
|
||||
OPT_STYLE_BLOCKQUOTE,
|
||||
OPT_STYLE_DIY,
|
||||
];
|
||||
export const OPT_STYLE_USE_COLOR = [
|
||||
OPT_STYLE_LINE,
|
||||
OPT_STYLE_DOTLINE,
|
||||
OPT_STYLE_DASHLINE,
|
||||
OPT_STYLE_WAVYLINE,
|
||||
OPT_STYLE_HIGHLIGHT,
|
||||
OPT_STYLE_BLOCKQUOTE,
|
||||
];
|
||||
|
||||
export const OPT_TIMING_PAGESCROLL = "mk_pagescroll"; // 滚动加载翻译
|
||||
export const OPT_TIMING_PAGEOPEN = "mk_pageopen"; // 直接翻译到底
|
||||
export const OPT_TIMING_MOUSEOVER = "mk_mouseover";
|
||||
export const OPT_TIMING_CONTROL = "mk_ctrlKey";
|
||||
export const OPT_TIMING_SHIFT = "mk_shiftKey";
|
||||
export const OPT_TIMING_ALT = "mk_altKey";
|
||||
export const OPT_TIMING_ALL = [
|
||||
OPT_TIMING_PAGESCROLL,
|
||||
OPT_TIMING_PAGEOPEN,
|
||||
OPT_TIMING_MOUSEOVER,
|
||||
OPT_TIMING_CONTROL,
|
||||
OPT_TIMING_SHIFT,
|
||||
OPT_TIMING_ALT,
|
||||
];
|
||||
|
||||
export const DEFAULT_FETCH_LIMIT = 10; // 默认最大任务数量
|
||||
export const DEFAULT_FETCH_INTERVAL = 100; // 默认任务间隔时间
|
||||
|
||||
export const PROMPT_PLACE_FROM = "{{from}}"; // 占位符
|
||||
export const PROMPT_PLACE_TO = "{{to}}"; // 占位符
|
||||
export const PROMPT_PLACE_TEXT = "{{text}}"; // 占位符
|
||||
|
||||
export const DEFAULT_COLOR = "#209CEE"; // 默认高亮背景色/线条颜色
|
||||
|
||||
export const DEFAULT_TRANS_TAG = "span";
|
||||
export const DEFAULT_SELECT_STYLE =
|
||||
"-webkit-line-clamp: unset; max-height: none; height: auto;";
|
||||
|
||||
// 全局规则
|
||||
export const GLOBLA_RULE = {
|
||||
pattern: "*", // 匹配网址
|
||||
selector: DEFAULT_SELECTOR, // 选择器
|
||||
keepSelector: DEFAULT_KEEP_SELECTOR, // 保留元素选择器
|
||||
terms: "", // 专业术语
|
||||
translator: OPT_TRANS_MICROSOFT, // 翻译服务
|
||||
fromLang: "auto", // 源语言
|
||||
toLang: "zh-CN", // 目标语言
|
||||
textStyle: OPT_STYLE_DASHLINE, // 译文样式
|
||||
transOpen: "false", // 开启翻译
|
||||
bgColor: "", // 译文颜色
|
||||
textDiyStyle: "", // 自定义译文样式
|
||||
selectStyle: DEFAULT_SELECT_STYLE, // 选择器节点样式
|
||||
parentStyle: DEFAULT_SELECT_STYLE, // 选择器父节点样式
|
||||
injectJs: "", // 注入JS
|
||||
injectCss: "", // 注入CSS
|
||||
transOnly: "false", // 是否仅显示译文
|
||||
transTiming: OPT_TIMING_PAGESCROLL, // 翻译时机/鼠标悬停翻译
|
||||
transTag: DEFAULT_TRANS_TAG, // 译文元素标签
|
||||
transTitle: "false", // 是否同时翻译页面标题
|
||||
detectRemote: "false", // 是否使用远程语言检测
|
||||
skipLangs: [], // 不翻译的语言
|
||||
fixerSelector: "", // 修复函数选择器
|
||||
fixerFunc: "-", // 修复函数
|
||||
};
|
||||
|
||||
// 输入框翻译
|
||||
export const OPT_INPUT_TRANS_SIGNS = ["/", "//", "\\", "\\\\", ">", ">>"];
|
||||
export const DEFAULT_INPUT_SHORTCUT = ["AltLeft", "KeyI"];
|
||||
export const DEFAULT_INPUT_RULE = {
|
||||
transOpen: true,
|
||||
translator: OPT_TRANS_MICROSOFT,
|
||||
fromLang: "auto",
|
||||
toLang: "en",
|
||||
triggerShortcut: DEFAULT_INPUT_SHORTCUT,
|
||||
triggerCount: 1,
|
||||
triggerTime: 200,
|
||||
transSign: OPT_INPUT_TRANS_SIGNS[0],
|
||||
};
|
||||
|
||||
// 划词翻译
|
||||
export const DEFAULT_TRANBOX_SHORTCUT = ["AltLeft", "KeyS"];
|
||||
export const DEFAULT_TRANBOX_SETTING = {
|
||||
transOpen: true,
|
||||
translator: OPT_TRANS_MICROSOFT,
|
||||
fromLang: "auto",
|
||||
toLang: "zh-CN",
|
||||
toLang2: "en",
|
||||
tranboxShortcut: DEFAULT_TRANBOX_SHORTCUT,
|
||||
btnOffsetX: 10,
|
||||
btnOffsetY: 10,
|
||||
hideTranBtn: false,
|
||||
};
|
||||
|
||||
// 订阅列表
|
||||
export const DEFAULT_SUBRULES_LIST = [
|
||||
{
|
||||
url: process.env.REACT_APP_RULESURL,
|
||||
selected: false,
|
||||
},
|
||||
{
|
||||
url: process.env.REACT_APP_RULESURL_ON,
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
url: process.env.REACT_APP_RULESURL_OFF,
|
||||
selected: false,
|
||||
},
|
||||
];
|
||||
|
||||
// 翻译接口
|
||||
export const DEFAULT_TRANS_APIS = {
|
||||
[OPT_TRANS_GOOGLE]: {
|
||||
url: "https://translate.googleapis.com/translate_a/single",
|
||||
key: "",
|
||||
fetchLimit: DEFAULT_FETCH_LIMIT, // 最大任务数量
|
||||
fetchInterval: DEFAULT_FETCH_INTERVAL, // 任务间隔时间
|
||||
},
|
||||
[OPT_TRANS_MICROSOFT]: {
|
||||
fetchLimit: DEFAULT_FETCH_LIMIT,
|
||||
fetchInterval: DEFAULT_FETCH_INTERVAL,
|
||||
},
|
||||
[OPT_TRANS_BAIDU]: {
|
||||
fetchLimit: DEFAULT_FETCH_LIMIT,
|
||||
fetchInterval: DEFAULT_FETCH_INTERVAL,
|
||||
},
|
||||
[OPT_TRANS_TENCENT]: {
|
||||
fetchLimit: DEFAULT_FETCH_LIMIT,
|
||||
fetchInterval: DEFAULT_FETCH_INTERVAL,
|
||||
},
|
||||
[OPT_TRANS_DEEPL]: {
|
||||
url: "https://api-free.deepl.com/v2/translate",
|
||||
key: "",
|
||||
fetchLimit: 1,
|
||||
fetchInterval: 500,
|
||||
},
|
||||
[OPT_TRANS_DEEPLFREE]: {
|
||||
fetchLimit: 1,
|
||||
fetchInterval: 500,
|
||||
},
|
||||
[OPT_TRANS_DEEPLX]: {
|
||||
url: "http://localhost:1188/translate",
|
||||
key: "",
|
||||
fetchLimit: 1,
|
||||
fetchInterval: 500,
|
||||
},
|
||||
[OPT_TRANS_OPENAI]: {
|
||||
url: "https://api.openai.com/v1/chat/completions",
|
||||
key: "",
|
||||
model: "gpt-4",
|
||||
prompt: `You will be provided with a sentence in ${PROMPT_PLACE_FROM}, and your task is to translate it into ${PROMPT_PLACE_TO}.`,
|
||||
fetchLimit: 1,
|
||||
fetchInterval: 500,
|
||||
},
|
||||
[OPT_TRANS_GEMINI]: {
|
||||
url: "https://generativelanguage.googleapis.com/v1/models",
|
||||
key: "",
|
||||
model: "gemini-pro",
|
||||
prompt: `Translate the following text from ${PROMPT_PLACE_FROM} to ${PROMPT_PLACE_TO}:\n\n${PROMPT_PLACE_TEXT}`,
|
||||
fetchLimit: 1,
|
||||
fetchInterval: 500,
|
||||
},
|
||||
[OPT_TRANS_CLOUDFLAREAI]: {
|
||||
url: "https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/ai/run/@cf/meta/m2m100-1.2b",
|
||||
key: "",
|
||||
fetchLimit: 1,
|
||||
fetchInterval: 500,
|
||||
},
|
||||
[OPT_TRANS_CUSTOMIZE]: {
|
||||
url: "",
|
||||
key: "",
|
||||
fetchLimit: DEFAULT_FETCH_LIMIT,
|
||||
fetchInterval: DEFAULT_FETCH_INTERVAL,
|
||||
},
|
||||
};
|
||||
|
||||
// 默认快捷键
|
||||
export const OPT_SHORTCUT_TRANSLATE = "toggleTranslate";
|
||||
export const OPT_SHORTCUT_STYLE = "toggleStyle";
|
||||
export const OPT_SHORTCUT_POPUP = "togglePopup";
|
||||
export const OPT_SHORTCUT_SETTING = "openSetting";
|
||||
export const DEFAULT_SHORTCUTS = {
|
||||
[OPT_SHORTCUT_TRANSLATE]: ["AltLeft", "KeyQ"],
|
||||
[OPT_SHORTCUT_STYLE]: ["AltLeft", "KeyC"],
|
||||
[OPT_SHORTCUT_POPUP]: ["AltLeft", "KeyK"],
|
||||
[OPT_SHORTCUT_SETTING]: ["AltLeft", "KeyO"],
|
||||
};
|
||||
|
||||
export const TRANS_MIN_LENGTH = 5; // 最短翻译长度
|
||||
export const TRANS_MAX_LENGTH = 5000; // 最长翻译长度
|
||||
export const TRANS_NEWLINE_LENGTH = 20; // 换行字符数
|
||||
export const DEFAULT_BLACKLIST = [
|
||||
"https://fishjar.github.io/kiss-translator/options.html",
|
||||
"https://translate.google.com",
|
||||
"https://www.deepl.com/translator",
|
||||
"oapi.dingtalk.com",
|
||||
"login.dingtalk.com",
|
||||
]; // 禁用翻译名单
|
||||
|
||||
export const DEFAULT_SETTING = {
|
||||
darkMode: false, // 深色模式
|
||||
uiLang: "en", // 界面语言
|
||||
// fetchLimit: DEFAULT_FETCH_LIMIT, // 最大任务数量(移至transApis,作废)
|
||||
// fetchInterval: DEFAULT_FETCH_INTERVAL, // 任务间隔时间(移至transApis,作废)
|
||||
minLength: TRANS_MIN_LENGTH,
|
||||
maxLength: TRANS_MAX_LENGTH,
|
||||
newlineLength: TRANS_NEWLINE_LENGTH,
|
||||
clearCache: false, // 是否在浏览器下次启动时清除缓存
|
||||
injectRules: true, // 是否注入订阅规则
|
||||
// injectWebfix: true, // 是否注入修复补丁(作废)
|
||||
// detectRemote: false, // 是否使用远程语言检测(移至rule,作废)
|
||||
// contextMenus: true, // 是否添加右键菜单(作废)
|
||||
contextMenuType: 1, // 右键菜单类型(0不显示,1简单菜单,2多级菜单)
|
||||
// transTag: DEFAULT_TRANS_TAG, // 译文元素标签(移至rule,作废)
|
||||
// transOnly: false, // 是否仅显示译文(移至rule,作废)
|
||||
// transTitle: false, // 是否同时翻译页面标题(移至rule,作废)
|
||||
subrulesList: DEFAULT_SUBRULES_LIST, // 订阅列表
|
||||
owSubrule: DEFAULT_OW_RULE, // 覆写订阅规则
|
||||
transApis: DEFAULT_TRANS_APIS, // 翻译接口
|
||||
// mouseKey: OPT_TIMING_PAGESCROLL, // 翻译时机/鼠标悬停翻译(移至rule,作废)
|
||||
shortcuts: DEFAULT_SHORTCUTS, // 快捷键
|
||||
inputRule: DEFAULT_INPUT_RULE, // 输入框设置
|
||||
tranboxSetting: DEFAULT_TRANBOX_SETTING, // 划词翻译设置
|
||||
touchTranslate: 2, // 触屏翻译
|
||||
blacklist: DEFAULT_BLACKLIST.join(",\n"), // 禁用翻译名单
|
||||
// disableLangs: [], // 不翻译的语言(移至rule,作废)
|
||||
transInterval: 500, // 翻译间隔时间
|
||||
};
|
||||
|
||||
export const DEFAULT_RULES = [GLOBLA_RULE];
|
||||
|
||||
export const OPT_SYNCTYPE_WORKER = "KISS-Worker";
|
||||
export const OPT_SYNCTYPE_WEBDAV = "WebDAV";
|
||||
export const OPT_SYNCTYPE_ALL = [OPT_SYNCTYPE_WORKER, OPT_SYNCTYPE_WEBDAV];
|
||||
|
||||
export const DEFAULT_SYNC = {
|
||||
syncType: OPT_SYNCTYPE_WORKER, // 同步方式
|
||||
syncUrl: "", // 数据同步接口
|
||||
syncUser: "", // 数据同步用户名
|
||||
syncKey: "", // 数据同步密钥
|
||||
syncMeta: {}, // 数据更新及同步信息
|
||||
subRulesSyncAt: 0, // 订阅规则同步时间
|
||||
dataCaches: {}, // 缓存同步时间
|
||||
};
|
||||
export * from "./app";
|
||||
export * from "./rules";
|
||||
export * from "./api";
|
||||
export * from "./setting";
|
||||
export * from "./i18n";
|
||||
export * from "./storage";
|
||||
export * from "./url";
|
||||
export * from "./msg";
|
||||
export * from "./client";
|
||||
|
||||
32
src/config/msg.js
Normal file
32
src/config/msg.js
Normal file
@@ -0,0 +1,32 @@
|
||||
export const CMD_TOGGLE_TRANSLATE = "toggleTranslate";
|
||||
export const CMD_TOGGLE_STYLE = "toggleStyle";
|
||||
export const CMD_OPEN_OPTIONS = "openOptions";
|
||||
export const CMD_OPEN_TRANBOX = "openTranbox";
|
||||
|
||||
export const MSG_FETCH = "kiss_fetch";
|
||||
export const MSG_GET_HTTPCACHE = "get_httpcache";
|
||||
export const MSG_PUT_HTTPCACHE = "put_httpcache";
|
||||
export const MSG_OPEN_OPTIONS = "open_options";
|
||||
export const MSG_SAVE_RULE = "save_rule";
|
||||
export const MSG_TRANS_TOGGLE = "trans_toggle";
|
||||
export const MSG_TRANS_TOGGLE_STYLE = "trans_toggle_style";
|
||||
export const MSG_OPEN_TRANBOX = "open_tranbox";
|
||||
export const MSG_TRANS_GETRULE = "trans_getrule";
|
||||
export const MSG_TRANS_PUTRULE = "trans_putrule";
|
||||
export const MSG_TRANS_CURRULE = "trans_currule";
|
||||
export const MSG_TRANSBOX_TOGGLE = "transbox_toggle";
|
||||
export const MSG_MOUSEHOVER_TOGGLE = "mousehover_toggle";
|
||||
export const MSG_TRANSINPUT_TOGGLE = "transinput_toggle";
|
||||
export const MSG_CONTEXT_MENUS = "context_menus";
|
||||
export const MSG_COMMAND_SHORTCUTS = "command_shortcuts";
|
||||
export const MSG_INJECT_JS = "inject_js";
|
||||
export const MSG_INJECT_CSS = "inject_css";
|
||||
export const MSG_UPDATE_CSP = "update_csp";
|
||||
export const MSG_BUILTINAI_DETECT = "builtinai_detect";
|
||||
export const MSG_BUILTINAI_TRANSLATE = "builtinai_translte";
|
||||
export const MSG_SET_LOGLEVEL = "set_loglevel";
|
||||
export const MSG_CLEAR_CACHES = "clear_caches";
|
||||
|
||||
export const MSG_XHR_DATA_YOUTUBE = "KISS_XHR_DATA_YOUTUBE";
|
||||
// export const MSG_GLOBAL_VAR_FETCH = "KISS_GLOBAL_VAR_FETCH";
|
||||
// export const MSG_GLOBAL_VAR_BACK = "KISS_GLOBAL_VAR_BACK";
|
||||
@@ -1,38 +1,69 @@
|
||||
import { FIXER_BR, FIXER_BN, FIXER_BR_DIV, FIXER_BN_DIV } from "../libs/webfix";
|
||||
import { OPT_TRANS_MICROSOFT } from "./api";
|
||||
|
||||
export const GLOBAL_KEY = "*";
|
||||
export const REMAIN_KEY = "-";
|
||||
export const SHADOW_KEY = ">>>";
|
||||
|
||||
export const DEFAULT_SELECTOR = `:is(li, p, h1, h2, h3, h4, h5, h6, dd, blockquote)`;
|
||||
export const DEFAULT_KEEP_SELECTOR = `code, img, svg`;
|
||||
export const DEFAULT_RULE = {
|
||||
pattern: "", // 匹配网址
|
||||
selector: "", // 选择器
|
||||
keepSelector: "", // 保留元素选择器
|
||||
terms: "", // 专业术语
|
||||
translator: GLOBAL_KEY, // 翻译服务
|
||||
fromLang: GLOBAL_KEY, // 源语言
|
||||
toLang: GLOBAL_KEY, // 目标语言
|
||||
textStyle: GLOBAL_KEY, // 译文样式
|
||||
transOpen: GLOBAL_KEY, // 开启翻译
|
||||
bgColor: "", // 译文颜色
|
||||
textDiyStyle: "", // 自定义译文样式
|
||||
selectStyle: "", // 选择器节点样式
|
||||
parentStyle: "", // 选择器父节点样式
|
||||
injectJs: "", // 注入JS
|
||||
injectCss: "", // 注入CSS
|
||||
transOnly: GLOBAL_KEY, // 是否仅显示译文
|
||||
transTiming: GLOBAL_KEY, // 翻译时机/鼠标悬停翻译
|
||||
transTag: GLOBAL_KEY, // 译文元素标签
|
||||
transTitle: GLOBAL_KEY, // 是否同时翻译页面标题
|
||||
detectRemote: GLOBAL_KEY, // 是否使用远程语言检测
|
||||
skipLangs: [], // 不翻译的语言
|
||||
fixerSelector: "", // 修复函数选择器
|
||||
fixerFunc: GLOBAL_KEY, // 修复函数
|
||||
};
|
||||
export const DEFAULT_COLOR = "#209CEE"; // 默认高亮背景色/线条颜色
|
||||
|
||||
const DEFAULT_DIY_STYLE = `color: #666;
|
||||
export const DEFAULT_TRANS_TAG = "font";
|
||||
export const DEFAULT_SELECT_STYLE =
|
||||
"-webkit-line-clamp: unset; max-height: none; height: auto;";
|
||||
|
||||
export const OPT_STYLE_NONE = "style_none"; // 无
|
||||
export const OPT_STYLE_LINE = "under_line"; // 下划线
|
||||
export const OPT_STYLE_DOTLINE = "dot_line"; // 点状线
|
||||
export const OPT_STYLE_DASHLINE = "dash_line"; // 虚线
|
||||
export const OPT_STYLE_DASHBOX = "dash_box"; // 虚线框
|
||||
export const OPT_STYLE_WAVYLINE = "wavy_line"; // 波浪线
|
||||
export const OPT_STYLE_FUZZY = "fuzzy"; // 模糊
|
||||
export const OPT_STYLE_HIGHLIGHT = "highlight"; // 高亮
|
||||
export const OPT_STYLE_BLOCKQUOTE = "blockquote"; // 引用
|
||||
export const OPT_STYLE_GRADIENT = "gradient"; // 渐变
|
||||
export const OPT_STYLE_BLINK = "blink"; // 闪现
|
||||
export const OPT_STYLE_GLOW = "glow"; // 发光
|
||||
export const OPT_STYLE_DIY = "diy_style"; // 自定义样式
|
||||
export const OPT_STYLE_ALL = [
|
||||
OPT_STYLE_NONE,
|
||||
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_GRADIENT,
|
||||
OPT_STYLE_BLINK,
|
||||
OPT_STYLE_GLOW,
|
||||
OPT_STYLE_DIY,
|
||||
];
|
||||
export const OPT_STYLE_USE_COLOR = [
|
||||
OPT_STYLE_LINE,
|
||||
OPT_STYLE_DOTLINE,
|
||||
OPT_STYLE_DASHLINE,
|
||||
OPT_STYLE_DASHBOX,
|
||||
OPT_STYLE_WAVYLINE,
|
||||
OPT_STYLE_HIGHLIGHT,
|
||||
OPT_STYLE_BLOCKQUOTE,
|
||||
];
|
||||
|
||||
export const OPT_TIMING_PAGESCROLL = "mk_pagescroll"; // 滚动加载翻译
|
||||
export const OPT_TIMING_PAGEOPEN = "mk_pageopen"; // 直接翻译到底
|
||||
export const OPT_TIMING_MOUSEOVER = "mk_mouseover";
|
||||
export const OPT_TIMING_CONTROL = "mk_ctrlKey";
|
||||
export const OPT_TIMING_SHIFT = "mk_shiftKey";
|
||||
export const OPT_TIMING_ALT = "mk_altKey";
|
||||
export const OPT_TIMING_ALL = [
|
||||
OPT_TIMING_PAGESCROLL,
|
||||
OPT_TIMING_PAGEOPEN,
|
||||
OPT_TIMING_MOUSEOVER,
|
||||
OPT_TIMING_CONTROL,
|
||||
OPT_TIMING_SHIFT,
|
||||
OPT_TIMING_ALT,
|
||||
];
|
||||
|
||||
export const DEFAULT_DIY_STYLE = `color: #333;
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
LightGreen 20%,
|
||||
@@ -42,11 +73,93 @@ background: linear-gradient(
|
||||
LightSkyBlue 80%
|
||||
);
|
||||
&:hover {
|
||||
color: #333;
|
||||
color: #111;
|
||||
};`;
|
||||
|
||||
export const DEFAULT_SELECTOR =
|
||||
"h1, h2, h3, h4, h5, h6, li, p, dd, blockquote, figcaption, label, legend";
|
||||
export const DEFAULT_IGNORE_SELECTOR =
|
||||
"aside, button, footer, form, pre, mark, nav";
|
||||
export const DEFAULT_KEEP_SELECTOR = `a:has(code)`;
|
||||
export const DEFAULT_RULE = {
|
||||
pattern: "", // 匹配网址
|
||||
selector: "", // 选择器
|
||||
keepSelector: "", // 保留元素选择器
|
||||
terms: "", // 专业术语
|
||||
aiTerms: "", // AI专业术语
|
||||
apiSlug: GLOBAL_KEY, // 翻译服务
|
||||
fromLang: GLOBAL_KEY, // 源语言
|
||||
toLang: GLOBAL_KEY, // 目标语言
|
||||
textStyle: GLOBAL_KEY, // 译文样式
|
||||
transOpen: GLOBAL_KEY, // 开启翻译
|
||||
bgColor: "", // 译文颜色
|
||||
textDiyStyle: "", // 自定义译文样式
|
||||
selectStyle: "", // 选择器节点样式
|
||||
parentStyle: "", // 选择器父节点样式
|
||||
grandStyle: "", // 选择器父节点样式
|
||||
injectJs: "", // 注入JS
|
||||
injectCss: "", // 注入CSS
|
||||
transOnly: GLOBAL_KEY, // 是否仅显示译文
|
||||
// transTiming: GLOBAL_KEY, // 翻译时机/鼠标悬停翻译 (暂时作废)
|
||||
transTag: GLOBAL_KEY, // 译文元素标签
|
||||
transTitle: GLOBAL_KEY, // 是否同时翻译页面标题
|
||||
// transSelected: GLOBAL_KEY, // 是否启用划词翻译 (移回setting)
|
||||
// detectRemote: GLOBAL_KEY, // 是否使用远程语言检测 (移回setting)
|
||||
// skipLangs: [], // 不翻译的语言 (移回setting)
|
||||
// fixerSelector: "", // 修复函数选择器 (暂时作废)
|
||||
// fixerFunc: GLOBAL_KEY, // 修复函数 (暂时作废)
|
||||
transStartHook: "", // 钩子函数
|
||||
transEndHook: "", // 钩子函数
|
||||
// transRemoveHook: "", // 钩子函数 (暂时作废)
|
||||
autoScan: GLOBAL_KEY, // 是否自动识别文本节点
|
||||
hasRichText: GLOBAL_KEY, // 是否启用富文本翻译
|
||||
hasShadowroot: GLOBAL_KEY, // 是否包含shadowroot
|
||||
rootsSelector: "", // 翻译范围选择器
|
||||
ignoreSelector: "", // 不翻译的选择器
|
||||
};
|
||||
|
||||
// 全局规则
|
||||
export const GLOBLA_RULE = {
|
||||
pattern: "*", // 匹配网址
|
||||
selector: DEFAULT_SELECTOR, // 选择器
|
||||
keepSelector: DEFAULT_KEEP_SELECTOR, // 保留元素选择器
|
||||
terms: "", // 专业术语
|
||||
aiTerms: "", // AI专业术语
|
||||
apiSlug: OPT_TRANS_MICROSOFT, // 翻译服务
|
||||
fromLang: "auto", // 源语言
|
||||
toLang: "zh-CN", // 目标语言
|
||||
textStyle: OPT_STYLE_NONE, // 译文样式
|
||||
transOpen: "false", // 开启翻译
|
||||
bgColor: "", // 译文颜色
|
||||
textDiyStyle: DEFAULT_DIY_STYLE, // 自定义译文样式
|
||||
selectStyle: DEFAULT_SELECT_STYLE, // 选择器节点样式
|
||||
parentStyle: DEFAULT_SELECT_STYLE, // 选择器父节点样式
|
||||
grandStyle: DEFAULT_SELECT_STYLE, // 选择器祖节点样式
|
||||
injectJs: "", // 注入JS
|
||||
injectCss: "", // 注入CSS
|
||||
transOnly: "false", // 是否仅显示译文
|
||||
// transTiming: OPT_TIMING_PAGESCROLL, // 翻译时机/鼠标悬停翻译 (暂时作废)
|
||||
transTag: DEFAULT_TRANS_TAG, // 译文元素标签
|
||||
transTitle: "false", // 是否同时翻译页面标题
|
||||
// transSelected: "true", // 是否启用划词翻译 (移回setting)
|
||||
// detectRemote: "true", // 是否使用远程语言检测 (移回setting)
|
||||
// skipLangs: [], // 不翻译的语言 (移回setting)
|
||||
// fixerSelector: "", // 修复函数选择器 (暂时作废)
|
||||
// fixerFunc: "-", // 修复函数 (暂时作废)
|
||||
transStartHook: "", // 钩子函数
|
||||
transEndHook: "", // 钩子函数
|
||||
// transRemoveHook: "", // 钩子函数 (暂时作废)
|
||||
autoScan: "true", // 是否自动识别文本节点
|
||||
hasRichText: "true", // 是否启用富文本翻译
|
||||
hasShadowroot: "false", // 是否包含shadowroot
|
||||
rootsSelector: "body", // 翻译范围选择器
|
||||
ignoreSelector: DEFAULT_IGNORE_SELECTOR, // 不翻译的选择器
|
||||
};
|
||||
|
||||
export const DEFAULT_RULES = [GLOBLA_RULE];
|
||||
|
||||
export const DEFAULT_OW_RULE = {
|
||||
translator: REMAIN_KEY,
|
||||
apiSlug: REMAIN_KEY,
|
||||
fromLang: REMAIN_KEY,
|
||||
toLang: REMAIN_KEY,
|
||||
textStyle: REMAIN_KEY,
|
||||
@@ -55,264 +168,36 @@ 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,
|
||||
rootsSelector: `#rcnt`,
|
||||
},
|
||||
"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}`,
|
||||
ignoreSelector: `.button, code, footer, form, mark, pre, .mwe-math-element, .mw-editsection`,
|
||||
},
|
||||
"news.ycombinator.com": {
|
||||
selector: `.title, p`,
|
||||
fixerSelector: `.toptext, .commtext`,
|
||||
fixerFunc: FIXER_BR,
|
||||
selector: `p, .titleline, .commtext`,
|
||||
rootsSelector: `#bigbox`,
|
||||
keepSelector: `code, img, svg, pre, .sitebit`,
|
||||
ignoreSelector: `button, code, footer, form, header, mark, nav, pre, .reply`,
|
||||
autoScan: `false`,
|
||||
},
|
||||
"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": {
|
||||
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`,
|
||||
"twitter.com, https://x.com": {
|
||||
selector: `[data-testid='tweetText']`,
|
||||
keepSelector: `img, svg, span:has(a), div:has(a)`,
|
||||
autoScan: `false`,
|
||||
},
|
||||
"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,
|
||||
rootsSelector: `ytd-page-manager`,
|
||||
ignoreSelector: `aside, button, footer, form, header, pre, mark, nav, #player, #container, .caption-window, .ytp-settings-menu`,
|
||||
},
|
||||
};
|
||||
|
||||
export const BUILTIN_RULES = Object.entries(RULES_MAP)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([pattern, rule]) => ({
|
||||
...DEFAULT_RULE,
|
||||
// ...DEFAULT_RULE,
|
||||
...rule,
|
||||
pattern,
|
||||
}));
|
||||
|
||||
182
src/config/setting.js
Normal file
182
src/config/setting.js
Normal file
@@ -0,0 +1,182 @@
|
||||
import { LogLevel } from "../libs/log";
|
||||
import {
|
||||
OPT_DICT_BING,
|
||||
OPT_SUG_YOUDAO,
|
||||
DEFAULT_HTTP_TIMEOUT,
|
||||
OPT_TRANS_MICROSOFT,
|
||||
DEFAULT_API_LIST,
|
||||
} from "./api";
|
||||
|
||||
// 默认快捷键
|
||||
export const OPT_SHORTCUT_TRANSLATE = "toggleTranslate";
|
||||
export const OPT_SHORTCUT_STYLE = "toggleStyle";
|
||||
export const OPT_SHORTCUT_POPUP = "togglePopup";
|
||||
export const OPT_SHORTCUT_SETTING = "openSetting";
|
||||
export const DEFAULT_SHORTCUTS = {
|
||||
[OPT_SHORTCUT_TRANSLATE]: ["AltLeft", "KeyQ"],
|
||||
[OPT_SHORTCUT_STYLE]: ["AltLeft", "KeyC"],
|
||||
[OPT_SHORTCUT_POPUP]: ["AltLeft", "KeyK"],
|
||||
[OPT_SHORTCUT_SETTING]: ["AltLeft", "KeyO"],
|
||||
};
|
||||
|
||||
export const TRANS_MIN_LENGTH = 2; // 最短翻译长度
|
||||
export const TRANS_MAX_LENGTH = 100000; // 最长翻译长度
|
||||
export const TRANS_NEWLINE_LENGTH = 20; // 换行字符数
|
||||
export const DEFAULT_BLACKLIST = [
|
||||
"https://fishjar.github.io/kiss-translator/options.html",
|
||||
"https://translate.google.com",
|
||||
"https://www.deepl.com/translator",
|
||||
]; // 禁用翻译名单
|
||||
export const DEFAULT_CSPLIST = []; // 禁用CSP名单
|
||||
export const DEFAULT_ORILIST = ["https://dict.youdao.com"]; // 移除Origin名单
|
||||
|
||||
// 同步设置
|
||||
export const OPT_SYNCTYPE_WORKER = "KISS-Worker";
|
||||
export const OPT_SYNCTYPE_WEBDAV = "WebDAV";
|
||||
export const OPT_SYNCTOKEN_PERFIX = "kt_";
|
||||
export const OPT_SYNCTYPE_ALL = [OPT_SYNCTYPE_WORKER, OPT_SYNCTYPE_WEBDAV];
|
||||
export const DEFAULT_SYNC = {
|
||||
syncType: OPT_SYNCTYPE_WORKER, // 同步方式
|
||||
syncUrl: "", // 数据同步接口
|
||||
syncUser: "", // 数据同步用户名
|
||||
syncKey: "", // 数据同步密钥
|
||||
syncMeta: {}, // 数据更新及同步信息
|
||||
subRulesSyncAt: 0, // 订阅规则同步时间
|
||||
dataCaches: {}, // 缓存同步时间
|
||||
};
|
||||
|
||||
// 输入框翻译
|
||||
export const OPT_INPUT_TRANS_SIGNS = ["/", "//", "\\", "\\\\", ">", ">>"];
|
||||
export const DEFAULT_INPUT_SHORTCUT = ["AltLeft", "KeyI"];
|
||||
export const DEFAULT_INPUT_RULE = {
|
||||
transOpen: true,
|
||||
apiSlug: OPT_TRANS_MICROSOFT,
|
||||
fromLang: "auto",
|
||||
toLang: "en",
|
||||
triggerShortcut: DEFAULT_INPUT_SHORTCUT,
|
||||
triggerCount: 1,
|
||||
triggerTime: 200,
|
||||
transSign: OPT_INPUT_TRANS_SIGNS[0],
|
||||
};
|
||||
|
||||
// 划词翻译
|
||||
export const PHONIC_MAP = {
|
||||
en_phonic: ["英", "uk"],
|
||||
us_phonic: ["美", "en"],
|
||||
};
|
||||
export const OPT_TRANBOX_TRIGGER_CLICK = "click";
|
||||
export const OPT_TRANBOX_TRIGGER_HOVER = "hover";
|
||||
export const OPT_TRANBOX_TRIGGER_SELECT = "select";
|
||||
export const OPT_TRANBOX_TRIGGER_ALL = [
|
||||
OPT_TRANBOX_TRIGGER_CLICK,
|
||||
OPT_TRANBOX_TRIGGER_HOVER,
|
||||
OPT_TRANBOX_TRIGGER_SELECT,
|
||||
];
|
||||
export const DEFAULT_TRANBOX_SHORTCUT = ["AltLeft", "KeyS"];
|
||||
export const DEFAULT_TRANBOX_SETTING = {
|
||||
transOpen: true, // 是否启用划词翻译
|
||||
apiSlugs: [OPT_TRANS_MICROSOFT],
|
||||
fromLang: "auto",
|
||||
toLang: "zh-CN",
|
||||
toLang2: "en",
|
||||
tranboxShortcut: DEFAULT_TRANBOX_SHORTCUT,
|
||||
btnOffsetX: 10,
|
||||
btnOffsetY: 10,
|
||||
boxOffsetX: 0,
|
||||
boxOffsetY: 10,
|
||||
hideTranBtn: false, // 是否隐藏翻译按钮
|
||||
hideClickAway: false, // 是否点击外部关闭弹窗
|
||||
simpleStyle: false, // 是否简洁界面
|
||||
followSelection: false, // 翻译框是否跟随选中文本
|
||||
triggerMode: OPT_TRANBOX_TRIGGER_CLICK, // 触发翻译方式
|
||||
// extStyles: "", // 附加样式
|
||||
enDict: OPT_DICT_BING, // 英文词典
|
||||
enSug: OPT_SUG_YOUDAO, // 英文建议
|
||||
};
|
||||
|
||||
const SUBTITLE_WINDOW_STYLE = `padding: 0.5em 1em;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
line-height: 1.3;
|
||||
text-shadow: 1px 1px 2px black;
|
||||
display: inline-block`;
|
||||
|
||||
const SUBTITLE_ORIGIN_STYLE = `font-size: clamp(1.5rem, 3cqw, 3rem);`;
|
||||
|
||||
const SUBTITLE_TRANSLATION_STYLE = `font-size: clamp(1.5rem, 3cqw, 3rem);`;
|
||||
|
||||
export const DEFAULT_SUBTITLE_SETTING = {
|
||||
enabled: true, // 是否开启
|
||||
apiSlug: OPT_TRANS_MICROSOFT,
|
||||
segSlug: "-", // AI智能断句
|
||||
chunkLength: 1000, // AI处理切割长度
|
||||
// fromLang: "en",
|
||||
toLang: "zh-CN",
|
||||
isBilingual: true, // 是否双语显示
|
||||
windowStyle: SUBTITLE_WINDOW_STYLE, // 背景样式
|
||||
originStyle: SUBTITLE_ORIGIN_STYLE, // 原文样式
|
||||
translationStyle: SUBTITLE_TRANSLATION_STYLE, // 译文样式
|
||||
};
|
||||
|
||||
// 订阅列表
|
||||
export const DEFAULT_SUBRULES_LIST = [
|
||||
{
|
||||
url: process.env.REACT_APP_RULESURL,
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
url: process.env.REACT_APP_RULESURL_ON,
|
||||
selected: false,
|
||||
},
|
||||
{
|
||||
url: process.env.REACT_APP_RULESURL_OFF,
|
||||
selected: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_MOUSEHOVER_KEY = ["KeyQ"];
|
||||
export const DEFAULT_MOUSE_HOVER_SETTING = {
|
||||
useMouseHover: true, // 是否启用鼠标悬停翻译
|
||||
mouseHoverKey: DEFAULT_MOUSEHOVER_KEY, // 鼠标悬停翻译组合键
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTING = {
|
||||
darkMode: "auto", // 深色模式
|
||||
uiLang: "en", // 界面语言
|
||||
// fetchLimit: DEFAULT_FETCH_LIMIT, // 最大任务数量(移至rule,作废)
|
||||
// fetchInterval: DEFAULT_FETCH_INTERVAL, // 任务间隔时间(移至rule,作废)
|
||||
minLength: TRANS_MIN_LENGTH,
|
||||
maxLength: TRANS_MAX_LENGTH,
|
||||
newlineLength: TRANS_NEWLINE_LENGTH,
|
||||
httpTimeout: DEFAULT_HTTP_TIMEOUT,
|
||||
clearCache: false, // 是否在浏览器下次启动时清除缓存
|
||||
injectRules: true, // 是否注入订阅规则
|
||||
fabClickAction: 0, // 悬浮按钮点击行为
|
||||
// injectWebfix: true, // 是否注入修复补丁(作废)
|
||||
// detectRemote: false, // 是否使用远程语言检测 (从rule移回)
|
||||
// contextMenus: true, // 是否添加右键菜单(作废)
|
||||
contextMenuType: 1, // 右键菜单类型(0不显示,1简单菜单,2多级菜单)
|
||||
// transTag: DEFAULT_TRANS_TAG, // 译文元素标签(移至rule,作废)
|
||||
// transOnly: false, // 是否仅显示译文(移至rule,作废)
|
||||
// transTitle: false, // 是否同时翻译页面标题(移至rule,作废)
|
||||
subrulesList: DEFAULT_SUBRULES_LIST, // 订阅列表
|
||||
// owSubrule: DEFAULT_OW_RULE, // 覆写订阅规则 (作废)
|
||||
transApis: DEFAULT_API_LIST, // 翻译接口 (v2.0 对象改为数组)
|
||||
// mouseKey: OPT_TIMING_PAGESCROLL, // 翻译时机/鼠标悬停翻译(移至rule,作废)
|
||||
shortcuts: DEFAULT_SHORTCUTS, // 快捷键
|
||||
inputRule: DEFAULT_INPUT_RULE, // 输入框设置
|
||||
tranboxSetting: DEFAULT_TRANBOX_SETTING, // 划词翻译设置
|
||||
touchTranslate: 2, // 触屏翻译
|
||||
blacklist: DEFAULT_BLACKLIST.join(",\n"), // 禁用翻译名单
|
||||
csplist: DEFAULT_CSPLIST.join(",\n"), // 禁用CSP名单
|
||||
orilist: DEFAULT_ORILIST.join(",\n"), // 禁用CSP名单
|
||||
// disableLangs: [], // 不翻译的语言(移至rule,作废)
|
||||
skipLangs: [], // 不翻译的语言(从rule移回)
|
||||
transInterval: 100, // 翻译等待时间
|
||||
langDetector: "-", // 远程语言识别服务
|
||||
mouseHoverSetting: DEFAULT_MOUSE_HOVER_SETTING, // 鼠标悬停翻译
|
||||
preInit: true, // 是否预加载脚本
|
||||
transAllnow: false, // 是否立即全部翻译
|
||||
subtitleSetting: DEFAULT_SUBTITLE_SETTING, // 字幕设置
|
||||
logLevel: LogLevel.INFO.value, // 日志级别
|
||||
};
|
||||
22
src/config/storage.js
Normal file
22
src/config/storage.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { APP_NAME, APP_VERSION } from "./app";
|
||||
|
||||
export const KV_RULES_KEY = `kiss-rules_v${APP_VERSION[0]}.json`;
|
||||
export const KV_WORDS_KEY = "kiss-words.json";
|
||||
export const KV_RULES_SHARE_KEY = `kiss-rules-share_v${APP_VERSION[0]}.json`;
|
||||
export const KV_SETTING_KEY = `kiss-setting_v${APP_VERSION[0]}.json`;
|
||||
export const KV_SALT_SYNC = "KISS-Translator-SYNC";
|
||||
export const KV_SALT_SHARE = "KISS-Translator-SHARE";
|
||||
|
||||
export const STOKEY_MSAUTH = `${APP_NAME}_msauth`;
|
||||
export const STOKEY_BDAUTH = `${APP_NAME}_bdauth`;
|
||||
export const STOKEY_SETTING_OLD = `${APP_NAME}_setting`;
|
||||
export const STOKEY_RULES_OLD = `${APP_NAME}_rules`;
|
||||
export const STOKEY_SETTING = `${APP_NAME}_setting_v${APP_VERSION[0]}`;
|
||||
export const STOKEY_RULES = `${APP_NAME}_rules_v${APP_VERSION[0]}`;
|
||||
export const STOKEY_WORDS = `${APP_NAME}_words`;
|
||||
export const STOKEY_SYNC = `${APP_NAME}_sync`;
|
||||
export const STOKEY_FAB = `${APP_NAME}_fab`;
|
||||
export const STOKEY_RULESCACHE_PREFIX = `${APP_NAME}_rulescache_`;
|
||||
|
||||
export const CACHE_NAME = `${APP_NAME}_cache`;
|
||||
export const DEFAULT_CACHE_TIMEOUT = 3600 * 24 * 7; // 缓存超时时间(7天)
|
||||
14
src/config/url.js
Normal file
14
src/config/url.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { APP_LCNAME } from "./app";
|
||||
|
||||
export const URL_CACHE_TRAN = `https://${APP_LCNAME}/translate`;
|
||||
export const URL_CACHE_SUBTITLE = `https://${APP_LCNAME}/subtitle`;
|
||||
export const URL_CACHE_DELANG = `https://${APP_LCNAME}/detectlang`;
|
||||
export const URL_CACHE_BINGDICT = `https://${APP_LCNAME}/bingdict`;
|
||||
|
||||
export const URL_KISS_WORKER = "https://github.com/fishjar/kiss-worker";
|
||||
export const URL_KISS_PROXY = "https://github.com/fishjar/kiss-proxy";
|
||||
export const URL_KISS_RULES = "https://github.com/fishjar/kiss-rules";
|
||||
export const URL_KISS_RULES_NEW_ISSUE =
|
||||
"https://github.com/fishjar/kiss-rules/issues/new";
|
||||
export const URL_RAW_PREFIX =
|
||||
"https://raw.githubusercontent.com/fishjar/kiss-translator/master";
|
||||
@@ -1,3 +1,5 @@
|
||||
import { run } from "./common";
|
||||
|
||||
run();
|
||||
if (document.documentElement && document.documentElement.tagName === "HTML") {
|
||||
run();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { createContext, useContext, useState, forwardRef } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import Snackbar from "@mui/material/Snackbar";
|
||||
import MuiAlert from "@mui/material/Alert";
|
||||
|
||||
@@ -18,32 +25,37 @@ export function AlertProvider({ children }) {
|
||||
const horizontal = "center";
|
||||
const [open, setOpen] = useState(false);
|
||||
const [severity, setSeverity] = useState("info");
|
||||
const [message, setMessage] = useState("");
|
||||
const [message, setMessage] = useState(null);
|
||||
|
||||
const showAlert = (msg, type) => {
|
||||
const showAlert = useCallback((msg, type) => {
|
||||
setOpen(true);
|
||||
setMessage(msg);
|
||||
setSeverity(type);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClose = (_, reason) => {
|
||||
const handleClose = useCallback((_, reason) => {
|
||||
if (reason === "clickaway") {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const error = (msg) => showAlert(msg, "error");
|
||||
const warning = (msg) => showAlert(msg, "warning");
|
||||
const info = (msg) => showAlert(msg, "info");
|
||||
const success = (msg) => showAlert(msg, "success");
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
error: (msg) => showAlert(msg, "error"),
|
||||
warning: (msg) => showAlert(msg, "warning"),
|
||||
info: (msg) => showAlert(msg, "info"),
|
||||
success: (msg) => showAlert(msg, "success"),
|
||||
}),
|
||||
[showAlert]
|
||||
);
|
||||
|
||||
return (
|
||||
<AlertContext.Provider value={{ error, warning, info, success }}>
|
||||
<AlertContext.Provider value={value}>
|
||||
{children}
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={3000}
|
||||
autoHideDuration={10000}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical, horizontal }}
|
||||
>
|
||||
|
||||
144
src/hooks/Api.js
144
src/hooks/Api.js
@@ -1,24 +1,136 @@
|
||||
import { useCallback } from "react";
|
||||
import { DEFAULT_TRANS_APIS } from "../config";
|
||||
import { useCallback, useEffect, 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 = transApis[translator] || {};
|
||||
Object.assign(transApis, { [translator]: { ...api, ...obj } });
|
||||
await updateSetting({ transApis });
|
||||
},
|
||||
[translator, transApis, updateSetting]
|
||||
return { transApis, updateSetting };
|
||||
}
|
||||
|
||||
export function useApiList() {
|
||||
const { transApis, updateSetting } = useApiState();
|
||||
|
||||
useEffect(() => {
|
||||
const curSlugs = new Set(transApis.map((api) => api.apiSlug));
|
||||
const missApis = DEFAULT_API_LIST.filter(
|
||||
(api) => !curSlugs.has(api.apiSlug)
|
||||
);
|
||||
if (missApis.length > 0) {
|
||||
updateSetting((prev) => ({
|
||||
...prev,
|
||||
transApis: [...(prev?.transApis || []), ...missApis],
|
||||
}));
|
||||
}
|
||||
}, [transApis, updateSetting]);
|
||||
|
||||
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: transApis[translator] || {}, updateApi, resetApi };
|
||||
const enabledApis = useMemo(
|
||||
() => transApis.filter((api) => !api.isDisabled),
|
||||
[transApis]
|
||||
);
|
||||
|
||||
const aiEnabledApis = useMemo(
|
||||
() => enabledApis.filter((api) => API_SPE_TYPES.ai.has(api.apiType)),
|
||||
[enabledApis]
|
||||
);
|
||||
|
||||
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],
|
||||
}));
|
||||
},
|
||||
[updateSetting]
|
||||
);
|
||||
|
||||
const deleteApi = useCallback(
|
||||
(apiSlug) => {
|
||||
updateSetting((prev) => ({
|
||||
...prev,
|
||||
transApis: (prev?.transApis || []).filter(
|
||||
(api) => api.apiSlug !== apiSlug
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updateSetting]
|
||||
);
|
||||
|
||||
return {
|
||||
transApis,
|
||||
userApis,
|
||||
builtinApis,
|
||||
enabledApis,
|
||||
aiEnabledApis,
|
||||
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,
|
||||
key: item.key,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
}));
|
||||
}, [apiSlug, updateSetting]);
|
||||
|
||||
return { api, update, reset };
|
||||
}
|
||||
|
||||
61
src/hooks/Audio.js
Normal file
61
src/hooks/Audio.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { apiBaiduTTS } from "../apis";
|
||||
import { kissLog } from "../libs/log";
|
||||
|
||||
/**
|
||||
* 声音播放hook
|
||||
* @param {*} src
|
||||
* @returns
|
||||
*/
|
||||
export function useAudio(src) {
|
||||
const audioRef = useRef(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
const onPlay = useCallback(() => {
|
||||
audioRef.current?.play();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
const audio = new Audio(src);
|
||||
audio.addEventListener("error", (err) => setError(err));
|
||||
audio.addEventListener("canplaythrough", () => setReady(true));
|
||||
audio.addEventListener("play", () => setPlaying(true));
|
||||
audio.addEventListener("ended", () => setPlaying(false));
|
||||
audioRef.current = audio;
|
||||
}, [src]);
|
||||
|
||||
return {
|
||||
error,
|
||||
ready,
|
||||
playing,
|
||||
onPlay,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语音hook
|
||||
* @param {*} text
|
||||
* @param {*} lan
|
||||
* @param {*} spd
|
||||
* @returns
|
||||
*/
|
||||
export function useTextAudio(text, lan = "uk", spd = 3) {
|
||||
const [src, setSrc] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
setSrc(await apiBaiduTTS(text, lan, spd));
|
||||
} catch (err) {
|
||||
kissLog("baidu tts", err);
|
||||
}
|
||||
})();
|
||||
}, [text, lan, spd]);
|
||||
|
||||
return useAudio(src);
|
||||
}
|
||||
@@ -11,8 +11,13 @@ export function useDarkMode() {
|
||||
updateSetting,
|
||||
} = useSetting();
|
||||
|
||||
const toggleDarkMode = useCallback(async () => {
|
||||
await updateSetting({ darkMode: !darkMode });
|
||||
const toggleDarkMode = useCallback(() => {
|
||||
const nextMode = {
|
||||
light: "dark",
|
||||
dark: "auto",
|
||||
auto: "light",
|
||||
};
|
||||
updateSetting({ darkMode: nextMode[darkMode] || "light" });
|
||||
}, [darkMode, updateSetting]);
|
||||
|
||||
return { darkMode, toggleDarkMode };
|
||||
|
||||
97
src/hooks/Confirm.js
Normal file
97
src/hooks/Confirm.js
Normal 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;
|
||||
}
|
||||
17
src/hooks/DebouncedCallback.js
Normal file
17
src/hooks/DebouncedCallback.js
Normal 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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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]) {
|
||||
delete favs[word];
|
||||
} else {
|
||||
favs[word] = { createdAt: Date.now() };
|
||||
(word) => {
|
||||
save((prev) => {
|
||||
if (!prev[word]) {
|
||||
return { ...prev, [word]: { createdAt: Date.now() } };
|
||||
}
|
||||
await setWords(favs);
|
||||
await updateSyncMeta(KV_WORDS_KEY);
|
||||
await trySyncWords();
|
||||
setFavWords(favs);
|
||||
|
||||
const favs = { ...prev };
|
||||
delete favs[word];
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -1,40 +1,152 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
|
||||
/**
|
||||
* fetch data hook
|
||||
* @returns
|
||||
*/
|
||||
export const useFetch = (url) => {
|
||||
export const useAsync = () => {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
const execute = useCallback(async (fn, ...args) => {
|
||||
if (!fn) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`[${res.status}] ${res.statusText}`);
|
||||
}
|
||||
let data;
|
||||
if (res.headers.get("Content-Type")?.includes("json")) {
|
||||
data = await res.json();
|
||||
} else {
|
||||
data = await res.text();
|
||||
}
|
||||
setData(data);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [url]);
|
||||
setError(null);
|
||||
|
||||
return [data, loading, error];
|
||||
try {
|
||||
const res = await fn(...args);
|
||||
setData(res);
|
||||
setLoading(false);
|
||||
return res;
|
||||
} catch (err) {
|
||||
setError(err?.message || "An unknown error occurred");
|
||||
setLoading(false);
|
||||
// throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setData(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return { data, loading, error, execute, reset };
|
||||
};
|
||||
|
||||
export const useAsyncNow = (fn, arg) => {
|
||||
const { execute, ...asyncState } = useAsync();
|
||||
|
||||
useEffect(() => {
|
||||
if (fn) {
|
||||
execute(fn, arg);
|
||||
}
|
||||
}, [execute, fn, arg]);
|
||||
|
||||
return { ...asyncState };
|
||||
};
|
||||
|
||||
export const useFetch = () => {
|
||||
const { execute, ...asyncState } = useAsync();
|
||||
|
||||
const requester = useCallback(async (url, options) => {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
const errorInfo = await response.text();
|
||||
throw new Error(
|
||||
`Request failed: ${response.status} ${response.statusText} - ${errorInfo}`
|
||||
);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.headers.get("Content-Type")?.includes("json")) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}, []);
|
||||
|
||||
const get = useCallback(
|
||||
async (url, options = {}) => {
|
||||
try {
|
||||
const result = await execute(requester, url, {
|
||||
...options,
|
||||
method: "GET",
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[execute, requester]
|
||||
);
|
||||
|
||||
const post = useCallback(
|
||||
async (url, body, options = {}) => {
|
||||
try {
|
||||
const result = await execute(requester, url, {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options.headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[execute, requester]
|
||||
);
|
||||
|
||||
const put = useCallback(
|
||||
async (url, body, options = {}) => {
|
||||
try {
|
||||
const result = await execute(requester, url, {
|
||||
...options,
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", ...options.headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[execute, requester]
|
||||
);
|
||||
|
||||
const del = useCallback(
|
||||
async (url, options = {}) => {
|
||||
try {
|
||||
const result = await execute(requester, url, {
|
||||
...options,
|
||||
method: "DELETE",
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[execute, requester]
|
||||
);
|
||||
|
||||
return {
|
||||
...asyncState,
|
||||
get,
|
||||
post,
|
||||
put,
|
||||
del,
|
||||
};
|
||||
};
|
||||
|
||||
export const useGet = (url) => {
|
||||
const { get, ...fetchState } = useFetch();
|
||||
|
||||
useEffect(() => {
|
||||
if (url) get(url);
|
||||
}, [url, get]);
|
||||
|
||||
return { ...fetchState };
|
||||
};
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useSetting } from "./Setting";
|
||||
import { I18N, URL_RAW_PREFIX } from "../config";
|
||||
import { useFetch } from "./Fetch";
|
||||
import { useGet } from "./Fetch";
|
||||
|
||||
export const getI18n = (uiLang, key, defaultText = "") => {
|
||||
return I18N?.[key]?.[uiLang] ?? defaultText;
|
||||
};
|
||||
|
||||
export const useLangMap = (uiLang) => {
|
||||
return (key, defaultText = "") => getI18n(uiLang, key, defaultText);
|
||||
};
|
||||
|
||||
/**
|
||||
* 多语言 hook
|
||||
@@ -10,12 +18,12 @@ export const useI18n = () => {
|
||||
const {
|
||||
setting: { uiLang },
|
||||
} = useSetting();
|
||||
return (key, defaultText = "") => I18N?.[key]?.[uiLang] ?? defaultText;
|
||||
return useLangMap(uiLang);
|
||||
};
|
||||
|
||||
export const useI18nMd = (key) => {
|
||||
const i18n = useI18n();
|
||||
const fileName = i18n(key);
|
||||
const url = fileName ? `${URL_RAW_PREFIX}/${fileName}` : "";
|
||||
return useFetch(url);
|
||||
return useGet(url);
|
||||
};
|
||||
|
||||
@@ -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
16
src/hooks/Loading.js
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/hooks/MouseHover.js
Normal file
11
src/hooks/MouseHover.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { DEFAULT_MOUSE_HOVER_SETTING } from "../config";
|
||||
import { useSetting } from "./Setting";
|
||||
|
||||
export function useMouseHoverSetting() {
|
||||
const { setting, updateChild } = useSetting();
|
||||
const mouseHoverSetting =
|
||||
setting?.mouseHoverSetting || DEFAULT_MOUSE_HOVER_SETTING;
|
||||
const updateMouseHoverSetting = updateChild("mouseHoverSetting");
|
||||
|
||||
return { mouseHoverSetting, updateMouseHoverSetting };
|
||||
}
|
||||
@@ -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;
|
||||
(rule) => {
|
||||
save((prev) => {
|
||||
if (
|
||||
rule.pattern === "*" ||
|
||||
prev.some((item) => item.pattern === rule.pattern)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
if (rules.map((item) => item.pattern).includes(rule.pattern)) {
|
||||
return;
|
||||
}
|
||||
rules.unshift(rule);
|
||||
await updateRules(rules);
|
||||
return [rule, ...prev];
|
||||
});
|
||||
},
|
||||
[list, updateRules]
|
||||
[save]
|
||||
);
|
||||
|
||||
const del = useCallback(
|
||||
async (pattern) => {
|
||||
let rules = [...list];
|
||||
(pattern) => {
|
||||
save((prev) => {
|
||||
if (pattern === "*") {
|
||||
return;
|
||||
return prev;
|
||||
}
|
||||
rules = rules.filter((item) => item.pattern !== pattern);
|
||||
await updateRules(rules);
|
||||
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 = "*";
|
||||
(pattern, obj) => {
|
||||
save((prev) => {
|
||||
if (
|
||||
prev.some(
|
||||
(item) => item.pattern === obj.pattern && item.pattern !== pattern
|
||||
)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
const rule = rules.find((r) => r.pattern === pattern);
|
||||
rule && Object.assign(rule, obj);
|
||||
await updateRules(rules);
|
||||
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 };
|
||||
|
||||
@@ -1,51 +1,106 @@
|
||||
import { STOKEY_SETTING, DEFAULT_SETTING, KV_SETTING_KEY } from "../config";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import {
|
||||
STOKEY_SETTING,
|
||||
DEFAULT_SETTING,
|
||||
KV_SETTING_KEY,
|
||||
MSG_SET_LOGLEVEL,
|
||||
} 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";
|
||||
import { logger } from "../libs/log";
|
||||
import { sendBgMsg } from "../libs/msg";
|
||||
import { isExt } from "../libs/client";
|
||||
|
||||
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 {
|
||||
data: setting,
|
||||
isLoading,
|
||||
update,
|
||||
reload,
|
||||
} = useStorage(STOKEY_SETTING, DEFAULT_SETTING, KV_SETTING_KEY);
|
||||
|
||||
const syncSetting = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
trySyncSetting();
|
||||
}, [2000]),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (typeof setting?.darkMode === "boolean") {
|
||||
update((currentSetting) => ({
|
||||
...currentSetting,
|
||||
darkMode: currentSetting.darkMode ? "dark" : "light",
|
||||
}));
|
||||
}
|
||||
}, [setting?.darkMode, update]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
logger.setLevel(setting?.logLevel);
|
||||
if (isExt) {
|
||||
await sendBgMsg(MSG_SET_LOGLEVEL, setting?.logLevel);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch log level, using default.", error);
|
||||
}
|
||||
})();
|
||||
}, [setting]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
const save = useCallback(
|
||||
async (val) => {
|
||||
setData(val);
|
||||
await storage.setObj(key, val);
|
||||
},
|
||||
[key]
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
} catch (err) {
|
||||
kissLog(err, "storage reload");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [key]);
|
||||
export function useStorage(key, defaultVal = null, syncKey = "") {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [data, setData] = useState(defaultVal);
|
||||
|
||||
// 首次加载数据
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const val = await storage.getObj(key);
|
||||
if (val) {
|
||||
setData(val);
|
||||
} else if (defaultVal) {
|
||||
setData(defaultVal);
|
||||
const storedVal = await storage.getObj(key);
|
||||
if (storedVal === undefined || storedVal === null) {
|
||||
await storage.setObj(key, defaultVal);
|
||||
} else if (isMounted) {
|
||||
setData(storedVal);
|
||||
}
|
||||
} catch (err) {
|
||||
kissLog(err, "storage load");
|
||||
kissLog(`storage load error for key: ${key}`, err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
}
|
||||
};
|
||||
|
||||
loadInitialData();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [key, defaultVal]);
|
||||
|
||||
return { data, save, update, remove, reload, loading };
|
||||
// 远端同步
|
||||
const runSync = useCallback(async (keyToSync, valueToSync) => {
|
||||
try {
|
||||
const res = await syncData(keyToSync, valueToSync);
|
||||
if (res?.isNew) {
|
||||
setData(res.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(`storage remove error for key: ${key}`, err);
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
/**
|
||||
* 从 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, isLoading };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
10
src/hooks/Subtitle.js
Normal file
10
src/hooks/Subtitle.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { DEFAULT_SUBTITLE_SETTING } from "../config";
|
||||
import { useSetting } from "./Setting";
|
||||
|
||||
export function useSubtitle() {
|
||||
const { setting, updateChild } = useSetting();
|
||||
const subtitleSetting = setting?.subtitleSetting || DEFAULT_SUBTITLE_SETTING;
|
||||
const updateSubtitle = updateChild("subtitleSetting");
|
||||
|
||||
return { subtitleSetting, updateSubtitle };
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
[sync?.syncMeta, updateSync]
|
||||
};
|
||||
return { syncMeta: newSyncMeta };
|
||||
});
|
||||
},
|
||||
[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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import { CssBaseline, GlobalStyles } from "@mui/material";
|
||||
import { useDarkMode } from "./ColorMode";
|
||||
import { THEME_DARK, THEME_LIGHT } from "../config";
|
||||
|
||||
@@ -9,8 +9,23 @@ import { THEME_DARK, THEME_LIGHT } from "../config";
|
||||
* @param {*} param0
|
||||
* @returns
|
||||
*/
|
||||
export default function Theme({ children, options }) {
|
||||
export default function Theme({ children, options, styles }) {
|
||||
const { darkMode } = useDarkMode();
|
||||
const [systemMode, setSystemMode] = useState(THEME_LIGHT);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function") {
|
||||
return;
|
||||
}
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = () => {
|
||||
setSystemMode(mediaQuery.matches ? THEME_DARK : THEME_LIGHT);
|
||||
};
|
||||
handleChange(); // Set initial value
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}, []);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
let htmlFontSize = 16;
|
||||
try {
|
||||
@@ -23,21 +38,25 @@ export default function Theme({ children, options }) {
|
||||
//
|
||||
}
|
||||
|
||||
const isDarkMode =
|
||||
darkMode === "dark" || (darkMode === "auto" && systemMode === THEME_DARK);
|
||||
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode: darkMode ? THEME_DARK : THEME_LIGHT,
|
||||
mode: isDarkMode ? THEME_DARK : THEME_LIGHT,
|
||||
},
|
||||
typography: {
|
||||
htmlFontSize,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}, [darkMode, options]);
|
||||
}, [darkMode, options, systemMode]);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
|
||||
<CssBaseline />
|
||||
<GlobalStyles styles={styles} />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -1,57 +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) {
|
||||
const [text, setText] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sameLang, setSamelang] = useState(false);
|
||||
|
||||
const { translator, fromLang, toLang, detectRemote, skipLangs = [] } = rule;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
if (!q.replace(/\[(\d+)\]/g, "").trim()) {
|
||||
setText(q);
|
||||
setSamelang(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const deLang = await tryDetectLang(q, detectRemote === "true");
|
||||
if (deLang && (toLang.includes(deLang) || skipLangs.includes(deLang))) {
|
||||
setSamelang(true);
|
||||
} else {
|
||||
const [trText, isSame] = await apiTranslate({
|
||||
translator,
|
||||
text: q,
|
||||
fromLang,
|
||||
toLang,
|
||||
apiSetting:
|
||||
setting.transApis?.[translator] || DEFAULT_TRANS_APIS[translator],
|
||||
});
|
||||
setText(trText);
|
||||
setSamelang(isSame);
|
||||
}
|
||||
} catch (err) {
|
||||
kissLog(err, "translate");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [q, translator, fromLang, toLang, detectRemote, skipLangs, setting]);
|
||||
|
||||
return { text, sameLang, loading };
|
||||
}
|
||||
61
src/hooks/ValidationInput.js
Normal file
61
src/hooks/ValidationInput.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { limitNumber, limitFloat } from "../libs/utils";
|
||||
|
||||
function ValidationInput({
|
||||
value,
|
||||
onChange,
|
||||
name,
|
||||
min,
|
||||
max,
|
||||
isFloat = false,
|
||||
...props
|
||||
}) {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleLocalChange = (e) => {
|
||||
setLocalValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
const numValue = Number(localValue);
|
||||
|
||||
if (isNaN(numValue)) {
|
||||
setLocalValue(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedValue = isFloat
|
||||
? limitFloat(numValue, min, max)
|
||||
: limitNumber(numValue, min, max);
|
||||
|
||||
if (validatedValue !== numValue) {
|
||||
setLocalValue(validatedValue);
|
||||
}
|
||||
|
||||
onChange({
|
||||
target: {
|
||||
name: name,
|
||||
value: validatedValue,
|
||||
},
|
||||
preventDefault: () => {},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<TextField
|
||||
{...props}
|
||||
type="number"
|
||||
name={name}
|
||||
value={localValue}
|
||||
onChange={handleLocalChange}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ValidationInput;
|
||||
16
src/index.js
16
src/index.js
@@ -7,14 +7,15 @@ import Paper from "@mui/material/Paper";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Button from "@mui/material/Button";
|
||||
import Link from "@mui/material/Link";
|
||||
import { useFetch } from "./hooks/Fetch";
|
||||
import { useGet } from "./hooks/Fetch";
|
||||
import { I18N, URL_RAW_PREFIX } from "./config";
|
||||
|
||||
function App() {
|
||||
const [lang, setLang] = useState("zh");
|
||||
const [data, loading, error] = useFetch(
|
||||
const { data, loading, error } = useGet(
|
||||
`${URL_RAW_PREFIX}/${I18N?.["about_md"]?.[lang]}`
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper sx={{ padding: 2, margin: 2 }}>
|
||||
<Stack spacing={2} direction="row" justifyContent="flex-end">
|
||||
@@ -36,19 +37,10 @@ function App() {
|
||||
<Link href={process.env.REACT_APP_USERSCRIPT_DOWNLOADURL}>
|
||||
Install/Update Userscript for Tampermonkey/Violentmonkey
|
||||
</Link>
|
||||
{/* <Link href={process.env.REACT_APP_USERSCRIPT_DOWNLOADURL2}>
|
||||
Install/Update Userscript for Tampermonkey/Violentmonkey 2
|
||||
</Link> */}
|
||||
<Link href={process.env.REACT_APP_USERSCRIPT_IOS_DOWNLOADURL}>
|
||||
Install/Update Userscript for iOS Safari
|
||||
</Link>
|
||||
{/* <Link href={process.env.REACT_APP_USERSCRIPT_IOS_DOWNLOADURL2}>
|
||||
Install/Update Userscript for iOS Safari 2
|
||||
</Link> */}
|
||||
<Link href={process.env.REACT_APP_OPTIONSPAGE}>Open Options Page</Link>
|
||||
{/* <Link href={process.env.REACT_APP_OPTIONSPAGE2}>
|
||||
Open Options Page 2
|
||||
</Link> */}
|
||||
</Stack>
|
||||
|
||||
{loading ? (
|
||||
@@ -56,7 +48,7 @@ function App() {
|
||||
<CircularProgress />
|
||||
</center>
|
||||
) : (
|
||||
<ReactMarkdown children={error ? error.message : data} />
|
||||
<ReactMarkdown children={error || data} />
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
|
||||
3
src/injector.js
Normal file
3
src/injector.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { XMLHttpRequestInjector } from "./subtitle/XMLHttpRequestInjector";
|
||||
|
||||
XMLHttpRequestInjector();
|
||||
@@ -1,13 +1,12 @@
|
||||
import { getMsauth, setMsauth } from "./storage";
|
||||
import { URL_MICROSOFT_AUTH } from "../config";
|
||||
import { fetchData } from "./fetch";
|
||||
import { kissLog } from "./log";
|
||||
import { apiMsAuth } from "../apis";
|
||||
|
||||
const parseMSToken = (token) => {
|
||||
try {
|
||||
return JSON.parse(atob(token.split(".")[1])).exp;
|
||||
} catch (err) {
|
||||
kissLog(err, "parseMSToken");
|
||||
kissLog("parseMSToken", err);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
@@ -17,28 +16,55 @@ const parseMSToken = (token) => {
|
||||
* @returns
|
||||
*/
|
||||
const _msAuth = () => {
|
||||
let { token, exp } = {};
|
||||
let tokenPromise = null;
|
||||
const EXPIRATION_MS = 1000;
|
||||
|
||||
const fetchNewToken = async () => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
||||
// 1. 查询storage缓存
|
||||
const storageToken = await getMsauth();
|
||||
if (storageToken) {
|
||||
const storageExp = parseMSToken(storageToken);
|
||||
const storageExpiresAt = storageExp * 1000;
|
||||
if (storageExpiresAt > now + EXPIRATION_MS) {
|
||||
return { token: storageToken, expiresAt: storageExpiresAt };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 缓存没有或失效,查询接口
|
||||
const apiToken = await apiMsAuth();
|
||||
if (!apiToken) {
|
||||
throw new Error("Failed to fetch ms token");
|
||||
}
|
||||
|
||||
const apiExp = parseMSToken(apiToken);
|
||||
const apiExpiresAt = apiExp * 1000;
|
||||
await setMsauth(apiToken);
|
||||
return { token: apiToken, expiresAt: apiExpiresAt };
|
||||
} catch (error) {
|
||||
kissLog("get msauth failed", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return async () => {
|
||||
// 查询内存缓存
|
||||
const now = Date.now();
|
||||
if (token && exp * 1000 > now + 1000) {
|
||||
return [token, exp];
|
||||
// 检查是否有缓存的 Promise
|
||||
if (tokenPromise) {
|
||||
try {
|
||||
const cachedResult = await tokenPromise;
|
||||
if (cachedResult.expiresAt > Date.now() + EXPIRATION_MS) {
|
||||
return cachedResult.token;
|
||||
}
|
||||
} catch (error) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
// 查询storage缓存
|
||||
const res = await getMsauth();
|
||||
token = res?.token;
|
||||
exp = res?.exp;
|
||||
if (token && exp * 1000 > now + 1000) {
|
||||
return [token, exp];
|
||||
}
|
||||
|
||||
// 缓存没有或失效,查询接口
|
||||
token = await fetchData(URL_MICROSOFT_AUTH);
|
||||
exp = parseMSToken(token);
|
||||
await setMsauth({ token, exp });
|
||||
return [token, exp];
|
||||
tokenPromise = fetchNewToken();
|
||||
const result = await tokenPromise;
|
||||
return result.token;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
152
src/libs/batchQueue.js
Normal file
152
src/libs/batchQueue.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
DEFAULT_BATCH_INTERVAL,
|
||||
DEFAULT_BATCH_SIZE,
|
||||
DEFAULT_BATCH_LENGTH,
|
||||
} from "../config";
|
||||
|
||||
/**
|
||||
* 批处理队列
|
||||
* @param {*} args
|
||||
* @param {*} param1
|
||||
* @returns
|
||||
*/
|
||||
const BatchQueue = (
|
||||
taskFn,
|
||||
{
|
||||
batchInterval = DEFAULT_BATCH_INTERVAL,
|
||||
batchSize = DEFAULT_BATCH_SIZE,
|
||||
batchLength = DEFAULT_BATCH_LENGTH,
|
||||
} = {}
|
||||
) => {
|
||||
const queue = [];
|
||||
let isProcessing = false;
|
||||
let timer = null;
|
||||
|
||||
const sendBatchRequest = async (payloads, batchArgs) => {
|
||||
return taskFn(payloads, batchArgs);
|
||||
};
|
||||
|
||||
const processQueue = async () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
if (queue.length === 0 || isProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing = true;
|
||||
|
||||
let tasksToProcess = [];
|
||||
let currentBatchLength = 0;
|
||||
let endIndex = 0;
|
||||
|
||||
for (const task of queue) {
|
||||
const textLength = task.payload?.length || 0;
|
||||
if (
|
||||
endIndex >= batchSize ||
|
||||
(currentBatchLength + textLength > batchLength && endIndex > 0)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
currentBatchLength += textLength;
|
||||
endIndex++;
|
||||
}
|
||||
|
||||
if (endIndex > 0) {
|
||||
tasksToProcess = queue.splice(0, endIndex);
|
||||
}
|
||||
|
||||
if (tasksToProcess.length === 0) {
|
||||
isProcessing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payloads = tasksToProcess.map((item) => item.payload);
|
||||
const batchArgs = tasksToProcess[0].args;
|
||||
const responses = await sendBatchRequest(payloads, batchArgs);
|
||||
if (!Array.isArray(responses)) {
|
||||
throw new Error("responses format error");
|
||||
}
|
||||
|
||||
tasksToProcess.forEach((taskItem, index) => {
|
||||
const response = responses[index];
|
||||
if (response) {
|
||||
taskItem.resolve(response);
|
||||
} else {
|
||||
taskItem.reject(new Error(`No response for item at index ${index}`));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
tasksToProcess.forEach((taskItem) => taskItem.reject(error));
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
if (queue.length > 0) {
|
||||
if (queue.length >= batchSize) {
|
||||
setTimeout(processQueue, 0);
|
||||
} else {
|
||||
scheduleProcessing();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleProcessing = () => {
|
||||
if (!isProcessing && !timer && queue.length > 0) {
|
||||
timer = setTimeout(processQueue, batchInterval);
|
||||
}
|
||||
};
|
||||
|
||||
const addTask = (data, args) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = data;
|
||||
queue.push({ payload, resolve, reject, args });
|
||||
|
||||
if (queue.length >= batchSize) {
|
||||
processQueue();
|
||||
} else {
|
||||
scheduleProcessing();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
queue.forEach((task) =>
|
||||
task.reject(new Error("Queue instance was destroyed."))
|
||||
);
|
||||
queue.length = 0;
|
||||
};
|
||||
|
||||
return { addTask, destroy };
|
||||
};
|
||||
|
||||
// 实例字典
|
||||
const queueMap = new Map();
|
||||
|
||||
/**
|
||||
* 获取批处理实例
|
||||
*/
|
||||
export const getBatchQueue = (key, taskFn, options) => {
|
||||
if (queueMap.has(key)) {
|
||||
return queueMap.get(key);
|
||||
}
|
||||
|
||||
const queue = BatchQueue(taskFn, options);
|
||||
queueMap.set(key, queue);
|
||||
return queue;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除所有任务
|
||||
*/
|
||||
export const clearAllBatchQueue = () => {
|
||||
for (const queue of queueMap.values()) {
|
||||
queue.destroy();
|
||||
}
|
||||
};
|
||||
@@ -8,10 +8,13 @@ function _browser() {
|
||||
try {
|
||||
return require("webextension-polyfill");
|
||||
} catch (err) {
|
||||
// kissLog(err, "browser");
|
||||
// kissLog("browser", err);
|
||||
}
|
||||
}
|
||||
|
||||
export const browser = _browser();
|
||||
|
||||
export const isBg = () => globalThis?.ContextType === "BACKGROUND";
|
||||
|
||||
export const isBuiltinAIAvailable =
|
||||
"LanguageDetector" in globalThis && "Translator" in globalThis;
|
||||
|
||||
168
src/libs/builtinAI.js
Normal file
168
src/libs/builtinAI.js
Normal file
@@ -0,0 +1,168 @@
|
||||
import { kissLog, logger } from "./log";
|
||||
|
||||
/**
|
||||
* Chrome 浏览器内置翻译
|
||||
*/
|
||||
class ChromeTranslator {
|
||||
#translatorMap = new Map();
|
||||
#detectorPromise = null;
|
||||
|
||||
constructor(options = {}) {
|
||||
this.onProgress = options.onProgress || this.#defaultProgressHandler;
|
||||
}
|
||||
|
||||
#defaultProgressHandler(type, progress) {
|
||||
kissLog(`Downloading ${type} model: ${progress}%`);
|
||||
}
|
||||
|
||||
#getDetectorPromise() {
|
||||
if (!this.#detectorPromise) {
|
||||
this.#detectorPromise = (async () => {
|
||||
try {
|
||||
const availability = await LanguageDetector.availability();
|
||||
if (availability === "unavailable") {
|
||||
throw new Error("LanguageDetector unavailable");
|
||||
}
|
||||
|
||||
return await LanguageDetector.create({
|
||||
monitor: (m) => this._monitorProgress(m, "detector"),
|
||||
});
|
||||
} catch (error) {
|
||||
this.#detectorPromise = null;
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return this.#detectorPromise;
|
||||
}
|
||||
|
||||
#createTranslator(sourceLanguage, targetLanguage) {
|
||||
const key = `${sourceLanguage}_${targetLanguage}`;
|
||||
if (this.#translatorMap.has(key)) {
|
||||
return this.#translatorMap.get(key);
|
||||
}
|
||||
|
||||
const translatorPromise = (async () => {
|
||||
try {
|
||||
const avail = await Translator.availability({
|
||||
sourceLanguage,
|
||||
targetLanguage,
|
||||
});
|
||||
if (avail === "unavailable") {
|
||||
throw new Error(
|
||||
`Translator ${sourceLanguage}_${targetLanguage} unavailable`
|
||||
);
|
||||
}
|
||||
|
||||
const translator = await Translator.create({
|
||||
sourceLanguage,
|
||||
targetLanguage,
|
||||
monitor: (m) => this._monitorProgress(m, `translator (${key})`),
|
||||
});
|
||||
this.#translatorMap.set(key, translator);
|
||||
|
||||
return translator;
|
||||
} catch (error) {
|
||||
this.#translatorMap.delete(key);
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
this.#translatorMap.set(key, translatorPromise);
|
||||
return translatorPromise;
|
||||
}
|
||||
|
||||
_monitorProgress(monitorable, type) {
|
||||
monitorable.addEventListener("downloadprogress", (e) => {
|
||||
const progress = e.total > 0 ? Math.round((e.loaded / e.total) * 100) : 0;
|
||||
this.onProgress(type, progress);
|
||||
});
|
||||
}
|
||||
|
||||
async detectLanguage(text, confidenceThreshold = 0.4) {
|
||||
if (!text) {
|
||||
return ["", "Input text cannot be empty."];
|
||||
}
|
||||
|
||||
try {
|
||||
const detector = await this.#getDetectorPromise();
|
||||
const results = await detector.detect(text);
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
return ["", "No language could be detected."];
|
||||
}
|
||||
|
||||
const { detectedLanguage, confidence } = results[0];
|
||||
if (confidence < confidenceThreshold) {
|
||||
return [
|
||||
"",
|
||||
`Confidence of test results (${detectedLanguage} ${confidence.toFixed(
|
||||
2
|
||||
)}) below the set threshold ${confidenceThreshold}。`,
|
||||
];
|
||||
}
|
||||
|
||||
return [detectedLanguage, ""];
|
||||
} catch (error) {
|
||||
kissLog("detectLanguage", error, `(${text})`);
|
||||
return ["", error.message];
|
||||
}
|
||||
}
|
||||
|
||||
async translateText(text, targetLanguage, sourceLanguage = "auto") {
|
||||
if (!text || !targetLanguage || typeof text !== "string") {
|
||||
return ["", sourceLanguage, "Input text cannot be empty."];
|
||||
}
|
||||
|
||||
try {
|
||||
let finalSourceLanguage = sourceLanguage;
|
||||
if (sourceLanguage === "auto") {
|
||||
const [detectedLanguage, detectionError] =
|
||||
await this.detectLanguage(text);
|
||||
if (detectionError || !detectedLanguage) {
|
||||
const reason =
|
||||
detectionError || "Unable to determine source language.";
|
||||
return [
|
||||
"",
|
||||
finalSourceLanguage,
|
||||
`Automatic detection of source language failed: ${reason}`,
|
||||
];
|
||||
}
|
||||
finalSourceLanguage = detectedLanguage;
|
||||
}
|
||||
|
||||
if (finalSourceLanguage === targetLanguage) {
|
||||
return ["", finalSourceLanguage, "Same lang"];
|
||||
}
|
||||
|
||||
const translator = await this.#createTranslator(
|
||||
finalSourceLanguage,
|
||||
targetLanguage
|
||||
);
|
||||
const translatedText = await translator.translate(text);
|
||||
|
||||
return [translatedText, finalSourceLanguage, ""];
|
||||
} catch (error) {
|
||||
kissLog("translateText", error, `(${text})`);
|
||||
|
||||
if (
|
||||
error &&
|
||||
error.message &&
|
||||
error.message.includes("Other generic failures occurred")
|
||||
) {
|
||||
logger.info("Generic failure detected, resetting translator cache.");
|
||||
this.#translatorMap.clear();
|
||||
}
|
||||
|
||||
return ["", sourceLanguage, error.message];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chromeTranslator = new ChromeTranslator();
|
||||
|
||||
export const chromeDetect = (args) =>
|
||||
chromeTranslator.detectLanguage(args.text);
|
||||
export const chromeTranslate = (args) =>
|
||||
chromeTranslator.translateText(args.text, args.to, args.from);
|
||||
159
src/libs/cache.js
Normal file
159
src/libs/cache.js
Normal file
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
CACHE_NAME,
|
||||
DEFAULT_CACHE_TIMEOUT,
|
||||
MSG_CLEAR_CACHES,
|
||||
MSG_GET_HTTPCACHE,
|
||||
MSG_PUT_HTTPCACHE,
|
||||
} from "../config";
|
||||
import { kissLog } from "./log";
|
||||
import { isExt } from "./client";
|
||||
import { isBg } from "./browser";
|
||||
import { sendBgMsg } from "./msg";
|
||||
import { blobToBase64 } from "./utils";
|
||||
|
||||
/**
|
||||
* 清除缓存数据
|
||||
*/
|
||||
export const tryClearCaches = async () => {
|
||||
try {
|
||||
if (isExt && !isBg) {
|
||||
await sendBgMsg(MSG_CLEAR_CACHES);
|
||||
} else {
|
||||
await caches.delete(CACHE_NAME);
|
||||
}
|
||||
} catch (err) {
|
||||
kissLog("clean caches", err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 构造缓存 request
|
||||
* @param {*} input
|
||||
* @param {*} init
|
||||
* @returns
|
||||
*/
|
||||
const newCacheReq = async (input, init) => {
|
||||
let request = new Request(input, init);
|
||||
if (request.method !== "GET") {
|
||||
const body = await request.text();
|
||||
const cacheUrl = new URL(request.url);
|
||||
cacheUrl.pathname += body;
|
||||
request = new Request(cacheUrl.toString(), { method: "GET" });
|
||||
}
|
||||
|
||||
return request;
|
||||
};
|
||||
|
||||
/**
|
||||
* 查询 caches
|
||||
* @param {*} input
|
||||
* @param {*} init
|
||||
* @returns
|
||||
*/
|
||||
export const getHttpCache = async ({ input, init }) => {
|
||||
try {
|
||||
const request = await newCacheReq(input, init);
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const response = await cache.match(request);
|
||||
if (response) {
|
||||
const res = await parseResponse(response);
|
||||
return res;
|
||||
}
|
||||
} catch (err) {
|
||||
kissLog("get cache", err);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 插入 caches
|
||||
* @param {*} input
|
||||
* @param {*} init
|
||||
* @param {*} 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);
|
||||
const res = new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": `max-age=${maxAge}`,
|
||||
},
|
||||
});
|
||||
// res.headers.set("Cache-Control", `max-age=${maxAge}`);
|
||||
await cache.put(req, res);
|
||||
} catch (err) {
|
||||
kissLog("put cache", err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析 response
|
||||
* @param {*} res
|
||||
* @returns
|
||||
*/
|
||||
export const parseResponse = async (res) => {
|
||||
if (!res) {
|
||||
throw new Error("Response object does not exist");
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = {
|
||||
url: res.url,
|
||||
status: res.status,
|
||||
};
|
||||
if (res.headers.get("Content-Type")?.includes("json")) {
|
||||
msg.response = await res.json();
|
||||
}
|
||||
throw new Error(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
const contentType = res.headers.get("Content-Type");
|
||||
if (contentType?.includes("json")) {
|
||||
return res.json();
|
||||
} else if (contentType?.includes("audio")) {
|
||||
const blob = await res.blob();
|
||||
return blobToBase64(blob);
|
||||
}
|
||||
return res.text();
|
||||
};
|
||||
|
||||
/**
|
||||
* getHttpCache 兼容性封装
|
||||
* @param {*} input
|
||||
* @param {*} init
|
||||
* @returns
|
||||
*/
|
||||
export const getHttpCachePolyfill = (input, init) => {
|
||||
// 插件
|
||||
if (isExt && !isBg()) {
|
||||
return sendBgMsg(MSG_GET_HTTPCACHE, { input, init });
|
||||
}
|
||||
|
||||
// 油猴/网页/BackgroundPage
|
||||
return getHttpCache({ input, init });
|
||||
};
|
||||
|
||||
/**
|
||||
* putHttpCache 兼容性封装
|
||||
* @param {*} input
|
||||
* @param {*} init
|
||||
* @param {*} data
|
||||
* @returns
|
||||
*/
|
||||
export const putHttpCachePolyfill = (input, init, data) => {
|
||||
// 插件
|
||||
if (isExt && !isBg()) {
|
||||
return sendBgMsg(MSG_PUT_HTTPCACHE, { input, init, data });
|
||||
}
|
||||
|
||||
// 油猴/网页/BackgroundPage
|
||||
return putHttpCache({ input, init, data });
|
||||
};
|
||||
@@ -1,6 +1,12 @@
|
||||
import { CLIENT_EXTS, CLIENT_USERSCRIPT, CLIENT_WEB } from "../config";
|
||||
import {
|
||||
CLIENT_EXTS,
|
||||
CLIENT_USERSCRIPT,
|
||||
CLIENT_WEB,
|
||||
CLIENT_FIREFOX,
|
||||
} from "../config";
|
||||
|
||||
export const client = process.env.REACT_APP_CLIENT;
|
||||
export const isExt = CLIENT_EXTS.includes(client);
|
||||
export const isGm = client === CLIENT_USERSCRIPT;
|
||||
export const isWeb = client === CLIENT_WEB;
|
||||
export const isFirefox = client === CLIENT_FIREFOX;
|
||||
|
||||
65
src/libs/detect.js
Normal file
65
src/libs/detect.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
OPT_TRANS_GOOGLE,
|
||||
OPT_TRANS_MICROSOFT,
|
||||
OPT_TRANS_BAIDU,
|
||||
OPT_TRANS_TENCENT,
|
||||
OPT_LANGS_TO_CODE,
|
||||
OPT_LANGS_MAP,
|
||||
OPT_TRANS_BUILTINAI,
|
||||
OPT_LANGDETECTOR_MAP,
|
||||
} from "../config";
|
||||
import { browser } from "./browser";
|
||||
import {
|
||||
apiGoogleLangdetect,
|
||||
apiMicrosoftLangdetect,
|
||||
apiBaiduLangdetect,
|
||||
apiTencentLangdetect,
|
||||
apiBuiltinAIDetect,
|
||||
} from "../apis";
|
||||
import { kissLog } from "./log";
|
||||
|
||||
const langdetectFns = {
|
||||
[OPT_TRANS_GOOGLE]: apiGoogleLangdetect,
|
||||
[OPT_TRANS_MICROSOFT]: apiMicrosoftLangdetect,
|
||||
[OPT_TRANS_BAIDU]: apiBaiduLangdetect,
|
||||
[OPT_TRANS_TENCENT]: apiTencentLangdetect,
|
||||
[OPT_TRANS_BUILTINAI]: apiBuiltinAIDetect,
|
||||
};
|
||||
|
||||
/**
|
||||
* 语言识别
|
||||
* @param {*} text
|
||||
* @returns
|
||||
*/
|
||||
export const tryDetectLang = async (text, langDetector = "-") => {
|
||||
let deLang = "";
|
||||
|
||||
// 内置AI/远程识别
|
||||
if (OPT_LANGDETECTOR_MAP.has(langDetector)) {
|
||||
try {
|
||||
const lang = await langdetectFns[langDetector](text);
|
||||
if (lang) {
|
||||
deLang = OPT_LANGS_TO_CODE[langDetector].get(lang) || "";
|
||||
}
|
||||
} catch (err) {
|
||||
kissLog("detect lang remote", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 本地识别
|
||||
if (!deLang) {
|
||||
try {
|
||||
const res = await browser?.i18n?.detectLanguage(text);
|
||||
const lang = res?.languages?.[0]?.language;
|
||||
if (lang && OPT_LANGS_MAP.has(lang)) {
|
||||
deLang = lang;
|
||||
} else if (lang?.startsWith("zh")) {
|
||||
deLang = "zh-CN";
|
||||
}
|
||||
} catch (err) {
|
||||
kissLog("detect lang local", err);
|
||||
}
|
||||
}
|
||||
|
||||
return deLang;
|
||||
};
|
||||
@@ -1,19 +1,11 @@
|
||||
import { isExt, isGm } from "./client";
|
||||
import { sendBgMsg } from "./msg";
|
||||
import { taskPool } from "./pool";
|
||||
import {
|
||||
MSG_FETCH,
|
||||
MSG_FETCH_LIMIT,
|
||||
MSG_FETCH_CLEAR,
|
||||
CACHE_NAME,
|
||||
DEFAULT_FETCH_INTERVAL,
|
||||
DEFAULT_FETCH_LIMIT,
|
||||
} from "../config";
|
||||
import { getSettingWithDefault } from "./storage";
|
||||
import { MSG_FETCH, DEFAULT_HTTP_TIMEOUT } from "../config";
|
||||
import { isBg } from "./browser";
|
||||
import { newCacheReq, newTransReq } from "./req";
|
||||
import { kissLog } from "./log";
|
||||
|
||||
const TIMEOUT = 5000;
|
||||
import { getFetchPool } from "./pool";
|
||||
import { getHttpCachePolyfill, parseResponse } from "./cache";
|
||||
|
||||
/**
|
||||
* 油猴脚本的请求封装
|
||||
@@ -21,7 +13,10 @@ const TIMEOUT = 5000;
|
||||
* @param {*} init
|
||||
* @returns
|
||||
*/
|
||||
export const fetchGM = async (input, { method = "GET", headers, body } = {}) =>
|
||||
export const fetchGM = async (
|
||||
input,
|
||||
{ method = "GET", headers, body, timeout } = {}
|
||||
) =>
|
||||
new Promise((resolve, reject) => {
|
||||
GM.xmlHttpRequest({
|
||||
method,
|
||||
@@ -29,8 +24,8 @@ export const fetchGM = async (input, { method = "GET", headers, body } = {}) =>
|
||||
headers,
|
||||
data: body,
|
||||
// withCredentials: true,
|
||||
timeout: TIMEOUT,
|
||||
onload: ({ response, responseHeaders, status, statusText, ...opts }) => {
|
||||
timeout,
|
||||
onload: ({ response, responseHeaders, status, statusText }) => {
|
||||
const headers = {};
|
||||
responseHeaders.split("\n").forEach((line) => {
|
||||
const [name, value] = line.split(":").map((item) => item.trim());
|
||||
@@ -51,33 +46,28 @@ export const fetchGM = async (input, { method = "GET", headers, body } = {}) =>
|
||||
|
||||
/**
|
||||
* 发起请求
|
||||
* @param {*} param0
|
||||
* @param {*} input
|
||||
* @param {*} init
|
||||
* @param {*} opts
|
||||
* @returns
|
||||
*/
|
||||
export const fetchApi = async ({ input, init, transOpts, apiSetting }) => {
|
||||
if (transOpts?.translator) {
|
||||
[input, init] = await newTransReq(transOpts, apiSetting);
|
||||
export const fetchPatcher = async (input, init = {}, opts) => {
|
||||
let timeout = opts?.httpTimeout;
|
||||
if (!timeout) {
|
||||
try {
|
||||
timeout = (await getSettingWithDefault()).httpTimeout;
|
||||
} catch (err) {
|
||||
kissLog("getSettingWithDefault", err);
|
||||
}
|
||||
|
||||
if (!input) {
|
||||
throw new Error("url is empty");
|
||||
}
|
||||
if (!timeout) {
|
||||
timeout = DEFAULT_HTTP_TIMEOUT;
|
||||
}
|
||||
|
||||
if (isGm) {
|
||||
let info;
|
||||
if (window.KISS_GM) {
|
||||
info = await window.KISS_GM.getInfo();
|
||||
} else {
|
||||
info = GM.info;
|
||||
}
|
||||
// todo: 自定义接口 init 可能包含了 signal
|
||||
Object.assign(init, { timeout });
|
||||
|
||||
// Tampermonkey --> .connects
|
||||
// Violentmonkey --> .connect
|
||||
const connects = info?.script?.connects || info?.script?.connect || [];
|
||||
const url = new URL(input);
|
||||
const isSafe = connects.find((item) => url.hostname.endsWith(item));
|
||||
|
||||
if (isSafe) {
|
||||
const { body, headers, status, statusText } = window.KISS_GM
|
||||
? await window.KISS_GM.fetch(input, init)
|
||||
: await fetchGM(input, init);
|
||||
@@ -88,125 +78,69 @@ export const fetchApi = async ({ input, init, transOpts, apiSetting }) => {
|
||||
statusText,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (AbortSignal?.timeout) {
|
||||
Object.assign(init, { signal: AbortSignal.timeout(TIMEOUT) });
|
||||
if (AbortSignal?.timeout && !init.signal) {
|
||||
Object.assign(init, { signal: AbortSignal.timeout(timeout) });
|
||||
}
|
||||
|
||||
return fetch(input, init);
|
||||
};
|
||||
|
||||
/**
|
||||
* 请求池实例
|
||||
*/
|
||||
export const fetchPool = taskPool(
|
||||
fetchApi,
|
||||
null,
|
||||
DEFAULT_FETCH_INTERVAL,
|
||||
DEFAULT_FETCH_LIMIT
|
||||
);
|
||||
|
||||
/**
|
||||
* 请求数据统一接口
|
||||
* @param {*} input
|
||||
* @param {*} opts
|
||||
* 处理请求
|
||||
* @param {*} param0
|
||||
* @returns
|
||||
*/
|
||||
export const fetchData = async (
|
||||
input,
|
||||
{ useCache, usePool, transOpts, apiSetting, ...init } = {}
|
||||
) => {
|
||||
const cacheReq = await newCacheReq(input, init);
|
||||
let res;
|
||||
|
||||
// 查询缓存
|
||||
if (useCache) {
|
||||
try {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
res = await cache.match(cacheReq);
|
||||
} catch (err) {
|
||||
kissLog(err, "cache match");
|
||||
}
|
||||
}
|
||||
|
||||
if (!res) {
|
||||
// 发送请求
|
||||
if (usePool) {
|
||||
res = await fetchPool.push({ input, init, transOpts, apiSetting });
|
||||
} else {
|
||||
res = await fetchApi({ input, init, transOpts, apiSetting });
|
||||
}
|
||||
|
||||
if (!res?.ok) {
|
||||
const msg = {
|
||||
url: input,
|
||||
status: res.status,
|
||||
};
|
||||
if (res.headers.get("Content-Type")?.includes("json")) {
|
||||
msg.response = await res.json();
|
||||
}
|
||||
throw new Error(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
// 插入缓存
|
||||
if (useCache) {
|
||||
try {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
await cache.put(cacheReq, res.clone());
|
||||
} catch (err) {
|
||||
kissLog(err, "cache put");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = res.headers.get("Content-Type");
|
||||
if (contentType?.includes("json")) {
|
||||
return await res.json();
|
||||
}
|
||||
return await res.text();
|
||||
export const fetchHandle = async ({ input, init, opts }) => {
|
||||
const res = await fetchPatcher(input, init, opts);
|
||||
return parseResponse(res);
|
||||
};
|
||||
|
||||
/**
|
||||
* fetch 兼容性封装
|
||||
* @param {*} input
|
||||
* @param {*} opts
|
||||
* @param {*} args
|
||||
* @returns
|
||||
*/
|
||||
export const fetchPolyfill = async (input, opts) => {
|
||||
export const fnPolyfill = ({ fn, msg = MSG_FETCH, ...args }) => {
|
||||
// 插件
|
||||
if (isExt && !isBg()) {
|
||||
return sendBgMsg(msg, { ...args });
|
||||
}
|
||||
|
||||
// 油猴/网页/BackgroundPage
|
||||
return fn({ ...args });
|
||||
};
|
||||
|
||||
/**
|
||||
* 数据请求
|
||||
* @param {*} input
|
||||
* @param {*} init
|
||||
* @param {*} param1
|
||||
* @returns
|
||||
*/
|
||||
export const fetchData = async (
|
||||
input,
|
||||
init,
|
||||
{ useCache, usePool, fetchInterval, fetchLimit, ...opts } = {}
|
||||
) => {
|
||||
if (!input?.trim()) {
|
||||
throw new Error("URL is empty");
|
||||
}
|
||||
|
||||
// 插件
|
||||
if (isExt && !isBg()) {
|
||||
return await sendBgMsg(MSG_FETCH, { input, opts });
|
||||
// 使用缓存数据
|
||||
if (useCache) {
|
||||
const resCache = await getHttpCachePolyfill(input, init);
|
||||
if (resCache) {
|
||||
return resCache;
|
||||
}
|
||||
}
|
||||
|
||||
// 油猴/网页/BackgroundPage
|
||||
return await fetchData(input, opts);
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新 fetch pool 参数
|
||||
* @param {*} interval
|
||||
* @param {*} limit
|
||||
*/
|
||||
export const updateFetchPool = async (interval, limit) => {
|
||||
if (isExt) {
|
||||
await sendBgMsg(MSG_FETCH_LIMIT, { interval, limit });
|
||||
} else {
|
||||
fetchPool.update(interval, limit);
|
||||
// 通过任务池发送请求
|
||||
if (usePool) {
|
||||
const fetchPool = getFetchPool(fetchInterval, fetchLimit);
|
||||
return fetchPool.push(fnPolyfill, { fn: fetchHandle, input, init, opts });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空任务池
|
||||
*/
|
||||
export const clearFetchPool = async () => {
|
||||
if (isExt) {
|
||||
await sendBgMsg(MSG_FETCH_CLEAR);
|
||||
} else {
|
||||
fetchPool.clear();
|
||||
}
|
||||
// 直接请求
|
||||
return fnPolyfill({ fn: fetchHandle, input, init, opts });
|
||||
};
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { CACHE_NAME } from "../config";
|
||||
import { browser } from "./browser";
|
||||
import { apiBaiduLangdetect } from "../apis";
|
||||
import { kissLog } from "./log";
|
||||
|
||||
/**
|
||||
* 清除缓存数据
|
||||
*/
|
||||
export const tryClearCaches = async () => {
|
||||
try {
|
||||
caches.delete(CACHE_NAME);
|
||||
} catch (err) {
|
||||
kissLog(err, "clean caches");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 语言识别
|
||||
* @param {*} q
|
||||
* @returns
|
||||
*/
|
||||
export const tryDetectLang = async (q, useRemote = false) => {
|
||||
let lang = "";
|
||||
|
||||
if (useRemote) {
|
||||
try {
|
||||
lang = await apiBaiduLangdetect(q);
|
||||
} catch (err) {
|
||||
kissLog(err, "detect lang remote");
|
||||
}
|
||||
}
|
||||
|
||||
if (!lang) {
|
||||
try {
|
||||
const res = await browser?.i18n?.detectLanguage(q);
|
||||
lang = res?.languages?.[0]?.language;
|
||||
} catch (err) {
|
||||
kissLog(err, "detect lang local");
|
||||
}
|
||||
}
|
||||
|
||||
return lang;
|
||||
};
|
||||
@@ -1,25 +1,35 @@
|
||||
import { trustedTypesHelper } from "./trustedTypes";
|
||||
|
||||
// Function to inject inline JavaScript code
|
||||
export const injectInlineJs = (code) => {
|
||||
export const injectInlineJs = (code, id = "kiss-translator-inline-js") => {
|
||||
if (document.getElementById(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = document.createElement("script");
|
||||
el.setAttribute("data-source", "KISS-Calendar injectInlineJs");
|
||||
el.setAttribute("type", "text/javascript");
|
||||
el.textContent = code;
|
||||
document.body?.appendChild(el);
|
||||
el.type = "text/javascript";
|
||||
el.id = id;
|
||||
el.textContent = trustedTypesHelper.createScript(code);
|
||||
(document.head || document.documentElement).appendChild(el);
|
||||
};
|
||||
|
||||
// Function to inject external JavaScript file
|
||||
export const injectExternalJs = (src) => {
|
||||
export const injectExternalJs = (src, id = "kiss-translator-external-js") => {
|
||||
if (document.getElementById(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = document.createElement("script");
|
||||
el.setAttribute("data-source", "KISS-Calendar injectExternalJs");
|
||||
el.setAttribute("type", "text/javascript");
|
||||
el.setAttribute("src", src);
|
||||
document.body?.appendChild(el);
|
||||
el.type = "text/javascript";
|
||||
el.id = id;
|
||||
el.src = trustedTypesHelper.createScriptURL(src);
|
||||
(document.head || document.documentElement).appendChild(el);
|
||||
};
|
||||
|
||||
// Function to inject internal CSS code
|
||||
export const injectInternalCss = (styles) => {
|
||||
const el = document.createElement("style");
|
||||
el.setAttribute("data-source", "KISS-Calendar injectInternalCss");
|
||||
el.setAttribute("data-source", "kiss-inject injectInternalCss");
|
||||
el.textContent = styles;
|
||||
document.head?.appendChild(el);
|
||||
};
|
||||
@@ -27,7 +37,7 @@ export const injectInternalCss = (styles) => {
|
||||
// Function to inject external CSS file
|
||||
export const injectExternalCss = (href) => {
|
||||
const el = document.createElement("link");
|
||||
el.setAttribute("data-source", "KISS-Calendar injectExternalCss");
|
||||
el.setAttribute("data-source", "kiss-inject injectExternalCss");
|
||||
el.setAttribute("rel", "stylesheet");
|
||||
el.setAttribute("type", "text/css");
|
||||
el.setAttribute("href", href);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
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 { genEventName, removeEndchar, matchInputStr } from "./utils";
|
||||
import { stepShortcutRegister } from "./shortcut";
|
||||
import { apiTranslate } from "../apis";
|
||||
import { loadingSvg } from "./svg";
|
||||
import { createLoadingSVG } from "./svg";
|
||||
import { kissLog } from "./log";
|
||||
|
||||
function isInputNode(node) {
|
||||
@@ -18,34 +18,20 @@ function isEditAbleNode(node) {
|
||||
return node.hasAttribute("contenteditable");
|
||||
}
|
||||
|
||||
function selectContent(node) {
|
||||
function replaceContentEditableText(node, newText) {
|
||||
node.focus();
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
function pasteContentEvent(node, text) {
|
||||
node.focus();
|
||||
const data = new DataTransfer();
|
||||
data.setData("text/plain", text);
|
||||
range.deleteContents();
|
||||
const textNode = document.createTextNode(newText);
|
||||
range.insertNode(textNode);
|
||||
|
||||
const event = new ClipboardEvent("paste", { clipboardData: data });
|
||||
document.dispatchEvent(event);
|
||||
data.clearData();
|
||||
}
|
||||
|
||||
function pasteContentCommand(node, text) {
|
||||
node.focus();
|
||||
document.execCommand("insertText", false, text);
|
||||
}
|
||||
|
||||
function collapseToEnd(node) {
|
||||
node.focus();
|
||||
const selection = window.getSelection();
|
||||
selection.collapseToEnd();
|
||||
}
|
||||
|
||||
@@ -57,65 +43,108 @@ function getNodeText(node) {
|
||||
}
|
||||
|
||||
function addLoading(node, loadingId) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
const div = document.createElement("div");
|
||||
div.id = loadingId;
|
||||
div.innerHTML = loadingSvg;
|
||||
div.appendChild(createLoadingSVG());
|
||||
div.style.cssText = `
|
||||
width: ${node.offsetWidth}px;
|
||||
height: ${node.offsetHeight}px;
|
||||
line-height: ${node.offsetHeight}px;
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
left: ${rect.left}px;
|
||||
top: ${rect.top}px;
|
||||
width: ${rect.width}px;
|
||||
height: ${rect.height}px;
|
||||
line-height: ${rect.height}px;
|
||||
text-align: center;
|
||||
left: ${node.offsetLeft}px;
|
||||
top: ${node.offsetTop}px;
|
||||
z-index: 2147483647;
|
||||
pointer-events: none; /* 允许点击穿透 */
|
||||
`;
|
||||
node.offsetParent?.appendChild(div);
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
|
||||
function removeLoading(node, loadingId) {
|
||||
const div = node.offsetParent.querySelector(`#${loadingId}`);
|
||||
if (div) {
|
||||
div.remove();
|
||||
}
|
||||
function removeLoading(loadingId) {
|
||||
const div = document.getElementById(loadingId);
|
||||
if (div) div.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入框翻译
|
||||
*/
|
||||
export default function inputTranslate({
|
||||
inputRule: {
|
||||
transOpen,
|
||||
triggerShortcut,
|
||||
translator,
|
||||
fromLang,
|
||||
toLang,
|
||||
export class InputTranslator {
|
||||
#config;
|
||||
#unregisterShortcut = null;
|
||||
#isEnabled = false;
|
||||
#triggerShortcut; // 用于缓存快捷键
|
||||
|
||||
constructor({ inputRule = DEFAULT_INPUT_RULE, transApis = [] } = {}) {
|
||||
this.#config = { inputRule, transApis };
|
||||
|
||||
const { triggerShortcut: initialTriggerShortcut } = this.#config.inputRule;
|
||||
if (initialTriggerShortcut && initialTriggerShortcut.length > 0) {
|
||||
this.#triggerShortcut = initialTriggerShortcut;
|
||||
} else {
|
||||
this.#triggerShortcut = DEFAULT_INPUT_SHORTCUT;
|
||||
}
|
||||
|
||||
if (this.#config.inputRule.transOpen) {
|
||||
this.enable();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用输入翻译功能
|
||||
*/
|
||||
enable() {
|
||||
if (this.#isEnabled || !this.#config.inputRule.transOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { triggerCount, triggerTime } = this.#config.inputRule;
|
||||
this.#unregisterShortcut = stepShortcutRegister(
|
||||
this.#triggerShortcut,
|
||||
this.#handleTranslate.bind(this),
|
||||
triggerCount,
|
||||
triggerTime,
|
||||
transSign,
|
||||
} = DEFAULT_INPUT_RULE,
|
||||
transApis,
|
||||
}) {
|
||||
if (!transOpen) {
|
||||
triggerTime
|
||||
);
|
||||
|
||||
this.#isEnabled = true;
|
||||
kissLog("Input Translator enabled.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用输入翻译功能
|
||||
*/
|
||||
disable() {
|
||||
if (!this.#isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiSetting = transApis?.[translator] || DEFAULT_TRANS_APIS[translator];
|
||||
if (triggerShortcut.length === 0) {
|
||||
triggerShortcut = DEFAULT_INPUT_SHORTCUT;
|
||||
triggerCount = 1;
|
||||
if (this.#unregisterShortcut) {
|
||||
this.#unregisterShortcut();
|
||||
this.#unregisterShortcut = null;
|
||||
}
|
||||
this.#isEnabled = false;
|
||||
kissLog("Input Translator disabled.");
|
||||
}
|
||||
|
||||
stepShortcutRegister(
|
||||
triggerShortcut,
|
||||
async () => {
|
||||
/**
|
||||
* 切换启用/禁用状态
|
||||
*/
|
||||
toggle() {
|
||||
if (this.#isEnabled) {
|
||||
this.disable();
|
||||
} else {
|
||||
this.enable();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译核心逻辑
|
||||
* @private
|
||||
*/
|
||||
async #handleTranslate() {
|
||||
let node = document.activeElement;
|
||||
if (!node) return;
|
||||
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (node.shadowRoot) {
|
||||
while (node.shadowRoot && node.shadowRoot.activeElement) {
|
||||
node = node.shadowRoot.activeElement;
|
||||
}
|
||||
|
||||
@@ -123,25 +152,32 @@ export default function inputTranslate({
|
||||
return;
|
||||
}
|
||||
|
||||
const { apiSlug, transSign, triggerCount } = this.#config.inputRule;
|
||||
let { fromLang, toLang } = this.#config.inputRule;
|
||||
|
||||
let initText = getNodeText(node);
|
||||
if (triggerShortcut.length === 1 && triggerShortcut[0].length === 1) {
|
||||
// todo: remove multiple char
|
||||
initText = removeEndchar(initText, triggerShortcut[0], triggerCount);
|
||||
}
|
||||
if (!initText.trim()) {
|
||||
return;
|
||||
|
||||
if (
|
||||
this.#triggerShortcut.length === 1 &&
|
||||
this.#triggerShortcut[0].length === 1
|
||||
) {
|
||||
initText = removeEndchar(
|
||||
initText,
|
||||
this.#triggerShortcut[0],
|
||||
triggerCount
|
||||
);
|
||||
}
|
||||
|
||||
if (!initText.trim()) return;
|
||||
|
||||
let text = initText;
|
||||
if (transSign) {
|
||||
const res = matchInputStr(text, transSign);
|
||||
if (res) {
|
||||
let lang = res[1];
|
||||
if (lang === "zh" || lang === "cn") {
|
||||
lang = "zh-CN";
|
||||
} else if (lang === "tw" || lang === "hk") {
|
||||
lang = "zh-TW";
|
||||
}
|
||||
if (lang === "zh" || lang === "cn") lang = "zh-CN";
|
||||
else if (lang === "tw" || lang === "hk") lang = "zh-TW";
|
||||
|
||||
if (lang && OPT_LANGS_LIST.includes(lang)) {
|
||||
toLang = lang;
|
||||
}
|
||||
@@ -149,51 +185,62 @@ export default function inputTranslate({
|
||||
}
|
||||
}
|
||||
|
||||
// console.log("input -->", text);
|
||||
const apiSetting =
|
||||
this.#config.transApis.find((api) => api.apiSlug === apiSlug) ||
|
||||
DEFAULT_API_SETTING;
|
||||
const loadingId = "kiss-loading-" + genEventName();
|
||||
|
||||
const loadingId = "kiss-" + genEventName();
|
||||
try {
|
||||
addLoading(node, loadingId);
|
||||
|
||||
const [trText, isSame] = await apiTranslate({
|
||||
translator,
|
||||
text,
|
||||
fromLang,
|
||||
toLang,
|
||||
apiSetting,
|
||||
});
|
||||
if (!trText || isSame) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trText || isSame) return;
|
||||
|
||||
if (isInputNode(node)) {
|
||||
node.value = trText;
|
||||
node.dispatchEvent(
|
||||
new Event("input", { bubbles: true, cancelable: true })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
selectContent(node);
|
||||
await sleep(200);
|
||||
|
||||
pasteContentEvent(node, trText);
|
||||
await sleep(200);
|
||||
|
||||
// todo: use includes?
|
||||
if (getNodeText(node).startsWith(initText)) {
|
||||
pasteContentCommand(node, trText);
|
||||
await sleep(100);
|
||||
} else {
|
||||
collapseToEnd(node);
|
||||
replaceContentEditableText(node, trText);
|
||||
}
|
||||
} catch (err) {
|
||||
kissLog(err, "translate input");
|
||||
kissLog("Translate input error:", err);
|
||||
} finally {
|
||||
removeLoading(node, loadingId);
|
||||
removeLoading(loadingId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置
|
||||
*/
|
||||
updateConfig({ inputRule, transApis }) {
|
||||
const wasEnabled = this.#isEnabled;
|
||||
if (wasEnabled) {
|
||||
this.disable();
|
||||
}
|
||||
|
||||
if (inputRule) {
|
||||
this.#config.inputRule = inputRule;
|
||||
}
|
||||
if (transApis) {
|
||||
this.#config.transApis = transApis;
|
||||
}
|
||||
|
||||
const { triggerShortcut: initialTriggerShortcut } = this.#config.inputRule;
|
||||
this.#triggerShortcut =
|
||||
initialTriggerShortcut && initialTriggerShortcut.length > 0
|
||||
? initialTriggerShortcut
|
||||
: DEFAULT_INPUT_SHORTCUT;
|
||||
|
||||
if (wasEnabled) {
|
||||
this.enable();
|
||||
}
|
||||
}
|
||||
},
|
||||
triggerCount,
|
||||
triggerTime
|
||||
);
|
||||
}
|
||||
|
||||
16
src/libs/interpreter.js
Normal file
16
src/libs/interpreter.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import Sval from "sval";
|
||||
|
||||
const interpreter = new Sval({
|
||||
// ECMA Version of the code
|
||||
// 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15
|
||||
// or 2015 | 2016 | 2017 | 2018 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024
|
||||
// or "latest"
|
||||
ecmaVer: "latest",
|
||||
// Code source type
|
||||
// "script" or "module"
|
||||
sourceType: "script",
|
||||
// Whether the code runs in a sandbox
|
||||
sandBox: true,
|
||||
});
|
||||
|
||||
export default interpreter;
|
||||
171
src/libs/log.js
171
src/libs/log.js
@@ -1,12 +1,161 @@
|
||||
/**
|
||||
* 日志函数
|
||||
* @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" }, // 特殊级别,用于关闭所有日志
|
||||
};
|
||||
|
||||
function findLogLevelByValue(value) {
|
||||
return Object.values(LogLevel).find((level) => level.value === value);
|
||||
}
|
||||
|
||||
function findLogLevelByName(name) {
|
||||
if (typeof name !== "string" || name.length === 0) return undefined;
|
||||
const upperCaseName = name.toUpperCase();
|
||||
return Object.values(LogLevel).find((level) => level.name === upperCaseName);
|
||||
}
|
||||
|
||||
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) {
|
||||
let newLevelObject;
|
||||
|
||||
if (typeof level === "string") {
|
||||
newLevelObject = findLogLevelByName(level);
|
||||
if (!newLevelObject) {
|
||||
this.warn(
|
||||
`Invalid log level name provided: "${level}". Keeping current level.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (typeof level === "number") {
|
||||
newLevelObject = findLogLevelByValue(level);
|
||||
if (!newLevelObject) {
|
||||
this.warn(
|
||||
`Invalid log level value provided: ${level}. Keeping current level.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (level && typeof level.value === "number") {
|
||||
newLevelObject = level;
|
||||
} else {
|
||||
this.warn(
|
||||
"Invalid argument passed to setLevel. Must be a LogLevel object, number, or string."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.level = newLevelObject;
|
||||
console.log(
|
||||
`[${this.config.prefix}] Log level dynamically set to ${this.config.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);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
export const kissLog = logger.info.bind(logger);
|
||||
|
||||
// todo:debug日志埋点
|
||||
|
||||
206
src/libs/pool.js
206
src/libs/pool.js
@@ -1,80 +1,170 @@
|
||||
import { DEFAULT_FETCH_INTERVAL, DEFAULT_FETCH_LIMIT } from "../config";
|
||||
import { kissLog } from "./log";
|
||||
|
||||
/**
|
||||
* 任务池
|
||||
* @param {*} fn
|
||||
* @param {*} preFn
|
||||
* @param {*} _interval
|
||||
* @param {*} _limit
|
||||
* @returns
|
||||
*/
|
||||
export const taskPool = (
|
||||
fn,
|
||||
preFn,
|
||||
_interval = 100,
|
||||
_limit = 100,
|
||||
_retryInteral = 1000
|
||||
) => {
|
||||
const pool = [];
|
||||
const maxRetry = 2; // 最大重试次数
|
||||
let maxCount = _limit; // 最大数量
|
||||
let curCount = 0; // 当前数量
|
||||
let interval = _interval; // 间隔时间
|
||||
let timer = null;
|
||||
class TaskPool {
|
||||
#pool = [];
|
||||
|
||||
const run = async () => {
|
||||
// console.log("timer", timer);
|
||||
timer && clearTimeout(timer);
|
||||
timer = setTimeout(run, interval);
|
||||
#maxRetry = 2; // 最大重试次数
|
||||
#retryInterval = 1000; // 重试间隔时间
|
||||
#limit; // 最大并发数
|
||||
#interval; // 任务最小启动间隔
|
||||
|
||||
#currentConcurrent = 0; // 当前正在执行的任务数
|
||||
#lastExecutionTime = 0; // 上一个任务的启动时间
|
||||
#schedulerTimer = null; // 用于调度下一个任务的定时器
|
||||
|
||||
constructor(
|
||||
interval = DEFAULT_FETCH_INTERVAL,
|
||||
limit = DEFAULT_FETCH_LIMIT,
|
||||
retryInterval = 1000
|
||||
) {
|
||||
this.#interval = interval;
|
||||
this.#limit = limit;
|
||||
this.#retryInterval = retryInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度器
|
||||
*/
|
||||
#scheduleNext() {
|
||||
if (this.#schedulerTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#currentConcurrent >= this.#limit || this.#pool.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceLast = now - this.#lastExecutionTime;
|
||||
const delay = Math.max(0, this.#interval - timeSinceLast);
|
||||
|
||||
this.#schedulerTimer = setTimeout(() => {
|
||||
this.#schedulerTimer = null;
|
||||
if (this.#currentConcurrent < this.#limit && this.#pool.length > 0) {
|
||||
const task = this.#pool.shift();
|
||||
if (task) {
|
||||
this.#lastExecutionTime = Date.now();
|
||||
this.#execute(task);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#pool.length > 0) {
|
||||
this.#scheduleNext();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单个任务
|
||||
* @param {object} task - 任务对象
|
||||
*/
|
||||
async #execute(task) {
|
||||
this.#currentConcurrent++;
|
||||
const { fn, args, resolve, reject, retry } = task;
|
||||
|
||||
if (curCount < maxCount) {
|
||||
const item = pool.shift();
|
||||
if (item) {
|
||||
curCount++;
|
||||
const { args, resolve, reject, retry } = item;
|
||||
try {
|
||||
const preArgs = preFn ? await preFn(item.args) : {};
|
||||
const res = await fn({ ...args, ...preArgs });
|
||||
const res = await fn(args);
|
||||
resolve(res);
|
||||
} catch (err) {
|
||||
kissLog(err, "task");
|
||||
if (retry < maxRetry) {
|
||||
const retryTimer = setTimeout(() => {
|
||||
clearTimeout(retryTimer);
|
||||
pool.push({ args, resolve, reject, retry: retry + 1 });
|
||||
}, _retryInteral);
|
||||
kissLog("task pool", err);
|
||||
if (retry < this.#maxRetry) {
|
||||
setTimeout(() => {
|
||||
this.#pool.unshift({ ...task, retry: retry + 1 }); // unshift 保证重试任务优先
|
||||
this.#scheduleNext();
|
||||
}, this.#retryInterval);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
} finally {
|
||||
curCount--;
|
||||
this.#currentConcurrent--;
|
||||
this.#scheduleNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
push: async (args) => {
|
||||
if (!timer) {
|
||||
run();
|
||||
}
|
||||
/**
|
||||
* 向任务池中添加一个新任务
|
||||
* @param {Function} fn - 要执行的异步函数
|
||||
* @param {*} args - 函数的参数
|
||||
* @returns {Promise}
|
||||
*/
|
||||
push(fn, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pool.push({ args, resolve, reject, retry: 0 });
|
||||
this.#pool.push({ fn, args, resolve, reject, retry: 0 });
|
||||
this.#scheduleNext();
|
||||
});
|
||||
},
|
||||
update: (_interval = 100, _limit = 100) => {
|
||||
if (_interval >= 0 && _interval <= 5000 && _interval !== interval) {
|
||||
interval = _interval;
|
||||
}
|
||||
if (_limit >= 1 && _limit <= 100 && _limit !== maxCount) {
|
||||
maxCount = _limit;
|
||||
|
||||
/**
|
||||
* 更新任务池的配置
|
||||
* @param {number} interval - 新的最小任务间隔
|
||||
* @param {number} limit - 新的最大并发数
|
||||
*/
|
||||
update(interval, limit) {
|
||||
if (interval >= 0) {
|
||||
this.#interval = interval;
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
pool.length = 0;
|
||||
curCount = 0;
|
||||
timer && clearTimeout(timer);
|
||||
timer = null;
|
||||
},
|
||||
};
|
||||
if (limit >= 1) {
|
||||
this.#limit = limit;
|
||||
}
|
||||
|
||||
this.#scheduleNext();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空任务池
|
||||
*/
|
||||
clear() {
|
||||
for (const task of this.#pool) {
|
||||
task.reject("the task pool was cleared");
|
||||
}
|
||||
|
||||
this.#pool.length = 0;
|
||||
if (this.#schedulerTimer) {
|
||||
clearTimeout(this.#schedulerTimer);
|
||||
this.#schedulerTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求池实例
|
||||
*/
|
||||
let fetchPool;
|
||||
|
||||
/**
|
||||
* 获取请求池实例
|
||||
* @param interval
|
||||
* @param limit
|
||||
* @returns
|
||||
*/
|
||||
export const getFetchPool = (interval, limit) => {
|
||||
if (!fetchPool) {
|
||||
fetchPool = new TaskPool(
|
||||
interval ?? DEFAULT_FETCH_INTERVAL,
|
||||
limit ?? DEFAULT_FETCH_LIMIT
|
||||
);
|
||||
} else if (interval && limit) {
|
||||
updateFetchPool(interval, limit);
|
||||
}
|
||||
return fetchPool;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新请求池参数
|
||||
* @param {*} interval
|
||||
* @param {*} limit
|
||||
*/
|
||||
export const updateFetchPool = (interval, limit) => {
|
||||
fetchPool?.update(interval, limit);
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空请求池
|
||||
*/
|
||||
export const clearFetchPool = () => {
|
||||
fetchPool?.clear();
|
||||
};
|
||||
|
||||
317
src/libs/req.js
317
src/libs/req.js
@@ -1,317 +0,0 @@
|
||||
import queryString from "query-string";
|
||||
import {
|
||||
OPT_TRANS_GOOGLE,
|
||||
OPT_TRANS_MICROSOFT,
|
||||
OPT_TRANS_DEEPL,
|
||||
OPT_TRANS_DEEPLFREE,
|
||||
OPT_TRANS_DEEPLX,
|
||||
OPT_TRANS_BAIDU,
|
||||
OPT_TRANS_TENCENT,
|
||||
OPT_TRANS_OPENAI,
|
||||
OPT_TRANS_GEMINI,
|
||||
OPT_TRANS_CLOUDFLAREAI,
|
||||
OPT_TRANS_CUSTOMIZE,
|
||||
URL_MICROSOFT_TRAN,
|
||||
URL_TENCENT_TRANSMART,
|
||||
PROMPT_PLACE_FROM,
|
||||
PROMPT_PLACE_TO,
|
||||
PROMPT_PLACE_TEXT,
|
||||
} from "../config";
|
||||
import { msAuth } from "./auth";
|
||||
import { genDeeplFree } from "../apis/deepl";
|
||||
import { genBaidu } from "../apis/baidu";
|
||||
|
||||
const keyMap = new Map();
|
||||
|
||||
// 轮询key
|
||||
const keyPick = (translator, key = "") => {
|
||||
const keys = key
|
||||
.split(/\n|,/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (keys.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const preIndex = keyMap.get(translator) ?? -1;
|
||||
const curIndex = (preIndex + 1) % keys.length;
|
||||
keyMap.set(translator, curIndex);
|
||||
|
||||
return keys[curIndex];
|
||||
};
|
||||
|
||||
/**
|
||||
* 构造缓存 request
|
||||
* @param {*} request
|
||||
* @returns
|
||||
*/
|
||||
export const newCacheReq = async (input, init) => {
|
||||
let request = new Request(input, init);
|
||||
if (request.method !== "GET") {
|
||||
const body = await request.text();
|
||||
const cacheUrl = new URL(request.url);
|
||||
cacheUrl.pathname += body;
|
||||
request = new Request(cacheUrl.toString(), { method: "GET" });
|
||||
}
|
||||
|
||||
return request;
|
||||
};
|
||||
|
||||
const genGoogle = ({ text, from, to, url, key }) => {
|
||||
const params = {
|
||||
client: "gtx",
|
||||
dt: "t",
|
||||
dj: 1,
|
||||
ie: "UTF-8",
|
||||
sl: from,
|
||||
tl: to,
|
||||
q: text,
|
||||
};
|
||||
const input = `${url}?${queryString.stringify(params)}`;
|
||||
const init = {
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
},
|
||||
};
|
||||
if (key) {
|
||||
init.headers.Authorization = `Bearer ${key}`;
|
||||
}
|
||||
|
||||
return [input, init];
|
||||
};
|
||||
|
||||
const genMicrosoft = async ({ text, from, to }) => {
|
||||
const [token] = await msAuth();
|
||||
const params = {
|
||||
from,
|
||||
to,
|
||||
"api-version": "3.0",
|
||||
};
|
||||
const input = `${URL_MICROSOFT_TRAN}?${queryString.stringify(params)}`;
|
||||
const init = {
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify([{ Text: text }]),
|
||||
};
|
||||
|
||||
return [input, init];
|
||||
};
|
||||
|
||||
const genDeepl = ({ text, from, to, url, key }) => {
|
||||
const data = {
|
||||
text: [text],
|
||||
target_lang: to,
|
||||
source_lang: from,
|
||||
// split_sentences: "0",
|
||||
};
|
||||
const init = {
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
Authorization: `DeepL-Auth-Key ${key}`,
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
};
|
||||
|
||||
return [url, init];
|
||||
};
|
||||
|
||||
const genDeeplX = ({ text, from, to, url, key }) => {
|
||||
const data = {
|
||||
text,
|
||||
target_lang: to,
|
||||
source_lang: from,
|
||||
};
|
||||
|
||||
const init = {
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
};
|
||||
if (key) {
|
||||
init.headers.Authorization = `Bearer ${key}`;
|
||||
}
|
||||
|
||||
return [url, init];
|
||||
};
|
||||
|
||||
const genTencent = ({ text, from, to }) => {
|
||||
const data = {
|
||||
header: {
|
||||
fn: "auto_translation_block",
|
||||
},
|
||||
source: {
|
||||
text_block: text,
|
||||
lang: from,
|
||||
},
|
||||
target: {
|
||||
lang: to,
|
||||
},
|
||||
};
|
||||
|
||||
const init = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
};
|
||||
|
||||
return [URL_TENCENT_TRANSMART, init];
|
||||
};
|
||||
|
||||
const genOpenAI = ({ text, from, to, url, key, prompt, model }) => {
|
||||
prompt = prompt
|
||||
.replaceAll(PROMPT_PLACE_FROM, from)
|
||||
.replaceAll(PROMPT_PLACE_TO, to);
|
||||
|
||||
const data = {
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: prompt,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: text,
|
||||
},
|
||||
],
|
||||
temperature: 0,
|
||||
max_tokens: 256,
|
||||
};
|
||||
|
||||
const init = {
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
Authorization: `Bearer ${key}`, // OpenAI
|
||||
"api-key": key, // Azure OpenAI
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
};
|
||||
|
||||
return [url, init];
|
||||
};
|
||||
|
||||
const genGemini = ({ text, from, to, url, key, prompt, model }) => {
|
||||
prompt = prompt
|
||||
.replaceAll(PROMPT_PLACE_FROM, from)
|
||||
.replaceAll(PROMPT_PLACE_TO, to)
|
||||
.replaceAll(PROMPT_PLACE_TEXT, text);
|
||||
|
||||
const data = {
|
||||
contents: [
|
||||
{
|
||||
// role: "user",
|
||||
parts: [
|
||||
{
|
||||
text: prompt,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const input = `${url}/${model}:generateContent?key=${key}`;
|
||||
const init = {
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
};
|
||||
|
||||
return [input, init];
|
||||
};
|
||||
|
||||
const genCloudflareAI = ({ text, from, to, url, key }) => {
|
||||
const data = {
|
||||
text,
|
||||
source_lang: from,
|
||||
target_lang: to,
|
||||
};
|
||||
|
||||
const init = {
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
};
|
||||
|
||||
return [url, init];
|
||||
};
|
||||
|
||||
const genCustom = ({ text, from, to, url, key }) => {
|
||||
const data = {
|
||||
text,
|
||||
from,
|
||||
to,
|
||||
};
|
||||
const init = {
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
};
|
||||
if (key) {
|
||||
init.headers.Authorization = `Bearer ${key}`;
|
||||
}
|
||||
|
||||
return [url, init];
|
||||
};
|
||||
|
||||
/**
|
||||
* 构造翻译接口 request
|
||||
* @param {*}
|
||||
* @returns
|
||||
*/
|
||||
export const newTransReq = ({ translator, text, from, to }, apiSetting) => {
|
||||
const args = { text, from, to, ...apiSetting };
|
||||
|
||||
switch (translator) {
|
||||
case OPT_TRANS_DEEPL:
|
||||
case OPT_TRANS_OPENAI:
|
||||
case OPT_TRANS_GEMINI:
|
||||
case OPT_TRANS_CLOUDFLAREAI:
|
||||
args.key = keyPick(translator, args.key);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
switch (translator) {
|
||||
case OPT_TRANS_GOOGLE:
|
||||
return genGoogle(args);
|
||||
case OPT_TRANS_MICROSOFT:
|
||||
return genMicrosoft(args);
|
||||
case OPT_TRANS_DEEPL:
|
||||
return genDeepl(args);
|
||||
case OPT_TRANS_DEEPLFREE:
|
||||
return genDeeplFree(args);
|
||||
case OPT_TRANS_DEEPLX:
|
||||
return genDeeplX(args);
|
||||
case OPT_TRANS_BAIDU:
|
||||
return genBaidu(args);
|
||||
case OPT_TRANS_TENCENT:
|
||||
return genTencent(args);
|
||||
case OPT_TRANS_OPENAI:
|
||||
return genOpenAI(args);
|
||||
case OPT_TRANS_GEMINI:
|
||||
return genGemini(args);
|
||||
case OPT_TRANS_CLOUDFLAREAI:
|
||||
return genCloudflareAI(args);
|
||||
case OPT_TRANS_CUSTOMIZE:
|
||||
return genCustom(args);
|
||||
default:
|
||||
throw new Error(`[trans] translator: ${translator} not support`);
|
||||
}
|
||||
};
|
||||
@@ -1,18 +1,17 @@
|
||||
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,
|
||||
// OPT_TIMING_ALL,
|
||||
DEFAULT_RULE,
|
||||
GLOBLA_RULE,
|
||||
} from "../config";
|
||||
import { loadOrFetchSubRules } from "./subRules";
|
||||
import { getRulesWithDefault, setRules } from "./storage";
|
||||
import { trySyncRules } from "./sync";
|
||||
import { FIXER_ALL } from "./webfix";
|
||||
// import { FIXER_ALL } from "./webfix";
|
||||
import { kissLog } from "./log";
|
||||
|
||||
/**
|
||||
@@ -21,36 +20,17 @@ import { kissLog } from "./log";
|
||||
* @param {string} href
|
||||
* @returns
|
||||
*/
|
||||
export const matchRule = async (
|
||||
href,
|
||||
{ injectRules, subrulesList, owSubrule }
|
||||
) => {
|
||||
export const matchRule = async (href, { injectRules, subrulesList }) => {
|
||||
const rules = await getRulesWithDefault();
|
||||
if (injectRules) {
|
||||
try {
|
||||
const selectedSub = subrulesList.find((item) => item.selected);
|
||||
if (selectedSub?.url) {
|
||||
const mixRule = {};
|
||||
Object.entries(owSubrule)
|
||||
.filter(([key, val]) => {
|
||||
if (
|
||||
owSubrule.textStyle === REMAIN_KEY &&
|
||||
(key === "bgColor" || key === "textDiyStyle")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return val !== REMAIN_KEY;
|
||||
})
|
||||
.forEach(([key, val]) => {
|
||||
mixRule[key] = val;
|
||||
});
|
||||
|
||||
let subRules = await loadOrFetchSubRules(selectedSub.url);
|
||||
subRules = subRules.map((item) => ({ ...item, ...mixRule }));
|
||||
const subRules = await loadOrFetchSubRules(selectedSub.url);
|
||||
rules.splice(-1, 0, ...subRules);
|
||||
}
|
||||
} catch (err) {
|
||||
kissLog(err, "load injectRules");
|
||||
kissLog("load injectRules", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,12 +48,19 @@ export const matchRule = async (
|
||||
[
|
||||
"selector",
|
||||
"keepSelector",
|
||||
"rootsSelector",
|
||||
"ignoreSelector",
|
||||
"terms",
|
||||
"aiTerms",
|
||||
"selectStyle",
|
||||
"parentStyle",
|
||||
"grandStyle",
|
||||
"injectJs",
|
||||
"injectCss",
|
||||
"fixerSelector",
|
||||
// "fixerSelector",
|
||||
"transStartHook",
|
||||
"transEndHook",
|
||||
// "transRemoveHook",
|
||||
].forEach((key) => {
|
||||
if (!rule[key]?.trim()) {
|
||||
rule[key] = globalRule[key];
|
||||
@@ -81,26 +68,29 @@ export const matchRule = async (
|
||||
});
|
||||
|
||||
[
|
||||
"translator",
|
||||
"apiSlug",
|
||||
"fromLang",
|
||||
"toLang",
|
||||
"transOpen",
|
||||
"transOnly",
|
||||
"transTiming",
|
||||
// "transTiming",
|
||||
"autoScan",
|
||||
"hasRichText",
|
||||
"hasShadowroot",
|
||||
"transTag",
|
||||
"transTitle",
|
||||
"detectRemote",
|
||||
"fixerFunc",
|
||||
// "detectRemote",
|
||||
// "fixerFunc",
|
||||
].forEach((key) => {
|
||||
if (rule[key] === undefined || rule[key] === GLOBAL_KEY) {
|
||||
if (!rule[key] || rule[key] === GLOBAL_KEY) {
|
||||
rule[key] = globalRule[key];
|
||||
}
|
||||
});
|
||||
|
||||
if (!rule.skipLangs || rule.skipLangs.length === 0) {
|
||||
rule.skipLangs = globalRule.skipLangs;
|
||||
}
|
||||
if (rule.textStyle === GLOBAL_KEY) {
|
||||
// if (!rule.skipLangs || rule.skipLangs.length === 0) {
|
||||
// rule.skipLangs = globalRule.skipLangs;
|
||||
// }
|
||||
if (!rule.textStyle || rule.textStyle === GLOBAL_KEY) {
|
||||
rule.textStyle = globalRule.textStyle;
|
||||
rule.bgColor = globalRule.bgColor;
|
||||
rule.textDiyStyle = globalRule.textDiyStyle;
|
||||
@@ -142,12 +132,16 @@ export const checkRules = (rules) => {
|
||||
pattern,
|
||||
selector,
|
||||
keepSelector,
|
||||
rootsSelector,
|
||||
ignoreSelector,
|
||||
terms,
|
||||
aiTerms,
|
||||
selectStyle,
|
||||
parentStyle,
|
||||
grandStyle,
|
||||
injectJs,
|
||||
injectCss,
|
||||
translator,
|
||||
apiSlug,
|
||||
fromLang,
|
||||
toLang,
|
||||
textStyle,
|
||||
@@ -155,37 +149,57 @@ export const checkRules = (rules) => {
|
||||
bgColor,
|
||||
textDiyStyle,
|
||||
transOnly,
|
||||
transTiming,
|
||||
autoScan,
|
||||
hasRichText,
|
||||
hasShadowroot,
|
||||
// transTiming,
|
||||
transTag,
|
||||
transTitle,
|
||||
detectRemote,
|
||||
skipLangs,
|
||||
fixerSelector,
|
||||
fixerFunc,
|
||||
// detectRemote,
|
||||
// skipLangs,
|
||||
// fixerSelector,
|
||||
// fixerFunc,
|
||||
transStartHook,
|
||||
transEndHook,
|
||||
// transRemoveHook,
|
||||
}) => ({
|
||||
pattern: pattern.trim(),
|
||||
selector: type(selector) === "string" ? selector : "",
|
||||
keepSelector: type(keepSelector) === "string" ? keepSelector : "",
|
||||
rootsSelector: type(rootsSelector) === "string" ? rootsSelector : "",
|
||||
ignoreSelector: type(ignoreSelector) === "string" ? ignoreSelector : "",
|
||||
terms: type(terms) === "string" ? terms : "",
|
||||
aiTerms: type(aiTerms) === "string" ? aiTerms : "",
|
||||
selectStyle: type(selectStyle) === "string" ? selectStyle : "",
|
||||
parentStyle: type(parentStyle) === "string" ? parentStyle : "",
|
||||
grandStyle: type(grandStyle) === "string" ? grandStyle : "",
|
||||
injectJs: type(injectJs) === "string" ? injectJs : "",
|
||||
injectCss: type(injectCss) === "string" ? injectCss : "",
|
||||
bgColor: type(bgColor) === "string" ? bgColor : "",
|
||||
textDiyStyle: type(textDiyStyle) === "string" ? textDiyStyle : "",
|
||||
translator: matchValue([GLOBAL_KEY, ...OPT_TRANS_ALL], translator),
|
||||
apiSlug:
|
||||
type(apiSlug) === "string" && apiSlug.trim() !== ""
|
||||
? apiSlug.trim()
|
||||
: GLOBAL_KEY,
|
||||
fromLang: matchValue([GLOBAL_KEY, ...fromLangs], fromLang),
|
||||
toLang: matchValue([GLOBAL_KEY, ...toLangs], toLang),
|
||||
textStyle: matchValue([GLOBAL_KEY, ...OPT_STYLE_ALL], textStyle),
|
||||
transOpen: matchValue([GLOBAL_KEY, "true", "false"], transOpen),
|
||||
transOnly: matchValue([GLOBAL_KEY, "true", "false"], transOnly),
|
||||
transTiming: matchValue([GLOBAL_KEY, ...OPT_TIMING_ALL], transTiming),
|
||||
autoScan: matchValue([GLOBAL_KEY, "true", "false"], autoScan),
|
||||
hasRichText: matchValue([GLOBAL_KEY, "true", "false"], hasRichText),
|
||||
hasShadowroot: matchValue([GLOBAL_KEY, "true", "false"], hasShadowroot),
|
||||
// transTiming: matchValue([GLOBAL_KEY, ...OPT_TIMING_ALL], transTiming),
|
||||
transTag: matchValue([GLOBAL_KEY, "span", "font"], transTag),
|
||||
transTitle: matchValue([GLOBAL_KEY, "true", "false"], transTitle),
|
||||
detectRemote: matchValue([GLOBAL_KEY, "true", "false"], detectRemote),
|
||||
skipLangs: type(skipLangs) === "array" ? skipLangs : [],
|
||||
fixerSelector: type(fixerSelector) === "string" ? fixerSelector : "",
|
||||
fixerFunc: matchValue([GLOBAL_KEY, ...FIXER_ALL], fixerFunc),
|
||||
// detectRemote: matchValue([GLOBAL_KEY, "true", "false"], detectRemote),
|
||||
// skipLangs: type(skipLangs) === "array" ? skipLangs : [],
|
||||
// fixerSelector: type(fixerSelector) === "string" ? fixerSelector : "",
|
||||
transStartHook: type(transStartHook) === "string" ? transStartHook : "",
|
||||
transEndHook: type(transEndHook) === "string" ? transEndHook : "",
|
||||
// transRemoveHook:
|
||||
// type(transRemoveHook) === "string" ? transRemoveHook : "",
|
||||
// fixerFunc: matchValue([GLOBAL_KEY, ...FIXER_ALL], fixerFunc),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -194,16 +208,28 @@ export const checkRules = (rules) => {
|
||||
|
||||
/**
|
||||
* 保存或更新rule
|
||||
* @param {*} newRule
|
||||
* @param {*} curRule
|
||||
*/
|
||||
export const saveRule = async (newRule) => {
|
||||
export const saveRule = async (curRule) => {
|
||||
const rules = await getRulesWithDefault();
|
||||
const rule = rules.find((item) => isMatch(newRule.pattern, item.pattern));
|
||||
if (rule && rule.pattern !== GLOBAL_KEY) {
|
||||
Object.assign(rule, { ...newRule, pattern: rule.pattern });
|
||||
} else {
|
||||
rules.unshift(newRule);
|
||||
|
||||
const index = rules.findIndex(
|
||||
(item) =>
|
||||
item.pattern !== GLOBAL_KEY && isMatch(curRule.pattern, item.pattern)
|
||||
);
|
||||
if (index !== -1) {
|
||||
const rule = rules.splice(index, 1)[0];
|
||||
curRule = { ...rule, ...curRule, pattern: rule.pattern };
|
||||
}
|
||||
|
||||
const newRule = {};
|
||||
Object.entries(GLOBLA_RULE).forEach(([key, val]) => {
|
||||
newRule[key] =
|
||||
!curRule[key] || curRule[key] === val ? DEFAULT_RULE[key] : curRule[key];
|
||||
});
|
||||
|
||||
rules.unshift(newRule);
|
||||
await setRules(rules);
|
||||
|
||||
trySyncRules();
|
||||
};
|
||||
|
||||
56
src/libs/shadowroot.js
Normal file
56
src/libs/shadowroot.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { kissLog } from "./log";
|
||||
|
||||
/**
|
||||
* @class ShadowRootMonitor
|
||||
* @description 通过覆写 Element.prototype.attachShadow 来监控页面上所有新创建的 Shadow DOM
|
||||
*/
|
||||
export class ShadowRootMonitor {
|
||||
/**
|
||||
* @param {function(ShadowRoot): void} callback - 当一个新的 shadowRoot 被创建时调用的回调函数。
|
||||
*/
|
||||
constructor(callback) {
|
||||
if (typeof callback !== "function") {
|
||||
throw new Error("Callback must be a function.");
|
||||
}
|
||||
|
||||
this.callback = callback;
|
||||
this.isMonitoring = false;
|
||||
this.originalAttachShadow = Element.prototype.attachShadow;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始监控 shadowRoot 的创建。
|
||||
*/
|
||||
start() {
|
||||
if (this.isMonitoring) {
|
||||
return;
|
||||
}
|
||||
const monitorInstance = this;
|
||||
|
||||
Element.prototype.attachShadow = function (...args) {
|
||||
const shadowRoot = monitorInstance.originalAttachShadow.apply(this, args);
|
||||
if (shadowRoot) {
|
||||
try {
|
||||
monitorInstance.callback(shadowRoot);
|
||||
} catch (error) {
|
||||
kissLog("Error in ShadowRootMonitor callback", error);
|
||||
}
|
||||
}
|
||||
return shadowRoot;
|
||||
};
|
||||
|
||||
this.isMonitoring = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止监控,并恢复原始的 attachShadow 方法。
|
||||
*/
|
||||
stop() {
|
||||
if (!this.isMonitoring) {
|
||||
return;
|
||||
}
|
||||
|
||||
Element.prototype.attachShadow = this.originalAttachShadow;
|
||||
this.isMonitoring = false;
|
||||
}
|
||||
}
|
||||
@@ -1,112 +1,120 @@
|
||||
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 (!e.code) {
|
||||
return;
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (!e.code) {
|
||||
return;
|
||||
}
|
||||
|
||||
// onKeyUp 应该在 key 从集合中移除前触发,以便判断组合键
|
||||
onKeyUp(new Set(pressedKeys), e);
|
||||
pressedKeys.delete(e.code);
|
||||
};
|
||||
|
||||
target.addEventListener("keydown", handleKeydown, true);
|
||||
target.addEventListener("keyup", handleKeyup, true);
|
||||
const handleBlur = () => {
|
||||
pressedKeys.clear();
|
||||
};
|
||||
|
||||
target.addEventListener("keydown", handleKeyDown);
|
||||
target.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
|
||||
return () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
target.removeEventListener("keydown", handleKeydown);
|
||||
target.removeEventListener("keyup", handleKeyup);
|
||||
target.removeEventListener("keydown", handleKeyDown);
|
||||
target.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
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 (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);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
STOKEY_SETTING,
|
||||
STOKEY_SETTING_OLD,
|
||||
STOKEY_RULES,
|
||||
STOKEY_RULES_OLD,
|
||||
STOKEY_WORDS,
|
||||
STOKEY_FAB,
|
||||
STOKEY_SYNC,
|
||||
@@ -15,6 +17,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) {
|
||||
@@ -59,7 +62,13 @@ async function trySetObj(key, obj) {
|
||||
|
||||
async function getObj(key) {
|
||||
const val = await get(key);
|
||||
return val && JSON.parse(val);
|
||||
if (val === null || val === undefined) return null;
|
||||
try {
|
||||
return JSON.parse(val);
|
||||
} catch (err) {
|
||||
kissLog("parse json in storage err: ", key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function putObj(key, obj) {
|
||||
@@ -85,17 +94,19 @@ export const storage = {
|
||||
* 设置信息
|
||||
*/
|
||||
export const getSetting = () => getObj(STOKEY_SETTING);
|
||||
export const getSettingOld = () => getObj(STOKEY_SETTING_OLD);
|
||||
export const getSettingWithDefault = async () => ({
|
||||
...DEFAULT_SETTING,
|
||||
...((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);
|
||||
|
||||
/**
|
||||
* 规则列表
|
||||
*/
|
||||
export const getRules = () => getObj(STOKEY_RULES);
|
||||
export const getRulesOld = () => getObj(STOKEY_RULES_OLD);
|
||||
export const getRulesWithDefault = async () =>
|
||||
(await getRules()) || DEFAULT_RULES;
|
||||
export const setRules = (val) => setObj(STOKEY_RULES, val);
|
||||
@@ -122,14 +133,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 +173,6 @@ export const tryInitDefaultData = async () => {
|
||||
BUILTIN_RULES
|
||||
);
|
||||
} catch (err) {
|
||||
kissLog(err, "init default");
|
||||
kissLog("init default", err);
|
||||
}
|
||||
};
|
||||
|
||||
166
src/libs/style.js
Normal file
166
src/libs/style.js
Normal file
@@ -0,0 +1,166 @@
|
||||
import { css, keyframes } from "@emotion/css";
|
||||
import {
|
||||
OPT_STYLE_NONE,
|
||||
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_GRADIENT,
|
||||
OPT_STYLE_BLINK,
|
||||
OPT_STYLE_GLOW,
|
||||
OPT_STYLE_DIY,
|
||||
DEFAULT_DIY_STYLE,
|
||||
DEFAULT_COLOR,
|
||||
} from "../config";
|
||||
|
||||
const gradientFlow = keyframes`
|
||||
to {
|
||||
background-position: 200% center;
|
||||
}
|
||||
`;
|
||||
|
||||
const blink = keyframes`
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const glow = keyframes`
|
||||
from {
|
||||
text-shadow: 0 0 10px #fff,
|
||||
0 0 20px #fff,
|
||||
0 0 30px #0073e6,
|
||||
0 0 40px #0073e6;
|
||||
}
|
||||
to {
|
||||
text-shadow: 0 0 20px #fff,
|
||||
0 0 30px #ff4da6,
|
||||
0 0 40px #ff4da6,
|
||||
0 0 50px #ff4da6;
|
||||
}
|
||||
`;
|
||||
|
||||
const genLineStyle = (style, color) => `
|
||||
text-decoration-line: underline;
|
||||
text-decoration-style: ${style};
|
||||
text-decoration-color: ${color};
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 0.3em;
|
||||
-webkit-text-decoration-line: underline;
|
||||
-webkit-text-decoration-style: ${style};
|
||||
-webkit-text-decoration-color: ${color};
|
||||
-webkit-text-decoration-thickness: 2px;
|
||||
-webkit-text-underline-offset: 0.3em;
|
||||
|
||||
/* opacity: 0.8;
|
||||
-webkit-opacity: 0.8;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
-webkit-opacity: 1;
|
||||
} */
|
||||
`;
|
||||
|
||||
const genStyles = ({
|
||||
textDiyStyle = DEFAULT_DIY_STYLE,
|
||||
bgColor = DEFAULT_COLOR,
|
||||
} = {}) => ({
|
||||
// 无样式
|
||||
[OPT_STYLE_NONE]: ``,
|
||||
// 下划线
|
||||
[OPT_STYLE_LINE]: genLineStyle("solid", bgColor),
|
||||
// 点状线
|
||||
[OPT_STYLE_DOTLINE]: genLineStyle("dotted", bgColor),
|
||||
// 虚线
|
||||
[OPT_STYLE_DASHLINE]: genLineStyle("dashed", bgColor),
|
||||
// 波浪线
|
||||
[OPT_STYLE_WAVYLINE]: genLineStyle("wavy", bgColor),
|
||||
// 虚线框
|
||||
[OPT_STYLE_DASHBOX]: `
|
||||
border: 2px dashed ${bgColor || DEFAULT_COLOR};
|
||||
display: inline-block;
|
||||
padding: 0.2em 0.4em;
|
||||
box-sizing: border-box;
|
||||
`,
|
||||
// 模糊
|
||||
[OPT_STYLE_FUZZY]: `
|
||||
filter: blur(0.2em);
|
||||
-webkit-filter: blur(0.2em);
|
||||
&:hover {
|
||||
filter: none;
|
||||
-webkit-filter: none;
|
||||
}
|
||||
`,
|
||||
// 高亮
|
||||
[OPT_STYLE_HIGHLIGHT]: `
|
||||
color: #fff;
|
||||
background-color: ${bgColor || DEFAULT_COLOR};
|
||||
`,
|
||||
// 引用
|
||||
[OPT_STYLE_BLOCKQUOTE]: `
|
||||
opacity: 0.8;
|
||||
-webkit-opacity: 0.8;
|
||||
display: block;
|
||||
padding: 0.25em 0.5em;
|
||||
border-left: 0.5em solid ${bgColor || DEFAULT_COLOR};
|
||||
background: rgb(32, 156, 238, 0.2);
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
-webkit-opacity: 1;
|
||||
}
|
||||
`,
|
||||
// 渐变
|
||||
[OPT_STYLE_GRADIENT]: `
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
#3b82f6,
|
||||
#9333ea,
|
||||
#ec4899,
|
||||
#3b82f6
|
||||
);
|
||||
background-size: 200% auto;
|
||||
color: transparent;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
animation: ${gradientFlow} 4s linear infinite;
|
||||
`,
|
||||
// 闪现
|
||||
[OPT_STYLE_BLINK]: `
|
||||
animation: ${blink} 1s infinite;
|
||||
`,
|
||||
// 发光
|
||||
[OPT_STYLE_GLOW]: `
|
||||
animation: ${glow} 2s ease-in-out infinite alternate;
|
||||
`,
|
||||
// 自定义
|
||||
[OPT_STYLE_DIY]: `
|
||||
${textDiyStyle}
|
||||
`,
|
||||
});
|
||||
|
||||
export const genTextClass = ({ textDiyStyle, bgColor = DEFAULT_COLOR }) => {
|
||||
const styles = genStyles({ textDiyStyle, bgColor });
|
||||
const textClass = {};
|
||||
let textStyles = "";
|
||||
Object.entries(styles).forEach(([k, v]) => {
|
||||
textClass[k] = css`
|
||||
${v}
|
||||
`;
|
||||
});
|
||||
Object.entries(styles).forEach(([k, v]) => {
|
||||
textStyles += `
|
||||
.${textClass[k]} {
|
||||
${v}
|
||||
}
|
||||
`;
|
||||
});
|
||||
return [textClass, textStyles];
|
||||
};
|
||||
|
||||
export const defaultStyles = genStyles();
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
127
src/libs/svg.js
127
src/libs/svg.js
@@ -1,34 +1,109 @@
|
||||
export const loadingSvg = `
|
||||
<svg viewBox="0 0 100 100" style="display:inline-block; width:100%; height: 100%;">
|
||||
export const loadingSvg = `<svg viewBox="-20 0 100 100"
|
||||
style="display: inline-block; width: 1em; height: 1em; vertical-align: middle;">
|
||||
<circle fill="#209CEE" stroke="none" cx="6" cy="50" r="6">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
dur="1s"
|
||||
type="translate"
|
||||
values="0 15 ; 0 -15; 0 15"
|
||||
repeatCount="indefinite"
|
||||
begin="0.1"
|
||||
/>
|
||||
<animateTransform attributeName="transform" dur="1s" type="translate" values="0 15 ; 0 -15; 0 15" repeatCount="indefinite" begin="0.1"/>
|
||||
</circle>
|
||||
<circle fill="#209CEE" stroke="none" cx="30" cy="50" r="6">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
dur="1s"
|
||||
type="translate"
|
||||
values="0 10 ; 0 -10; 0 10"
|
||||
repeatCount="indefinite"
|
||||
begin="0.2"
|
||||
/>
|
||||
<animateTransform attributeName="transform" dur="1s" type="translate" values="0 10 ; 0 -10; 0 10" repeatCount="indefinite" begin="0.2"/>
|
||||
</circle>
|
||||
<circle fill="#209CEE" stroke="none" cx="54" cy="50" r="6">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
dur="1s"
|
||||
type="translate"
|
||||
values="0 5 ; 0 -5; 0 5"
|
||||
repeatCount="indefinite"
|
||||
begin="0.3"
|
||||
/>
|
||||
<animateTransform attributeName="transform" dur="1s" type="translate" values="0 5 ; 0 -5; 0 5" repeatCount="indefinite" begin="0.3"/>
|
||||
</circle>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
function createSVGElement(tag, attributes) {
|
||||
const svgNS = "http://www.w3.org/2000/svg";
|
||||
const el = document.createElementNS(svgNS, tag);
|
||||
for (const key in attributes) {
|
||||
el.setAttribute(key, attributes[key]);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建loding动画
|
||||
* @returns
|
||||
*/
|
||||
export function createLoadingSVG() {
|
||||
const svg = createSVGElement("svg", {
|
||||
viewBox: "-20 0 100 100",
|
||||
style:
|
||||
"display: inline-block; width: 1em; height: 1em; vertical-align: middle;",
|
||||
});
|
||||
|
||||
const circleData = [
|
||||
{ cx: "6", begin: "0.1", values: "0 15 ; 0 -15; 0 15" },
|
||||
{ cx: "30", begin: "0.2", values: "0 10 ; 0 -10; 0 10" },
|
||||
{ cx: "54", begin: "0.3", values: "0 5 ; 0 -5; 0 5" },
|
||||
];
|
||||
|
||||
circleData.forEach((data) => {
|
||||
const circle = createSVGElement("circle", {
|
||||
fill: "#209CEE",
|
||||
stroke: "none",
|
||||
cx: data.cx,
|
||||
cy: "50",
|
||||
r: "6",
|
||||
});
|
||||
const animation = createSVGElement("animateTransform", {
|
||||
attributeName: "transform",
|
||||
dur: "1s",
|
||||
type: "translate",
|
||||
values: data.values,
|
||||
repeatCount: "indefinite",
|
||||
begin: data.begin,
|
||||
});
|
||||
circle.appendChild(animation);
|
||||
svg.appendChild(circle);
|
||||
});
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建logo
|
||||
* @param {*} param0
|
||||
* @returns
|
||||
*/
|
||||
export function createLogoSVG({
|
||||
width = "100%",
|
||||
height = "100%",
|
||||
viewBox = "-20 -20 70 70",
|
||||
isSelected = false,
|
||||
} = {}) {
|
||||
const svg = createSVGElement("svg", {
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
width,
|
||||
height,
|
||||
viewBox,
|
||||
version: "1.1",
|
||||
});
|
||||
|
||||
const path1 = createSVGElement("path", {
|
||||
d: "M0 0 C10.56 0 21.12 0 32 0 C32 10.56 32 21.12 32 32 C21.44 32 10.88 32 0 32 C0 21.44 0 10.88 0 0 Z ",
|
||||
fill: "#209CEE",
|
||||
transform: "translate(0,0)",
|
||||
});
|
||||
|
||||
const path2 = createSVGElement("path", {
|
||||
d: "M0 0 C0.66 0 1.32 0 2 0 C2 2.97 2 5.94 2 9 C2.969375 8.2575 3.93875 7.515 4.9375 6.75 C5.48277344 6.33234375 6.02804688 5.9146875 6.58984375 5.484375 C8.39053593 3.83283924 8.39053593 3.83283924 9 0 C13.95 0 18.9 0 24 0 C24 0.99 24 1.98 24 3 C22.68 3 21.36 3 20 3 C20 9.27 20 15.54 20 22 C19.01 22 18.02 22 17 22 C17 15.73 17 9.46 17 3 C15.35 3 13.7 3 12 3 C11.731875 3.598125 11.46375 4.19625 11.1875 4.8125 C10.01506533 6.97224808 8.80630718 8.35790256 7 10 C8.01790655 12.27071461 8.77442829 13.80784632 10.6875 15.4375 C11.120625 15.953125 11.55375 16.46875 12 17 C11.6875 19.6875 11.6875 19.6875 11 22 C10.34 22 9.68 22 9 22 C8.773125 21.236875 8.54625 20.47375 8.3125 19.6875 C6.73268318 16.45263699 5.16717283 15.58358642 2 14 C2 16.64 2 19.28 2 22 C1.34 22 0.68 22 0 22 C0 14.74 0 7.48 0 0 Z ",
|
||||
fill: "#E9F5FD",
|
||||
transform: "translate(4,5)",
|
||||
});
|
||||
|
||||
svg.appendChild(path1);
|
||||
svg.appendChild(path2);
|
||||
|
||||
if (isSelected) {
|
||||
const redLine = createSVGElement("path", {
|
||||
d: "M0 36 L32 36",
|
||||
stroke: "red",
|
||||
"stroke-width": "3",
|
||||
"stroke-linecap": "round",
|
||||
});
|
||||
svg.appendChild(redLine);
|
||||
}
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "../config";
|
||||
import {
|
||||
getSyncWithDefault,
|
||||
updateSync,
|
||||
putSync,
|
||||
getSettingWithDefault,
|
||||
getRulesWithDefault,
|
||||
getWordsWithDefault,
|
||||
@@ -20,13 +20,14 @@ import {
|
||||
import { apiSyncData } from "../apis";
|
||||
import { sha256, removeEndchar } from "./utils";
|
||||
import { createClient, getPatcher } from "webdav";
|
||||
import { fetchApi } from "./fetch";
|
||||
import { fetchPatcher } from "./fetch";
|
||||
import { kissLog } from "./log";
|
||||
|
||||
getPatcher().patch("request", (opts) => {
|
||||
return fetchApi({
|
||||
input: opts.url,
|
||||
init: { method: opts.method, headers: opts.headers, body: opts.data },
|
||||
return fetchPatcher(opts.url, {
|
||||
method: opts.method,
|
||||
headers: opts.headers,
|
||||
body: opts.data,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,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,
|
||||
@@ -69,13 +70,15 @@ const syncData = async (key, valueFn) => {
|
||||
syncMeta = {},
|
||||
} = await getSyncWithDefault();
|
||||
if (!syncUrl || !syncKey || (syncType === OPT_SYNCTYPE_WEBDAV && !syncUser)) {
|
||||
// throw new Error("sync args err");
|
||||
return;
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -92,13 +95,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 };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -106,7 +116,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);
|
||||
}
|
||||
@@ -116,7 +127,7 @@ export const trySyncSetting = async () => {
|
||||
try {
|
||||
await syncSetting();
|
||||
} catch (err) {
|
||||
kissLog(err, "sync setting");
|
||||
kissLog("sync setting", err.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -125,7 +136,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);
|
||||
}
|
||||
@@ -135,7 +147,7 @@ export const trySyncRules = async () => {
|
||||
try {
|
||||
await syncRules();
|
||||
} catch (err) {
|
||||
kissLog(err, "sync user rules");
|
||||
kissLog("sync user rules", err.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -144,7 +156,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);
|
||||
}
|
||||
@@ -154,7 +167,7 @@ export const trySyncWords = async () => {
|
||||
try {
|
||||
await syncWords();
|
||||
} catch (err) {
|
||||
kissLog(err, "sync fav words");
|
||||
kissLog("sync fav words", err.message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
96
src/libs/tranbox.js
Normal file
96
src/libs/tranbox.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import createCache from "@emotion/cache";
|
||||
import { CacheProvider } from "@emotion/react";
|
||||
import Slection from "../views/Selection";
|
||||
import { DEFAULT_TRANBOX_SETTING, APP_CONSTS } from "../config";
|
||||
|
||||
export class TransboxManager {
|
||||
#container = null;
|
||||
#reactRoot = null;
|
||||
#shadowContainer = null;
|
||||
#props = {};
|
||||
|
||||
constructor(initialProps = {}) {
|
||||
this.#props = initialProps;
|
||||
|
||||
const { tranboxSetting = DEFAULT_TRANBOX_SETTING } = this.#props;
|
||||
if (tranboxSetting?.transOpen) {
|
||||
this.enable();
|
||||
}
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return (
|
||||
!!this.#container && document.body.parentElement.contains(this.#container)
|
||||
);
|
||||
}
|
||||
|
||||
enable() {
|
||||
if (!this.isEnabled()) {
|
||||
this.#container = document.createElement("div");
|
||||
this.#container.id = APP_CONSTS.boxID;
|
||||
this.#container.className = "notranslate";
|
||||
this.#container.style.cssText =
|
||||
"font-size: 0; width: 0; height: 0; border: 0; padding: 0; margin: 0;";
|
||||
document.body.parentElement.appendChild(this.#container);
|
||||
|
||||
this.#shadowContainer = this.#container.attachShadow({ mode: "closed" });
|
||||
const emotionRoot = document.createElement("style");
|
||||
const shadowRootElement = document.createElement("div");
|
||||
shadowRootElement.className = `${APP_CONSTS.boxID}_warpper notranslate`;
|
||||
this.#shadowContainer.appendChild(emotionRoot);
|
||||
this.#shadowContainer.appendChild(shadowRootElement);
|
||||
const cache = createCache({
|
||||
key: APP_CONSTS.boxID,
|
||||
prepend: true,
|
||||
container: emotionRoot,
|
||||
});
|
||||
|
||||
this.#reactRoot = ReactDOM.createRoot(shadowRootElement);
|
||||
this.CacheProvider = ({ children }) => (
|
||||
<CacheProvider value={cache}>{children}</CacheProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const AppProvider = this.CacheProvider;
|
||||
this.#reactRoot.render(
|
||||
<React.StrictMode>
|
||||
<AppProvider>
|
||||
<Slection {...this.#props} />
|
||||
</AppProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
disable() {
|
||||
if (!this.isEnabled() || !this.#reactRoot) {
|
||||
return;
|
||||
}
|
||||
this.#reactRoot.unmount();
|
||||
this.#container.remove();
|
||||
this.#container = null;
|
||||
this.#reactRoot = null;
|
||||
this.#shadowContainer = null;
|
||||
this.CacheProvider = null;
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.isEnabled()) {
|
||||
this.disable();
|
||||
} else {
|
||||
this.enable();
|
||||
}
|
||||
}
|
||||
|
||||
update(newProps) {
|
||||
this.#props = { ...this.#props, ...newProps };
|
||||
if (this.isEnabled()) {
|
||||
if (!this.#props.tranboxSetting?.transOpen) {
|
||||
this.disable();
|
||||
} else {
|
||||
this.enable();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
33
src/libs/trustedTypes.js
Normal file
33
src/libs/trustedTypes.js
Normal file
@@ -0,0 +1,33 @@
|
||||
export const trustedTypesHelper = (() => {
|
||||
const POLICY_NAME = "kiss-translator-policy";
|
||||
let policy = null;
|
||||
|
||||
if (globalThis.trustedTypes && globalThis.trustedTypes.createPolicy) {
|
||||
try {
|
||||
policy = globalThis.trustedTypes.createPolicy(POLICY_NAME, {
|
||||
createHTML: (string) => string,
|
||||
createScript: (string) => string,
|
||||
createScriptURL: (string) => string,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.message.includes("already exists")) {
|
||||
policy = globalThis.trustedTypes.policies.get(POLICY_NAME);
|
||||
} else {
|
||||
console.error("cont create Trusted Types", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
createHTML: (htmlString) => {
|
||||
return policy ? policy.createHTML(htmlString) : htmlString;
|
||||
},
|
||||
createScript: (scriptString) => {
|
||||
return policy ? policy.createScript(scriptString) : scriptString;
|
||||
},
|
||||
createScriptURL: (urlString) => {
|
||||
return policy ? policy.createScriptURL(urlString) : urlString;
|
||||
},
|
||||
isEnabled: () => policy !== null,
|
||||
};
|
||||
})();
|
||||
@@ -15,6 +15,16 @@ export const limitNumber = (num, min = 0, max = 100) => {
|
||||
return number;
|
||||
};
|
||||
|
||||
export const limitFloat = (num, min = 0.0, max = 100.0) => {
|
||||
const number = parseFloat(num);
|
||||
if (Number.isNaN(number) || number < min) {
|
||||
return min;
|
||||
} else if (number > max) {
|
||||
return max;
|
||||
}
|
||||
return number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 匹配是否为数组中的值
|
||||
* @param {*} arr
|
||||
@@ -167,7 +177,7 @@ export const sha256 = async (text, salt) => {
|
||||
* 生成随机事件名称
|
||||
* @returns
|
||||
*/
|
||||
export const genEventName = () => btoa(Math.random()).slice(3, 11);
|
||||
export const genEventName = () => `kiss-${btoa(Math.random()).slice(3, 11)}`;
|
||||
|
||||
/**
|
||||
* 判断两个 Set 是否相同
|
||||
@@ -188,6 +198,8 @@ export const isSameSet = (a, b) => {
|
||||
* @returns
|
||||
*/
|
||||
export const removeEndchar = (s, c, count = 1) => {
|
||||
if (!s) return "";
|
||||
|
||||
let i = s.length;
|
||||
while (i > s.length - count && s[i - 1] === c) {
|
||||
i--;
|
||||
@@ -202,26 +214,20 @@ export const removeEndchar = (s, c, count = 1) => {
|
||||
* @returns
|
||||
*/
|
||||
export const matchInputStr = (str, sign) => {
|
||||
let reg = /\/([\w-]+)\s+([^]+)/;
|
||||
switch (sign) {
|
||||
case "//":
|
||||
reg = /\/\/([\w-]+)\s+([^]+)/;
|
||||
break;
|
||||
return str.match(/\/\/([\w-]+)\s+([^]+)/);
|
||||
case "\\":
|
||||
reg = /\\([\w-]+)\s+([^]+)/;
|
||||
break;
|
||||
return str.match(/\\([\w-]+)\s+([^]+)/);
|
||||
case "\\\\":
|
||||
reg = /\\\\([\w-]+)\s+([^]+)/;
|
||||
break;
|
||||
return str.match(/\\\\([\w-]+)\s+([^]+)/);
|
||||
case ">":
|
||||
reg = />([\w-]+)\s+([^]+)/;
|
||||
break;
|
||||
return str.match(/>([\w-]+)\s+([^]+)/);
|
||||
case ">>":
|
||||
reg = />>([\w-]+)\s+([^]+)/;
|
||||
break;
|
||||
return str.match(/>>([\w-]+)\s+([^]+)/);
|
||||
default:
|
||||
}
|
||||
return str.match(reg);
|
||||
return str.match(/\/([\w-]+)\s+([^]+)/);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -233,3 +239,137 @@ export const isValidWord = (str) => {
|
||||
const regex = /^[a-zA-Z-]+$/;
|
||||
return regex.test(str);
|
||||
};
|
||||
|
||||
/**
|
||||
* blob转为base64
|
||||
* @param {*} blob
|
||||
* @returns
|
||||
*/
|
||||
export const blobToBase64 = (blob) => {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取html内的文本
|
||||
* @param {*} htmlStr
|
||||
* @param {*} skipTag
|
||||
* @returns
|
||||
*/
|
||||
export const getHtmlText = (htmlStr, skipTag = "") => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlStr, "text/html");
|
||||
|
||||
if (skipTag) {
|
||||
doc.querySelectorAll(skipTag).forEach((el) => el.remove());
|
||||
}
|
||||
|
||||
return doc.body.innerText.trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析JSON字符串对象
|
||||
* @param {*} str
|
||||
* @returns
|
||||
*/
|
||||
export const parseJsonObj = (str) => {
|
||||
if (!str || type(str) !== "string") {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
if (str.trim()[0] !== "{") {
|
||||
str = `{${str}}`;
|
||||
}
|
||||
return JSON.parse(str);
|
||||
} catch (err) {
|
||||
//
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
/**
|
||||
* 提取json内容
|
||||
* @param {*} s
|
||||
* @returns
|
||||
*/
|
||||
export const extractJson = (raw) => {
|
||||
const jsonRegex = /({.*}|\[.*\])/s;
|
||||
const match = raw.match(jsonRegex);
|
||||
return match ? match[0] : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 空闲执行
|
||||
* @param {*} cb
|
||||
* @param {*} timeout
|
||||
* @returns
|
||||
*/
|
||||
export const scheduleIdle = (cb, timeout = 200) => {
|
||||
if (window.requestIdleCallback) {
|
||||
return requestIdleCallback(cb, { timeout });
|
||||
}
|
||||
return setTimeout(cb, timeout);
|
||||
};
|
||||
|
||||
/**
|
||||
* 截取url部分
|
||||
* @param {*} href
|
||||
* @returns
|
||||
*/
|
||||
export const parseUrlPattern = (href) => {
|
||||
if (href.startsWith("file")) {
|
||||
const filename = href.substring(href.lastIndexOf("/") + 1);
|
||||
return filename;
|
||||
} else if (href.startsWith("http")) {
|
||||
const url = new URL(href);
|
||||
return url.host;
|
||||
}
|
||||
return href;
|
||||
};
|
||||
|
||||
/**
|
||||
* 带超时的任务
|
||||
* @param {Promise|Function} task - 任务
|
||||
* @param {number} timeout - 超时时间 (毫秒)
|
||||
* @param {string} [timeoutMsg] - 超时错误提示
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export const withTimeout = (task, timeout, timeoutMsg = "Task timed out") => {
|
||||
const promise = typeof task === "function" ? task() : task;
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(timeoutMsg)), timeout)
|
||||
),
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 截短字符串
|
||||
* @param {*} str
|
||||
* @param {*} maxLength
|
||||
* @returns
|
||||
*/
|
||||
export const truncateWords = (str, maxLength = 200) => {
|
||||
if (typeof str !== "string") return "";
|
||||
if (str.length <= maxLength) return str;
|
||||
const truncated = str.slice(0, maxLength);
|
||||
return truncated.slice(0, truncated.lastIndexOf(" ")) + " …";
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成随机数
|
||||
* @param {*} min
|
||||
* @param {*} max
|
||||
* @param {*} integer
|
||||
* @returns
|
||||
*/
|
||||
export const randomBetween = (min, max, integer = true) => {
|
||||
const value = Math.random() * (max - min) + min;
|
||||
return integer ? Math.floor(value) : value;
|
||||
};
|
||||
|
||||
91
src/scripts/build-safari.js
Normal file
91
src/scripts/build-safari.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { $, globby } from "zx";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import dotenv from "dotenv";
|
||||
import { findUp } from "find-up";
|
||||
|
||||
async function main() {
|
||||
const rootPath = path.dirname(await findUp("package.json"));
|
||||
dotenv.config({ path: path.resolve(rootPath, ".env.local") });
|
||||
// https://github.com/vitejs/vite/issues/5885
|
||||
process.env.NODE_ENV = "production";
|
||||
|
||||
const ProjectName = "Kiss Translator";
|
||||
const AppCategory = "public.app-category.productivity";
|
||||
const Identifier = "com.fishjar.kiss-translator";
|
||||
const DevelopmentTeam = process.env.DEVELOPMENT_TEAM;
|
||||
const DistPath = "build";
|
||||
|
||||
await $`pnpm build:safari-output`;
|
||||
await $`xcrun safari-web-extension-converter --bundle-identifier ${Identifier} --force --project-location ${DistPath} build/safari`;
|
||||
async function updateProjectConfig() {
|
||||
const projectConfigPath = path.resolve(
|
||||
rootPath,
|
||||
`${DistPath}/${ProjectName}/${ProjectName}.xcodeproj/project.pbxproj`
|
||||
);
|
||||
const packageJson = JSON.parse(
|
||||
await fs.readFile(path.resolve(rootPath, "package.json"))
|
||||
);
|
||||
const content = await fs.readFile(projectConfigPath, "utf-8");
|
||||
const newContent = content
|
||||
.replaceAll(
|
||||
"MARKETING_VERSION = 1.0;",
|
||||
`MARKETING_VERSION = ${packageJson.version};`
|
||||
)
|
||||
.replace(
|
||||
new RegExp(
|
||||
`INFOPLIST_KEY_CFBundleDisplayName = ("?${ProjectName}"?);`,
|
||||
"g"
|
||||
),
|
||||
`INFOPLIST_KEY_CFBundleDisplayName = $1;\n INFOPLIST_KEY_LSApplicationCategoryType = "${AppCategory}";`
|
||||
)
|
||||
.replace(
|
||||
new RegExp(
|
||||
`INFOPLIST_KEY_CFBundleDisplayName = ("?${ProjectName}"?);`,
|
||||
"g"
|
||||
),
|
||||
`INFOPLIST_KEY_CFBundleDisplayName = $1;\n INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;`
|
||||
)
|
||||
.replaceAll(
|
||||
`COPY_PHASE_STRIP = NO;`,
|
||||
DevelopmentTeam
|
||||
? `COPY_PHASE_STRIP = NO;\n DEVELOPMENT_TEAM = ${DevelopmentTeam};`
|
||||
: "COPY_PHASE_STRIP = NO;"
|
||||
)
|
||||
.replace(
|
||||
/CURRENT_PROJECT_VERSION = \d+;/g,
|
||||
`CURRENT_PROJECT_VERSION = ${parseProjectVersion(packageJson.version)};`
|
||||
);
|
||||
await fs.writeFile(projectConfigPath, newContent);
|
||||
}
|
||||
|
||||
async function updateInfoPlist() {
|
||||
const projectPath = path.resolve(rootPath, DistPath, ProjectName);
|
||||
const files = await globby("**/*.plist", {
|
||||
cwd: projectPath,
|
||||
});
|
||||
for (const file of files) {
|
||||
const content = await fs.readFile(
|
||||
path.resolve(projectPath, file),
|
||||
"utf-8"
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.resolve(projectPath, file),
|
||||
content.replaceAll(
|
||||
"</dict>\n</plist>",
|
||||
" <key>CFBundleVersion</key>\n <string>$(CURRENT_PROJECT_VERSION)</string>\n</dict>\n</plist>"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseProjectVersion(version) {
|
||||
const [major, minor, patch] = version.split(".").map(Number);
|
||||
return major * 10000 + minor * 100 + patch;
|
||||
}
|
||||
|
||||
await updateProjectConfig();
|
||||
await updateInfoPlist();
|
||||
}
|
||||
|
||||
main();
|
||||
343
src/subtitle/BilingualSubtitleManager.js
Normal file
343
src/subtitle/BilingualSubtitleManager.js
Normal file
@@ -0,0 +1,343 @@
|
||||
import { logger } from "../libs/log.js";
|
||||
import { truncateWords } from "../libs/utils.js";
|
||||
|
||||
/**
|
||||
* @class BilingualSubtitleManager
|
||||
* @description 负责在视频上显示和翻译字幕的核心逻辑
|
||||
*/
|
||||
export class BilingualSubtitleManager {
|
||||
#videoEl;
|
||||
#formattedSubtitles = [];
|
||||
#translationService;
|
||||
#captionWindowEl = null;
|
||||
#paperEl = null;
|
||||
#currentSubtitleIndex = -1;
|
||||
#preTranslateSeconds = 100;
|
||||
#setting = {};
|
||||
#isAdPlaying = false;
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {HTMLVideoElement} options.videoEl - 页面上的 video 元素。
|
||||
* @param {Array<object>} options.formattedSubtitles - 已格式化好的字幕数组。
|
||||
* @param {(text: string, toLang: string) => Promise<string>} options.translationService - 外部翻译函数。
|
||||
* @param {object} options.setting - 配置对象,如目标翻译语言。
|
||||
*/
|
||||
constructor({ videoEl, formattedSubtitles, translationService, setting }) {
|
||||
this.#setting = setting;
|
||||
this.#videoEl = videoEl;
|
||||
this.#formattedSubtitles = formattedSubtitles;
|
||||
this.#translationService = translationService;
|
||||
|
||||
this.onTimeUpdate = this.onTimeUpdate.bind(this);
|
||||
this.onSeek = this.onSeek.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动字幕显示和翻译。
|
||||
*/
|
||||
start() {
|
||||
if (this.#formattedSubtitles.length === 0) {
|
||||
logger.warn("Bilingual Subtitles: No subtitles to display.");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Bilingual Subtitle Manager: Starting...");
|
||||
this.#createCaptionWindow();
|
||||
this.#attachEventListeners();
|
||||
this.onTimeUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁实例,清理资源。
|
||||
*/
|
||||
destroy() {
|
||||
logger.info("Bilingual Subtitle Manager: Destroying...");
|
||||
this.#removeEventListeners();
|
||||
this.#captionWindowEl?.parentElement?.parentElement?.remove();
|
||||
this.#formattedSubtitles = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新广告播放状态。
|
||||
*/
|
||||
setIsAdPlaying(isPlaying) {
|
||||
this.#isAdPlaying = isPlaying;
|
||||
this.onTimeUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并配置用于显示字幕的 DOM 元素。
|
||||
*/
|
||||
#createCaptionWindow() {
|
||||
const container = document.createElement("div");
|
||||
container.className = `kiss-caption-container notranslate`;
|
||||
Object.assign(container.style, {
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
left: "0",
|
||||
top: "0",
|
||||
pointerEvents: "none",
|
||||
});
|
||||
|
||||
const paper = document.createElement("div");
|
||||
paper.className = `kiss-caption-paper`;
|
||||
Object.assign(paper.style, {
|
||||
position: "absolute",
|
||||
width: "80%",
|
||||
left: "50%",
|
||||
bottom: "10%",
|
||||
transform: "translateX(-50%)",
|
||||
textAlign: "center",
|
||||
containerType: "inline-size",
|
||||
zIndex: "2147483647",
|
||||
pointerEvents: "auto",
|
||||
display: "none",
|
||||
});
|
||||
this.#paperEl = paper;
|
||||
|
||||
this.#captionWindowEl = document.createElement("div");
|
||||
this.#captionWindowEl.className = `kiss-caption-window`;
|
||||
this.#captionWindowEl.style.cssText = this.#setting.windowStyle;
|
||||
this.#captionWindowEl.style.pointerEvents = "auto";
|
||||
this.#captionWindowEl.style.cursor = "grab";
|
||||
this.#captionWindowEl.style.opacity = "1";
|
||||
|
||||
this.#paperEl.appendChild(this.#captionWindowEl);
|
||||
container.appendChild(this.#paperEl);
|
||||
|
||||
const videoContainer = this.#videoEl.parentElement?.parentElement;
|
||||
if (!videoContainer) {
|
||||
logger.warn("could not find videoContainer");
|
||||
return;
|
||||
}
|
||||
|
||||
videoContainer.style.position = "relative";
|
||||
videoContainer.appendChild(container);
|
||||
|
||||
this.#enableDragging(this.#paperEl, container, this.#captionWindowEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定的元素启用垂直拖动功能。
|
||||
*/
|
||||
#enableDragging(dragElement, boundaryContainer, handleElement) {
|
||||
let isDragging = false;
|
||||
let startY;
|
||||
let initialBottom;
|
||||
let dragElementHeight;
|
||||
|
||||
const onMouseDown = (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (e.button !== 0) return;
|
||||
|
||||
isDragging = true;
|
||||
handleElement.style.cursor = "grabbing";
|
||||
startY = e.clientY;
|
||||
|
||||
initialBottom =
|
||||
boundaryContainer.getBoundingClientRect().bottom -
|
||||
dragElement.getBoundingClientRect().bottom;
|
||||
|
||||
dragElementHeight = dragElement.offsetHeight;
|
||||
|
||||
document.addEventListener("mousemove", onMouseMove, { capture: true });
|
||||
document.addEventListener("mouseup", onMouseUp, { capture: true });
|
||||
};
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const deltaY = e.clientY - startY;
|
||||
let newBottom = initialBottom - deltaY;
|
||||
|
||||
const containerHeight = boundaryContainer.clientHeight;
|
||||
newBottom = Math.max(0, newBottom);
|
||||
newBottom = Math.min(containerHeight - dragElementHeight, newBottom);
|
||||
if (dragElementHeight > containerHeight) {
|
||||
newBottom = Math.max(0, newBottom);
|
||||
}
|
||||
|
||||
dragElement.style.bottom = `${newBottom}px`;
|
||||
};
|
||||
|
||||
const onMouseUp = (e) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
isDragging = false;
|
||||
handleElement.style.cursor = "grab";
|
||||
|
||||
document.removeEventListener("mousemove", onMouseMove, { capture: true });
|
||||
document.removeEventListener("mouseup", onMouseUp, { capture: true });
|
||||
|
||||
const finalBottomPx = dragElement.style.bottom;
|
||||
setTimeout(() => {
|
||||
dragElement.style.bottom = finalBottomPx;
|
||||
}, 50);
|
||||
};
|
||||
|
||||
handleElement.addEventListener("mousedown", onMouseDown);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定视频元素的 timeupdate 和 seeked 事件监听器。
|
||||
*/
|
||||
#attachEventListeners() {
|
||||
this.#videoEl.addEventListener("timeupdate", this.onTimeUpdate);
|
||||
this.#videoEl.addEventListener("seeked", this.onSeek);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除事件监听器。
|
||||
*/
|
||||
#removeEventListeners() {
|
||||
this.#videoEl.removeEventListener("timeupdate", this.onTimeUpdate);
|
||||
this.#videoEl.removeEventListener("seeked", this.onSeek);
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频播放时间更新时的回调,负责更新字幕和触发预翻译。
|
||||
*/
|
||||
onTimeUpdate() {
|
||||
const currentTimeMs = this.#videoEl.currentTime * 1000;
|
||||
const subtitleIndex = this.#findSubtitleIndexForTime(currentTimeMs);
|
||||
|
||||
if (subtitleIndex !== this.#currentSubtitleIndex) {
|
||||
this.#currentSubtitleIndex = subtitleIndex;
|
||||
const subtitle =
|
||||
subtitleIndex !== -1 ? this.#formattedSubtitles[subtitleIndex] : null;
|
||||
this.#updateCaptionDisplay(subtitle);
|
||||
}
|
||||
|
||||
this.#triggerTranslations(currentTimeMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户拖动进度条后的回调。
|
||||
*/
|
||||
onSeek() {
|
||||
this.#currentSubtitleIndex = -1;
|
||||
this.onTimeUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据时间(毫秒)查找对应的字幕索引。
|
||||
* @param {number} currentTimeMs
|
||||
* @returns {number} 找到的字幕索引,-1 表示没找到。
|
||||
*/
|
||||
#findSubtitleIndexForTime(currentTimeMs) {
|
||||
return this.#formattedSubtitles.findIndex(
|
||||
(sub) => currentTimeMs >= sub.start && currentTimeMs <= sub.end
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新字幕窗口的显示内容。
|
||||
* @param {object | null} subtitle - 字幕对象,或 null 用于清空。
|
||||
*/
|
||||
#updateCaptionDisplay(subtitle) {
|
||||
if (!this.#paperEl || !this.#captionWindowEl) return;
|
||||
|
||||
if (this.#isAdPlaying) {
|
||||
this.#paperEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
if (subtitle) {
|
||||
const p1 = document.createElement("p");
|
||||
p1.style.cssText = this.#setting.originStyle;
|
||||
p1.textContent = truncateWords(subtitle.text);
|
||||
|
||||
const p2 = document.createElement("p");
|
||||
p2.style.cssText = this.#setting.translationStyle;
|
||||
p2.textContent = truncateWords(subtitle.translation) || "...";
|
||||
|
||||
if (this.#setting.isBilingual) {
|
||||
this.#captionWindowEl.replaceChildren(p1, p2);
|
||||
} else {
|
||||
this.#captionWindowEl.replaceChildren(p2);
|
||||
}
|
||||
|
||||
this.#paperEl.style.display = "block";
|
||||
} else {
|
||||
this.#paperEl.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提前翻译指定时间范围内的字幕。
|
||||
* @param {number} currentTimeMs
|
||||
*/
|
||||
#triggerTranslations(currentTimeMs) {
|
||||
const lookAheadMs = this.#preTranslateSeconds * 1000;
|
||||
|
||||
for (const sub of this.#formattedSubtitles) {
|
||||
const isCurrent = sub.start <= currentTimeMs && sub.end >= currentTimeMs;
|
||||
const isUpcoming =
|
||||
sub.start > currentTimeMs && sub.start <= currentTimeMs + lookAheadMs;
|
||||
const needsTranslation = !sub.translation && !sub.isTranslating;
|
||||
|
||||
if ((isCurrent || isUpcoming) && needsTranslation) {
|
||||
this.#translateAndStore(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单个字幕的翻译并更新其状态。
|
||||
* @param {object} subtitle - 需要翻译的字幕对象。
|
||||
*/
|
||||
async #translateAndStore(subtitle) {
|
||||
subtitle.isTranslating = true;
|
||||
try {
|
||||
const { fromLang, toLang, apiSetting } = this.#setting;
|
||||
const [translatedText] = await this.#translationService({
|
||||
text: subtitle.text,
|
||||
fromLang,
|
||||
toLang,
|
||||
apiSetting,
|
||||
});
|
||||
subtitle.translation = translatedText;
|
||||
} catch (error) {
|
||||
logger.info("Translation failed for:", subtitle.text, error);
|
||||
subtitle.translation = "[Translation failed]";
|
||||
} finally {
|
||||
subtitle.isTranslating = false;
|
||||
|
||||
const currentSubtitleIndexNow = this.#findSubtitleIndexForTime(
|
||||
this.#videoEl.currentTime * 1000
|
||||
);
|
||||
if (this.#formattedSubtitles[currentSubtitleIndexNow] === subtitle) {
|
||||
this.#updateCaptionDisplay(subtitle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加新的字幕
|
||||
* @param {Array<object>} newSubtitlesChunk - 新的、要追加的字幕数据块。
|
||||
*/
|
||||
appendSubtitles(newSubtitlesChunk) {
|
||||
if (!newSubtitlesChunk || newSubtitlesChunk.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Bilingual Subtitle Manager: Appending ${newSubtitlesChunk.length} new subtitles...`
|
||||
);
|
||||
|
||||
this.#formattedSubtitles.push(...newSubtitlesChunk);
|
||||
this.#formattedSubtitles.sort((a, b) => a.start - b.start);
|
||||
this.#currentSubtitleIndex = -1;
|
||||
this.onTimeUpdate();
|
||||
}
|
||||
}
|
||||
19
src/subtitle/XMLHttpRequestInjector.js
Normal file
19
src/subtitle/XMLHttpRequestInjector.js
Normal file
@@ -0,0 +1,19 @@
|
||||
export const XMLHttpRequestInjector = () => {
|
||||
const originalOpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function (...args) {
|
||||
const url = args[1];
|
||||
if (typeof url === "string" && url.includes("timedtext")) {
|
||||
this.addEventListener("load", function () {
|
||||
window.postMessage(
|
||||
{
|
||||
type: "KISS_XHR_DATA_YOUTUBE",
|
||||
url: this.responseURL,
|
||||
response: this.responseText,
|
||||
},
|
||||
window.location.origin
|
||||
);
|
||||
});
|
||||
}
|
||||
return originalOpen.apply(this, args);
|
||||
};
|
||||
};
|
||||
940
src/subtitle/YouTubeCaptionProvider.js
Normal file
940
src/subtitle/YouTubeCaptionProvider.js
Normal file
@@ -0,0 +1,940 @@
|
||||
import { logger } from "../libs/log.js";
|
||||
import { apiSubtitle, apiTranslate } from "../apis/index.js";
|
||||
import { BilingualSubtitleManager } from "./BilingualSubtitleManager.js";
|
||||
import {
|
||||
MSG_XHR_DATA_YOUTUBE,
|
||||
APP_NAME,
|
||||
OPT_LANGS_TO_CODE,
|
||||
OPT_TRANS_MICROSOFT,
|
||||
} from "../config";
|
||||
import { sleep } from "../libs/utils.js";
|
||||
import { createLogoSVG } from "../libs/svg.js";
|
||||
import { randomBetween } from "../libs/utils.js";
|
||||
import { i18n } from "../config";
|
||||
|
||||
const VIDEO_SELECT = "#container video";
|
||||
const CONTORLS_SELECT = ".ytp-right-controls";
|
||||
const YT_CAPTION_SELECT = "#ytp-caption-window-container";
|
||||
const YT_AD_SELECT = ".video-ads";
|
||||
|
||||
class YouTubeCaptionProvider {
|
||||
#setting = {};
|
||||
#videoId = "";
|
||||
#subtitles = [];
|
||||
#managerInstance = null;
|
||||
#toggleButton = null;
|
||||
#enabled = false;
|
||||
#ytControls = null;
|
||||
#isBusy = false;
|
||||
#fromLang = "auto";
|
||||
#notificationEl = null;
|
||||
#notificationTimeout = null;
|
||||
#i18n = () => "";
|
||||
|
||||
constructor(setting = {}) {
|
||||
this.#setting = setting;
|
||||
this.#i18n = i18n(setting.uiLang || "zh");
|
||||
}
|
||||
|
||||
initialize() {
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.data?.type === MSG_XHR_DATA_YOUTUBE) {
|
||||
const { url, response } = event.data;
|
||||
if (url && response) {
|
||||
this.#handleInterceptedRequest(url, response);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("yt-navigate-finish", () => {
|
||||
setTimeout(() => {
|
||||
if (this.#toggleButton) {
|
||||
this.#toggleButton.style.opacity = "0.5";
|
||||
}
|
||||
this.#destroyManager();
|
||||
this.#doubleClick();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
this.#waitForElement(CONTORLS_SELECT, (ytControls) =>
|
||||
this.#injectToggleButton(ytControls)
|
||||
);
|
||||
|
||||
this.#waitForElement(YT_AD_SELECT, (adContainer) => {
|
||||
this.#moAds(adContainer);
|
||||
});
|
||||
}
|
||||
|
||||
get #videoEl() {
|
||||
return document.querySelector(VIDEO_SELECT);
|
||||
}
|
||||
|
||||
#moAds(adContainer) {
|
||||
const adLayoutSelector = ".ytp-ad-player-overlay-layout";
|
||||
const skipBtnSelector =
|
||||
".ytp-skip-ad-button, .ytp-ad-skip-button, .ytp-ad-skip-button-modern";
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === "childList") {
|
||||
const videoEl = this.#videoEl;
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
|
||||
if (node.matches(adLayoutSelector)) {
|
||||
logger.debug("Youtube Provider: AD start playing!", node);
|
||||
// todo: 顺带把广告快速跳过
|
||||
if (videoEl) {
|
||||
videoEl.playbackRate = 16;
|
||||
videoEl.currentTime = videoEl.duration;
|
||||
}
|
||||
if (this.#managerInstance) {
|
||||
this.#managerInstance.setIsAdPlaying(true);
|
||||
}
|
||||
} else if (node.matches(skipBtnSelector)) {
|
||||
logger.debug("Youtube Provider: AD skip button!", node);
|
||||
node.click();
|
||||
}
|
||||
|
||||
const skipBtn = node?.querySelector(skipBtnSelector);
|
||||
if (skipBtn) {
|
||||
logger.debug("Youtube Provider: AD skip button!!", skipBtn);
|
||||
skipBtn.click();
|
||||
}
|
||||
});
|
||||
mutation.removedNodes.forEach((node) => {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
|
||||
if (node.matches(adLayoutSelector)) {
|
||||
logger.debug("Youtube Provider: Ad ends!");
|
||||
if (videoEl) {
|
||||
videoEl.playbackRate = 1;
|
||||
}
|
||||
if (this.#managerInstance) {
|
||||
this.#managerInstance.setIsAdPlaying(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(adContainer, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
#waitForElement(selector, callback) {
|
||||
const element = document.querySelector(selector);
|
||||
if (element) {
|
||||
callback(element);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver((mutations, obs) => {
|
||||
const targetNode = document.querySelector(selector);
|
||||
if (targetNode) {
|
||||
obs.disconnect();
|
||||
callback(targetNode);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
async #doubleClick() {
|
||||
const button = this.#ytControls?.querySelector(
|
||||
"button.ytp-subtitles-button"
|
||||
);
|
||||
if (button) {
|
||||
await sleep(randomBetween(50, 100));
|
||||
button.click();
|
||||
await sleep(randomBetween(500, 1000));
|
||||
button.click();
|
||||
}
|
||||
}
|
||||
|
||||
#injectToggleButton(ytControls) {
|
||||
this.#ytControls = ytControls;
|
||||
|
||||
const kissControls = document.createElement("div");
|
||||
kissControls.className = "kiss-bilingual-subtitle-controls";
|
||||
Object.assign(kissControls.style, {
|
||||
height: "100%",
|
||||
});
|
||||
|
||||
const toggleButton = document.createElement("button");
|
||||
toggleButton.className =
|
||||
"ytp-button notranslate kiss-bilingual-subtitle-button";
|
||||
toggleButton.title = APP_NAME;
|
||||
Object.assign(toggleButton.style, {
|
||||
color: "white",
|
||||
opacity: "0.5",
|
||||
});
|
||||
|
||||
toggleButton.appendChild(createLogoSVG());
|
||||
kissControls.appendChild(toggleButton);
|
||||
|
||||
toggleButton.onclick = () => {
|
||||
if (this.#isBusy) {
|
||||
logger.info(`Youtube Provider: It's budy now...`);
|
||||
this.#showNotification(this.#i18n("subtitle_data_processing"));
|
||||
}
|
||||
|
||||
if (!this.#enabled) {
|
||||
logger.info(`Youtube Provider: Feature toggled ON.`);
|
||||
this.#enabled = true;
|
||||
this.#toggleButton?.replaceChildren(
|
||||
createLogoSVG({ isSelected: true })
|
||||
);
|
||||
this.#startManager();
|
||||
} else {
|
||||
logger.info(`Youtube Provider: Feature toggled OFF.`);
|
||||
this.#enabled = false;
|
||||
this.#toggleButton?.replaceChildren(createLogoSVG());
|
||||
this.#destroyManager();
|
||||
}
|
||||
};
|
||||
this.#toggleButton = toggleButton;
|
||||
this.#ytControls?.before(kissControls);
|
||||
}
|
||||
|
||||
#isSameLang(lang1, lang2) {
|
||||
return lang1.slice(0, 2) === lang2.slice(0, 2);
|
||||
}
|
||||
|
||||
// todo: 优化逻辑
|
||||
#findCaptionTrack(captionTracks) {
|
||||
if (!captionTracks?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let captionTrack = null;
|
||||
|
||||
const asrTrack = captionTracks.find((item) => item.kind === "asr");
|
||||
if (asrTrack) {
|
||||
captionTrack = captionTracks.find(
|
||||
(item) =>
|
||||
item.kind !== "asr" &&
|
||||
this.#isSameLang(item.languageCode, asrTrack.languageCode)
|
||||
);
|
||||
if (!captionTrack) {
|
||||
captionTrack = asrTrack;
|
||||
}
|
||||
}
|
||||
|
||||
if (!captionTrack) {
|
||||
captionTrack = captionTracks.pop();
|
||||
}
|
||||
|
||||
return captionTrack;
|
||||
}
|
||||
|
||||
async #getCaptionTracks(videoId) {
|
||||
try {
|
||||
const url = `https://www.youtube.com/watch?v=${videoId}`;
|
||||
const html = await fetch(url).then((r) => r.text());
|
||||
const match = html.match(/ytInitialPlayerResponse\s*=\s*(\{.*?\});/s);
|
||||
if (!match) return [];
|
||||
const data = JSON.parse(match[1]);
|
||||
return data.captions?.playerCaptionsTracklistRenderer?.captionTracks;
|
||||
} catch (err) {
|
||||
logger.info("Youtube Provider: get captionTracks", err);
|
||||
}
|
||||
}
|
||||
|
||||
async #getSubtitleEvents(capUrl, potUrl, responseText) {
|
||||
if (
|
||||
!potUrl.searchParams.get("tlang") &&
|
||||
potUrl.searchParams.get("kind") === capUrl.searchParams.get("kind") &&
|
||||
this.#isSameLang(
|
||||
potUrl.searchParams.get("lang"),
|
||||
capUrl.searchParams.get("lang")
|
||||
)
|
||||
) {
|
||||
try {
|
||||
const json = JSON.parse(responseText);
|
||||
return json?.events;
|
||||
} catch (err) {
|
||||
logger.info("Youtube Provider: parse responseText", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
potUrl.searchParams.delete("tlang");
|
||||
potUrl.searchParams.set("lang", capUrl.searchParams.get("lang"));
|
||||
potUrl.searchParams.set("fmt", "json3");
|
||||
if (capUrl.searchParams.get("kind")) {
|
||||
potUrl.searchParams.set("kind", capUrl.searchParams.get("kind"));
|
||||
} else {
|
||||
potUrl.searchParams.delete("kind");
|
||||
}
|
||||
|
||||
const res = await fetch(potUrl.href);
|
||||
if (res?.ok) {
|
||||
const json = await res.json();
|
||||
return json?.events;
|
||||
}
|
||||
logger.info(`Youtube Provider: Failed to fetch subtitles: ${res.status}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.info("Youtube Provider: fetching subtitles error", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#getVideoId() {
|
||||
const docUrl = new URL(document.location.href);
|
||||
return docUrl.searchParams.get("v");
|
||||
}
|
||||
|
||||
async #aiSegment({ videoId, fromLang, toLang, chunkEvents, segApiSetting }) {
|
||||
try {
|
||||
const events = chunkEvents.filter((item) => item.text);
|
||||
const chunkSign = `${events[0].start} --> ${events[events.length - 1].end}`;
|
||||
logger.debug("Youtube Provider: aiSegment events", {
|
||||
videoId,
|
||||
chunkSign,
|
||||
fromLang,
|
||||
toLang,
|
||||
events,
|
||||
});
|
||||
const subtitles = await apiSubtitle({
|
||||
videoId,
|
||||
chunkSign,
|
||||
fromLang,
|
||||
toLang,
|
||||
events,
|
||||
apiSetting: segApiSetting,
|
||||
});
|
||||
logger.debug("Youtube Provider: aiSegment subtitles", subtitles);
|
||||
if (Array.isArray(subtitles)) {
|
||||
return subtitles;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.info("Youtube Provider: ai segmentation", err);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async #handleInterceptedRequest(url, responseText) {
|
||||
if (this.#isBusy) {
|
||||
logger.info("Youtube Provider is busy...");
|
||||
return;
|
||||
}
|
||||
this.#isBusy = true;
|
||||
|
||||
try {
|
||||
const videoId = this.#getVideoId();
|
||||
if (!videoId) {
|
||||
logger.info("Youtube Provider: videoId not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoId === this.#videoId) {
|
||||
logger.info("Youtube Provider: videoId already processed.");
|
||||
return;
|
||||
}
|
||||
|
||||
const potUrl = new URL(url);
|
||||
if (videoId !== potUrl.searchParams.get("v")) {
|
||||
logger.info("Youtube Provider: skip other timedtext.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { segApiSetting, toLang } = this.#setting;
|
||||
|
||||
const captionTracks = await this.#getCaptionTracks(videoId);
|
||||
const captionTrack = this.#findCaptionTrack(captionTracks);
|
||||
if (!captionTrack) {
|
||||
logger.info("Youtube Provider: CaptionTrack not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const capUrl = new URL(captionTrack.baseUrl);
|
||||
const events = await this.#getSubtitleEvents(
|
||||
capUrl,
|
||||
potUrl,
|
||||
responseText
|
||||
);
|
||||
if (!events?.length) {
|
||||
logger.info("Youtube Provider: SubtitleEvents not got.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lang = potUrl.searchParams.get("lang");
|
||||
const fromLang =
|
||||
OPT_LANGS_TO_CODE[OPT_TRANS_MICROSOFT].get(lang) ||
|
||||
OPT_LANGS_TO_CODE[OPT_TRANS_MICROSOFT].get(lang.slice(0, 2)) ||
|
||||
"auto";
|
||||
|
||||
logger.debug(
|
||||
`Youtube Provider: fromLang: ${fromLang}, toLang: ${toLang}`
|
||||
);
|
||||
if (this.#isSameLang(fromLang, toLang)) {
|
||||
logger.info("Youtube Provider: skip same lang", fromLang, toLang);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#showNotification(this.#i18n("starting_to_process_subtitle"));
|
||||
|
||||
const flatEvents = this.#flatEvents(events);
|
||||
if (!flatEvents.length) return;
|
||||
|
||||
if (potUrl.searchParams.get("kind") === "asr" && segApiSetting) {
|
||||
logger.info("Youtube Provider: Starting AI ...");
|
||||
|
||||
const eventChunks = this.#splitEventsIntoChunks(
|
||||
flatEvents,
|
||||
segApiSetting.chunkLength
|
||||
);
|
||||
const subtitlesFallback = () =>
|
||||
this.#formatSubtitles(flatEvents, fromLang);
|
||||
|
||||
if (eventChunks.length === 0) {
|
||||
this.#onCaptionsReady({
|
||||
videoId,
|
||||
subtitles: subtitlesFallback(),
|
||||
fromLang,
|
||||
isInitialLoad: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const firstChunkEvents = eventChunks[0];
|
||||
const firstBatchSubtitles = await this.#aiSegment({
|
||||
videoId,
|
||||
chunkEvents: firstChunkEvents,
|
||||
fromLang,
|
||||
toLang,
|
||||
segApiSetting,
|
||||
});
|
||||
|
||||
if (!firstBatchSubtitles?.length) {
|
||||
this.#onCaptionsReady({
|
||||
videoId,
|
||||
subtitles: subtitlesFallback(),
|
||||
fromLang,
|
||||
isInitialLoad: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.#onCaptionsReady({
|
||||
videoId,
|
||||
subtitles: firstBatchSubtitles,
|
||||
fromLang,
|
||||
isInitialLoad: true,
|
||||
});
|
||||
|
||||
if (eventChunks.length > 1) {
|
||||
const remainingChunks = eventChunks.slice(1);
|
||||
this.#processRemainingChunksAsync({
|
||||
chunks: remainingChunks,
|
||||
videoId,
|
||||
fromLang,
|
||||
toLang,
|
||||
segApiSetting,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const subtitles = this.#formatSubtitles(flatEvents, fromLang);
|
||||
if (!subtitles?.length) {
|
||||
logger.info("Youtube Provider: No subtitles after format.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.#onCaptionsReady({
|
||||
videoId,
|
||||
subtitles,
|
||||
fromLang,
|
||||
isInitialLoad: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("Youtube Provider: unknow error", error);
|
||||
this.#showNotification(this.#i18n("subtitle_load_failed"));
|
||||
} finally {
|
||||
this.#isBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
#onCaptionsReady({ videoId, subtitles, fromLang }) {
|
||||
this.#subtitles = subtitles;
|
||||
this.#videoId = videoId;
|
||||
this.#fromLang = fromLang;
|
||||
|
||||
if (this.#toggleButton) {
|
||||
this.#toggleButton.style.opacity = subtitles.length ? "1" : "0.5";
|
||||
}
|
||||
|
||||
this.#destroyManager();
|
||||
if (this.#enabled) {
|
||||
this.#startManager();
|
||||
} else {
|
||||
this.#showNotification(this.#i18n("subtitle_data_is_ready"));
|
||||
}
|
||||
}
|
||||
|
||||
#startManager() {
|
||||
if (this.#managerInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoId = this.#getVideoId();
|
||||
if (!this.#subtitles?.length || this.#videoId !== videoId) {
|
||||
logger.info("Youtube Provider: No subtitles");
|
||||
this.#showNotification(this.#i18n("try_get_subtitle_data"));
|
||||
this.#doubleClick();
|
||||
return;
|
||||
}
|
||||
|
||||
const videoEl = this.#videoEl;
|
||||
if (!videoEl) {
|
||||
logger.warn("Youtube Provider: No video element found");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Youtube Provider: Starting manager...");
|
||||
|
||||
this.#managerInstance = new BilingualSubtitleManager({
|
||||
videoEl,
|
||||
formattedSubtitles: this.#subtitles,
|
||||
translationService: apiTranslate,
|
||||
setting: { ...this.#setting, fromLang: this.#fromLang },
|
||||
});
|
||||
this.#managerInstance.start();
|
||||
|
||||
this.#showNotification(this.#i18n("subtitle_load_succeed"));
|
||||
|
||||
const ytCaption = document.querySelector(YT_CAPTION_SELECT);
|
||||
ytCaption && (ytCaption.style.display = "none");
|
||||
}
|
||||
|
||||
#destroyManager() {
|
||||
if (!this.#managerInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Youtube Provider: Destroying manager...");
|
||||
|
||||
this.#managerInstance.destroy();
|
||||
this.#managerInstance = null;
|
||||
|
||||
const ytCaption = document.querySelector(YT_CAPTION_SELECT);
|
||||
ytCaption && (ytCaption.style.display = "block");
|
||||
}
|
||||
|
||||
#formatSubtitles(flatEvents, lang) {
|
||||
if (!flatEvents?.length) return [];
|
||||
|
||||
const noSpaceLanguages = [
|
||||
"zh", // 中文
|
||||
"ja", // 日文
|
||||
"ko", // 韩文(现代用空格,但结构上仍可连写)
|
||||
"th", // 泰文
|
||||
"lo", // 老挝文
|
||||
"km", // 高棉文
|
||||
"my", // 缅文
|
||||
];
|
||||
|
||||
if (noSpaceLanguages.some((l) => lang?.startsWith(l))) {
|
||||
const subtitles = [];
|
||||
let currentLine = null;
|
||||
const MAX_LENGTH = 100;
|
||||
|
||||
for (const segment of flatEvents) {
|
||||
if (segment.text) {
|
||||
if (!currentLine) {
|
||||
currentLine = {
|
||||
text: segment.text,
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
};
|
||||
} else {
|
||||
currentLine.text += segment.text;
|
||||
currentLine.end = segment.end;
|
||||
}
|
||||
|
||||
if (currentLine.text.length >= MAX_LENGTH) {
|
||||
subtitles.push(currentLine);
|
||||
currentLine = null;
|
||||
}
|
||||
} else {
|
||||
if (currentLine) {
|
||||
subtitles.push(currentLine);
|
||||
currentLine = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
subtitles.push(currentLine);
|
||||
}
|
||||
|
||||
return subtitles;
|
||||
}
|
||||
|
||||
let subtitles = this.#processSubtitles({ flatEvents });
|
||||
const isPoor = this.#isQualityPoor(subtitles);
|
||||
logger.debug("Youtube Provider: isQualityPoor", { isPoor, subtitles });
|
||||
if (isPoor) {
|
||||
subtitles = this.#processSubtitles({ flatEvents, usePause: true });
|
||||
}
|
||||
|
||||
return subtitles;
|
||||
}
|
||||
|
||||
#isQualityPoor(lines, lengthThreshold = 250, percentageThreshold = 0.1) {
|
||||
if (lines.length === 0) return false;
|
||||
const longLinesCount = lines.filter(
|
||||
(line) => line.text.length > lengthThreshold
|
||||
).length;
|
||||
return longLinesCount / lines.length > percentageThreshold;
|
||||
}
|
||||
|
||||
#processSubtitles({
|
||||
flatEvents,
|
||||
usePause = false,
|
||||
timeout = 1000,
|
||||
maxWords = 15,
|
||||
} = {}) {
|
||||
const groupedPauseWords = {
|
||||
1: new Set([
|
||||
"actually",
|
||||
"also",
|
||||
"although",
|
||||
"and",
|
||||
"anyway",
|
||||
"as",
|
||||
"basically",
|
||||
"because",
|
||||
"but",
|
||||
"eventually",
|
||||
"frankly",
|
||||
"honestly",
|
||||
"hopefully",
|
||||
"however",
|
||||
"if",
|
||||
"instead",
|
||||
"it's",
|
||||
"just",
|
||||
"let's",
|
||||
"like",
|
||||
"literally",
|
||||
"maybe",
|
||||
"meanwhile",
|
||||
"nevertheless",
|
||||
"nonetheless",
|
||||
"now",
|
||||
"okay",
|
||||
"or",
|
||||
"otherwise",
|
||||
"perhaps",
|
||||
"personally",
|
||||
"probably",
|
||||
"right",
|
||||
"since",
|
||||
"so",
|
||||
"suddenly",
|
||||
"that's",
|
||||
"then",
|
||||
"there's",
|
||||
"therefore",
|
||||
"though",
|
||||
"thus",
|
||||
"unless",
|
||||
"until",
|
||||
"well",
|
||||
"while",
|
||||
]),
|
||||
2: new Set([
|
||||
"after all",
|
||||
"at first",
|
||||
"at least",
|
||||
"even if",
|
||||
"even though",
|
||||
"for example",
|
||||
"for instance",
|
||||
"i believe",
|
||||
"i guess",
|
||||
"i mean",
|
||||
"i suppose",
|
||||
"i think",
|
||||
"in fact",
|
||||
"in the end",
|
||||
"of course",
|
||||
"then again",
|
||||
"to be fair",
|
||||
"you know",
|
||||
"you see",
|
||||
]),
|
||||
3: new Set([
|
||||
"as a result",
|
||||
"by the way",
|
||||
"in other words",
|
||||
"in that case",
|
||||
"in this case",
|
||||
"to be clear",
|
||||
"to be honest",
|
||||
]),
|
||||
};
|
||||
|
||||
const sentences = [];
|
||||
let currentBuffer = [];
|
||||
let bufferWordCount = 0;
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (currentBuffer.length > 0) {
|
||||
sentences.push({
|
||||
text: currentBuffer
|
||||
.map((s) => s.text)
|
||||
.join(" ")
|
||||
.trim(),
|
||||
start: currentBuffer[0].start,
|
||||
end: currentBuffer[currentBuffer.length - 1].end,
|
||||
});
|
||||
}
|
||||
currentBuffer = [];
|
||||
bufferWordCount = 0;
|
||||
};
|
||||
|
||||
flatEvents.forEach((segment) => {
|
||||
if (!segment.text) return;
|
||||
|
||||
const lastSegment = currentBuffer[currentBuffer.length - 1];
|
||||
|
||||
if (lastSegment) {
|
||||
const isEndOfSentence = /[.?!…\])]$/.test(lastSegment.text);
|
||||
const isPauseOfSentence = /[,]$/.test(lastSegment.text);
|
||||
const isTimeout = segment.start - lastSegment.end > timeout;
|
||||
const isWordLimitExceeded =
|
||||
(usePause || isPauseOfSentence) && bufferWordCount >= maxWords;
|
||||
|
||||
const startsWithSign = /^[[(♪]/.test(segment.text);
|
||||
const startsWithPauseWord =
|
||||
usePause &&
|
||||
groupedPauseWords["1"].has(
|
||||
segment.text.toLowerCase().split(" ")[0]
|
||||
) &&
|
||||
currentBuffer.length > 1;
|
||||
|
||||
if (
|
||||
isEndOfSentence ||
|
||||
isTimeout ||
|
||||
isWordLimitExceeded ||
|
||||
startsWithSign ||
|
||||
startsWithPauseWord
|
||||
) {
|
||||
flushBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
currentBuffer.push(segment);
|
||||
bufferWordCount += segment.text.split(/\s+/).length;
|
||||
});
|
||||
|
||||
flushBuffer();
|
||||
|
||||
return sentences;
|
||||
}
|
||||
|
||||
#flatEvents(events = []) {
|
||||
const segments = [];
|
||||
let buffer = null;
|
||||
|
||||
events.forEach(({ segs = [], tStartMs = 0, dDurationMs = 0 }) => {
|
||||
segs.forEach(({ utf8 = "", tOffsetMs = 0 }, j) => {
|
||||
const text = utf8.trim().replace(/\s+/g, " ");
|
||||
const start = tStartMs + tOffsetMs;
|
||||
|
||||
if (buffer) {
|
||||
if (!buffer.end || buffer.end > start) {
|
||||
buffer.end = start;
|
||||
}
|
||||
segments.push(buffer);
|
||||
buffer = null;
|
||||
}
|
||||
|
||||
buffer = {
|
||||
text,
|
||||
start,
|
||||
};
|
||||
|
||||
if (j === segs.length - 1) {
|
||||
buffer.end = tStartMs + dDurationMs;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
segments.push(buffer);
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
#splitEventsIntoChunks(flatEvents, chunkLength = 1000) {
|
||||
if (!flatEvents || flatEvents.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const eventChunks = [];
|
||||
let currentChunk = [];
|
||||
let currentChunkTextLength = 0;
|
||||
const MAX_CHUNK_LENGTH = chunkLength + 500;
|
||||
const PAUSE_THRESHOLD_MS = 1000;
|
||||
|
||||
for (let i = 0; i < flatEvents.length; i++) {
|
||||
const event = flatEvents[i];
|
||||
currentChunk.push(event);
|
||||
currentChunkTextLength += event.text.length;
|
||||
|
||||
const isLastEvent = i === flatEvents.length - 1;
|
||||
if (isLastEvent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let shouldSplit = false;
|
||||
|
||||
if (currentChunkTextLength >= MAX_CHUNK_LENGTH) {
|
||||
shouldSplit = true;
|
||||
} else if (currentChunkTextLength >= chunkLength) {
|
||||
const isEndOfSentence = /[.?!…\])]$/.test(event.text);
|
||||
const nextEvent = flatEvents[i + 1];
|
||||
const pauseDuration = nextEvent.start - event.end;
|
||||
if (isEndOfSentence || pauseDuration > PAUSE_THRESHOLD_MS) {
|
||||
shouldSplit = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSplit) {
|
||||
eventChunks.push(currentChunk);
|
||||
currentChunk = [];
|
||||
currentChunkTextLength = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentChunk.length > 0) {
|
||||
eventChunks.push(currentChunk);
|
||||
}
|
||||
|
||||
return eventChunks;
|
||||
}
|
||||
|
||||
async #processRemainingChunksAsync({
|
||||
chunks,
|
||||
videoId,
|
||||
fromLang,
|
||||
toLang,
|
||||
segApiSetting,
|
||||
}) {
|
||||
logger.info(`Youtube Provider: Starting for ${chunks.length} chunks.`);
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunkEvents = chunks[i];
|
||||
const chunkNum = i + 2;
|
||||
logger.info(
|
||||
`Youtube Provider: Processing subtitle chunk ${chunkNum}/${chunks.length + 1}: ${chunkEvents[0]?.start} --> ${chunkEvents[chunkEvents.length - 1]?.start}`
|
||||
);
|
||||
|
||||
let subtitlesForThisChunk = [];
|
||||
|
||||
try {
|
||||
const aiSubtitles = await this.#aiSegment({
|
||||
videoId,
|
||||
chunkEvents,
|
||||
fromLang,
|
||||
toLang,
|
||||
segApiSetting,
|
||||
});
|
||||
|
||||
if (aiSubtitles?.length > 0) {
|
||||
subtitlesForThisChunk = aiSubtitles;
|
||||
} else {
|
||||
logger.info(
|
||||
`Youtube Provider: AI segmentation for chunk ${chunkNum} returned no data.`
|
||||
);
|
||||
subtitlesForThisChunk = this.#formatSubtitles(chunkEvents, fromLang);
|
||||
}
|
||||
} catch (chunkError) {
|
||||
subtitlesForThisChunk = this.#formatSubtitles(chunkEvents, fromLang);
|
||||
}
|
||||
|
||||
if (this.#getVideoId() !== videoId) {
|
||||
logger.info("Youtube Provider: videoId changed!");
|
||||
break;
|
||||
}
|
||||
|
||||
if (subtitlesForThisChunk.length > 0 && this.#managerInstance) {
|
||||
logger.info(
|
||||
`Youtube Provider: Appending ${subtitlesForThisChunk.length} subtitles from chunk ${chunkNum}.`
|
||||
);
|
||||
this.#managerInstance.appendSubtitles(subtitlesForThisChunk);
|
||||
} else {
|
||||
logger.info(`Youtube Provider: Chunk ${chunkNum} no subtitles.`);
|
||||
}
|
||||
|
||||
await sleep(randomBetween(500, 1000));
|
||||
}
|
||||
|
||||
logger.info("Youtube Provider: All subtitle chunks processed.");
|
||||
}
|
||||
|
||||
#createNotificationElement() {
|
||||
const notificationEl = document.createElement("div");
|
||||
notificationEl.className = "kiss-notification";
|
||||
Object.assign(notificationEl.style, {
|
||||
position: "absolute",
|
||||
top: "40%",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
background: "rgba(0,0,0,0.7)",
|
||||
color: "red",
|
||||
padding: "0.5em 1em",
|
||||
borderRadius: "4px",
|
||||
zIndex: "2147483647",
|
||||
opacity: "0",
|
||||
transition: "opacity 0.3s ease-in-out",
|
||||
pointerEvents: "none",
|
||||
fontSize: "2em",
|
||||
width: "50%",
|
||||
textAlign: "center",
|
||||
});
|
||||
|
||||
const videoEl = this.#videoEl;
|
||||
const videoContainer = videoEl?.parentElement?.parentElement;
|
||||
if (videoContainer) {
|
||||
videoContainer.appendChild(notificationEl);
|
||||
this.#notificationEl = notificationEl;
|
||||
}
|
||||
}
|
||||
|
||||
#showNotification(message, duration = 3000) {
|
||||
if (!this.#notificationEl) this.#createNotificationElement();
|
||||
this.#notificationEl.textContent = message;
|
||||
this.#notificationEl.style.opacity = "1";
|
||||
clearTimeout(this.#notificationTimeout);
|
||||
this.#notificationTimeout = setTimeout(() => {
|
||||
this.#notificationEl.style.opacity = "0";
|
||||
}, duration);
|
||||
}
|
||||
}
|
||||
|
||||
export const YouTubeInitializer = (() => {
|
||||
let initialized = false;
|
||||
|
||||
return async (setting) => {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
initialized = true;
|
||||
|
||||
logger.info("Bilingual Subtitle Extension: Initializing...");
|
||||
const provider = new YouTubeCaptionProvider(setting);
|
||||
provider.initialize();
|
||||
};
|
||||
})();
|
||||
49
src/subtitle/subtitle.js
Normal file
49
src/subtitle/subtitle.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { YouTubeInitializer } from "./YouTubeCaptionProvider.js";
|
||||
import { browser } from "../libs/browser.js";
|
||||
import { isMatch } from "../libs/utils.js";
|
||||
import { DEFAULT_API_SETTING } from "../config/api.js";
|
||||
import { DEFAULT_SUBTITLE_SETTING } from "../config/setting.js";
|
||||
import { injectExternalJs } from "../libs/injector.js";
|
||||
import { logger } from "../libs/log.js";
|
||||
import { XMLHttpRequestInjector } from "./XMLHttpRequestInjector.js";
|
||||
import { injectInlineJs } from "../libs/injector.js";
|
||||
|
||||
const providers = [
|
||||
{ pattern: "https://www.youtube.com", start: YouTubeInitializer },
|
||||
];
|
||||
|
||||
export function runSubtitle({ href, setting, isUserscript }) {
|
||||
try {
|
||||
const subtitleSetting = setting.subtitleSetting || DEFAULT_SUBTITLE_SETTING;
|
||||
if (!subtitleSetting.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = providers.find((item) => isMatch(href, item.pattern));
|
||||
if (provider) {
|
||||
const id = "kiss-translator-xmlHttp-injector";
|
||||
if (isUserscript) {
|
||||
injectInlineJs(`(${XMLHttpRequestInjector})()`, id);
|
||||
} else {
|
||||
const src = browser.runtime.getURL("injector.js");
|
||||
injectExternalJs(src, id);
|
||||
}
|
||||
|
||||
const apiSetting =
|
||||
setting.transApis.find(
|
||||
(api) => api.apiSlug === subtitleSetting.apiSlug
|
||||
) || DEFAULT_API_SETTING;
|
||||
const segApiSetting = setting.transApis.find(
|
||||
(api) => api.apiSlug === subtitleSetting.segSlug
|
||||
);
|
||||
provider.start({
|
||||
...subtitleSetting,
|
||||
apiSetting,
|
||||
segApiSetting,
|
||||
uiLang: setting.uiLang,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("start subtitle provider", err);
|
||||
}
|
||||
}
|
||||
44
src/subtitle/vtt.js
Normal file
44
src/subtitle/vtt.js
Normal file
@@ -0,0 +1,44 @@
|
||||
function millisecondsStringToNumber(msString) {
|
||||
const cleanString = msString.trim();
|
||||
const milliseconds = parseInt(cleanString, 10);
|
||||
|
||||
if (isNaN(milliseconds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return milliseconds;
|
||||
}
|
||||
|
||||
export function parseBilingualVtt(vttText) {
|
||||
const cleanText = vttText.replace(/^\uFEFF/, "").trim();
|
||||
const cues = cleanText.split(/\n\n+/);
|
||||
|
||||
const result = [];
|
||||
|
||||
for (const cue of cues) {
|
||||
if (!cue.includes("-->")) continue;
|
||||
|
||||
const lines = cue.split("\n");
|
||||
|
||||
const timestampLineIndex = lines.findIndex((line) => line.includes("-->"));
|
||||
if (timestampLineIndex === -1) continue;
|
||||
|
||||
const [startTimeString, endTimeString] =
|
||||
lines[timestampLineIndex].split(" --> ");
|
||||
const textLines = lines.slice(timestampLineIndex + 1);
|
||||
|
||||
if (startTimeString && endTimeString && textLines.length > 0) {
|
||||
const originalText = textLines[0].trim();
|
||||
const translatedText = (textLines[1] || "").trim();
|
||||
|
||||
result.push({
|
||||
start: millisecondsStringToNumber(startTimeString),
|
||||
end: millisecondsStringToNumber(endTimeString),
|
||||
text: originalText,
|
||||
translation: translatedText,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { shortcutRegister } from "../../libs/shortcut";
|
||||
import { sendIframeMsg } from "../../libs/iframe";
|
||||
import { kissLog } from "../../libs/log";
|
||||
import { getI18n } from "../../hooks/I18n";
|
||||
|
||||
export default function Action({ translator, fab }) {
|
||||
const fabWidth = 40;
|
||||
@@ -32,6 +33,8 @@ export default function Action({ translator, fab }) {
|
||||
});
|
||||
const [moved, setMoved] = useState(false);
|
||||
|
||||
const { fabClickAction = 0 } = fab || {};
|
||||
|
||||
const handleWindowResize = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
@@ -96,11 +99,11 @@ export default function Action({ translator, fab }) {
|
||||
// 注册菜单
|
||||
try {
|
||||
const menuCommandIds = [];
|
||||
const { contextMenuType } = translator.setting;
|
||||
const { contextMenuType, uiLang } = translator.setting;
|
||||
contextMenuType !== 0 &&
|
||||
menuCommandIds.push(
|
||||
GM.registerMenuCommand(
|
||||
"Toggle Translate",
|
||||
getI18n(uiLang, "translate_switch"),
|
||||
(event) => {
|
||||
translator.toggle();
|
||||
sendIframeMsg(MSG_TRANS_TOGGLE);
|
||||
@@ -109,7 +112,7 @@ export default function Action({ translator, fab }) {
|
||||
"Q"
|
||||
),
|
||||
GM.registerMenuCommand(
|
||||
"Toggle Style",
|
||||
getI18n(uiLang, "toggle_style"),
|
||||
(event) => {
|
||||
translator.toggleStyle();
|
||||
sendIframeMsg(MSG_TRANS_TOGGLE_STYLE);
|
||||
@@ -118,14 +121,14 @@ export default function Action({ translator, fab }) {
|
||||
"C"
|
||||
),
|
||||
GM.registerMenuCommand(
|
||||
"Open Menu",
|
||||
getI18n(uiLang, "open_menu"),
|
||||
(event) => {
|
||||
setShowPopup((pre) => !pre);
|
||||
},
|
||||
"K"
|
||||
),
|
||||
GM.registerMenuCommand(
|
||||
"Open Setting",
|
||||
getI18n(uiLang, "open_setting"),
|
||||
(event) => {
|
||||
window.open(process.env.REACT_APP_OPTIONSPAGE, "_blank");
|
||||
},
|
||||
@@ -139,7 +142,7 @@ export default function Action({ translator, fab }) {
|
||||
});
|
||||
};
|
||||
} catch (err) {
|
||||
kissLog(err, "registerMenuCommand");
|
||||
kissLog("registerMenuCommand", err);
|
||||
}
|
||||
}, [translator]);
|
||||
|
||||
@@ -214,8 +217,14 @@ export default function Action({ translator, fab }) {
|
||||
color="primary"
|
||||
onClick={(e) => {
|
||||
if (!moved) {
|
||||
if (fabClickAction === 1) {
|
||||
translator.toggle();
|
||||
sendIframeMsg(MSG_TRANS_TOGGLE);
|
||||
setShowPopup(false);
|
||||
} else {
|
||||
setShowPopup((pre) => !pre);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TranslateIcon
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +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_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";
|
||||
|
||||
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_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);
|
||||
const { transOpen, textStyle, bgColor, textDiyStyle, transOnly, transTag } =
|
||||
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]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
{gap}
|
||||
<LoadingIcon />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!text || 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: text.replace(/\[(\d+)\]/g, (_, p) => keeps[parseInt(p)]),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{gap}
|
||||
<StyledSpan {...styles}>{text}</StyledSpan>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useI18n, useI18nMd } from "../../hooks/I18n";
|
||||
|
||||
export default function About() {
|
||||
const i18n = useI18n();
|
||||
const [data, loading, error] = useI18nMd("about_md");
|
||||
const { data, loading, error } = useI18nMd("about_md");
|
||||
return (
|
||||
<Box>
|
||||
{loading ? (
|
||||
|
||||
@@ -1,38 +1,52 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Button from "@mui/material/Button";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import {
|
||||
OPT_TRANS_ALL,
|
||||
OPT_TRANS_MICROSOFT,
|
||||
OPT_TRANS_DEEPL,
|
||||
OPT_TRANS_DEEPLFREE,
|
||||
OPT_TRANS_BAIDU,
|
||||
OPT_TRANS_TENCENT,
|
||||
OPT_TRANS_OPENAI,
|
||||
OPT_TRANS_GEMINI,
|
||||
OPT_TRANS_CLOUDFLAREAI,
|
||||
OPT_TRANS_CUSTOMIZE,
|
||||
URL_KISS_PROXY,
|
||||
DEFAULT_FETCH_LIMIT,
|
||||
DEFAULT_FETCH_INTERVAL,
|
||||
} from "../../config";
|
||||
import { useState } from "react";
|
||||
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 { 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 } from "../../libs/utils";
|
||||
import ReusableAutocomplete from "./ReusableAutocomplete";
|
||||
import ShowMoreButton from "./ShowMoreButton";
|
||||
import {
|
||||
OPT_TRANS_DEEPLX,
|
||||
OPT_TRANS_OLLAMA,
|
||||
OPT_TRANS_CUSTOMIZE,
|
||||
OPT_TRANS_NIUTRANS,
|
||||
OPT_TRANS_BUILTINAI,
|
||||
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_PLACEHOLDERS,
|
||||
BUILTIN_PLACETAGS,
|
||||
OPT_TRANS_AZUREAI,
|
||||
} from "../../config";
|
||||
import ValidationInput from "../../hooks/ValidationInput";
|
||||
|
||||
function TestButton({ translator, api }) {
|
||||
function TestButton({ api }) {
|
||||
const i18n = useI18n();
|
||||
const alert = useAlert();
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -40,15 +54,15 @@ function TestButton({ translator, api }) {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [text] = await apiTranslate({
|
||||
translator,
|
||||
text: "hello world",
|
||||
fromLang: "en",
|
||||
toLang: "zh-CN",
|
||||
apiSetting: api,
|
||||
apiSetting: { ...api },
|
||||
useCache: false,
|
||||
usePool: false,
|
||||
});
|
||||
if (!text) {
|
||||
throw new Error("empty reault");
|
||||
throw new Error("empty result");
|
||||
}
|
||||
alert.success(i18n("test_success"));
|
||||
} catch (err) {
|
||||
@@ -62,6 +76,15 @@ function TestButton({ translator, api }) {
|
||||
alert.error(
|
||||
<>
|
||||
<div>{i18n("test_failed")}</div>
|
||||
{msg === err.message ? (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 400,
|
||||
}}
|
||||
>
|
||||
{msg}
|
||||
</div>
|
||||
) : (
|
||||
<pre
|
||||
style={{
|
||||
maxWidth: 400,
|
||||
@@ -70,6 +93,7 @@ function TestButton({ translator, api }) {
|
||||
>
|
||||
{msg}
|
||||
</pre>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} finally {
|
||||
@@ -77,62 +101,132 @@ function TestButton({ translator, api }) {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <CircularProgress size={16} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button size="small" variant="contained" onClick={handleApiTest}>
|
||||
<LoadingButton
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={handleApiTest}
|
||||
loading={loading}
|
||||
>
|
||||
{i18n("click_test")}
|
||||
</Button>
|
||||
</LoadingButton>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiFields({ translator }) {
|
||||
function ApiFields({ apiSlug, isUserApi, deleteApi }) {
|
||||
const { api, update, reset } = useApiItem(apiSlug);
|
||||
const i18n = useI18n();
|
||||
const { api, updateApi, resetApi } = useApi(translator);
|
||||
const [formData, setFormData] = useState({});
|
||||
const [isModified, setIsModified] = useState(false);
|
||||
const [showMore, setShowMore] = 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) => {
|
||||
e.preventDefault();
|
||||
let { name, value, type, checked } = e.target;
|
||||
|
||||
if (type === "checkbox" || type === "switch") {
|
||||
value = checked;
|
||||
}
|
||||
|
||||
setFormData((prevData) => ({
|
||||
...prevData,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
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 handleReset = () => {
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const isConfirmed = await confirm({
|
||||
confirmText: i18n("delete"),
|
||||
cancelText: i18n("cancel"),
|
||||
});
|
||||
|
||||
if (isConfirmed) {
|
||||
deleteApi(apiSlug);
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
url = "",
|
||||
key = "",
|
||||
model = "",
|
||||
prompt = "",
|
||||
apiType,
|
||||
systemPrompt = "",
|
||||
subtitlePrompt = "",
|
||||
// userPrompt = "",
|
||||
customHeader = "",
|
||||
customBody = "",
|
||||
think = false,
|
||||
thinkIgnore = "",
|
||||
fetchLimit = DEFAULT_FETCH_LIMIT,
|
||||
fetchInterval = DEFAULT_FETCH_INTERVAL,
|
||||
} = api;
|
||||
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 = BUILTIN_PLACEHOLDERS[0],
|
||||
placetag = BUILTIN_PLACETAGS[0],
|
||||
region = "",
|
||||
// aiTerms = false,
|
||||
} = formData;
|
||||
|
||||
const handleChange = (e) => {
|
||||
let { name, value } = e.target;
|
||||
switch (name) {
|
||||
case "fetchLimit":
|
||||
value = limitNumber(value, 1, 100);
|
||||
break;
|
||||
case "fetchInterval":
|
||||
value = limitNumber(value, 0, 5000);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
updateApi({
|
||||
[name]: value,
|
||||
});
|
||||
};
|
||||
|
||||
const buildinTranslators = [
|
||||
OPT_TRANS_MICROSOFT,
|
||||
OPT_TRANS_DEEPLFREE,
|
||||
OPT_TRANS_BAIDU,
|
||||
OPT_TRANS_TENCENT,
|
||||
];
|
||||
|
||||
const mulkeysTranslators = [
|
||||
OPT_TRANS_DEEPL,
|
||||
OPT_TRANS_OPENAI,
|
||||
OPT_TRANS_GEMINI,
|
||||
OPT_TRANS_CLOUDFLAREAI,
|
||||
];
|
||||
const keyHelper = useMemo(
|
||||
() => (API_SPE_TYPES.mulkeys.has(apiType) ? i18n("mulkeys_help") : ""),
|
||||
[apiType, i18n]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
{!buildinTranslators.includes(translator) && (
|
||||
<TextField
|
||||
size="small"
|
||||
label={i18n("api_name")}
|
||||
name="apiName"
|
||||
value={apiName}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
{!API_SPE_TYPES.machine.has(apiType) &&
|
||||
apiType !== OPT_TRANS_BUILTINAI && (
|
||||
<>
|
||||
<TextField
|
||||
size="small"
|
||||
@@ -140,6 +234,11 @@ function ApiFields({ translator }) {
|
||||
name="url"
|
||||
value={url}
|
||||
onChange={handleChange}
|
||||
multiline={apiType === OPT_TRANS_DEEPLX}
|
||||
maxRows={10}
|
||||
helperText={
|
||||
apiType === OPT_TRANS_DEEPLX ? i18n("mulkeys_help") : ""
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
@@ -147,75 +246,497 @@ function ApiFields({ translator }) {
|
||||
name="key"
|
||||
value={key}
|
||||
onChange={handleChange}
|
||||
multiline={mulkeysTranslators.includes(translator)}
|
||||
helperText={
|
||||
mulkeysTranslators.includes(translator)
|
||||
? i18n("mulkeys_help")
|
||||
: ""
|
||||
}
|
||||
multiline={API_SPE_TYPES.mulkeys.has(apiType)}
|
||||
maxRows={10}
|
||||
helperText={keyHelper}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(translator === OPT_TRANS_OPENAI || translator === OPT_TRANS_GEMINI) && (
|
||||
<>
|
||||
{apiType === OPT_TRANS_AZUREAI && (
|
||||
<TextField
|
||||
size="small"
|
||||
label={"Region"}
|
||||
name="region"
|
||||
value={region}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{API_SPE_TYPES.ai.has(apiType) && (
|
||||
<>
|
||||
<Box>
|
||||
<Grid container spacing={2} columns={12}>
|
||||
<Grid item xs={12} sm={12} md={6} lg={3}>
|
||||
{/* todo: 改成 ReusableAutocomplete 可选择和填写模型 */}
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
label={"MODEL"}
|
||||
name="model"
|
||||
value={model}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={12} 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={12} sm={12} md={6} lg={3}>
|
||||
<ValidationInput
|
||||
size="small"
|
||||
fullWidth
|
||||
label={"Temperature"}
|
||||
type="number"
|
||||
name="temperature"
|
||||
value={temperature}
|
||||
onChange={handleChange}
|
||||
min={0.0}
|
||||
max={2.0}
|
||||
isFloat={true}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={12} md={6} lg={3}>
|
||||
<ValidationInput
|
||||
size="small"
|
||||
fullWidth
|
||||
label={"Max Tokens"}
|
||||
type="number"
|
||||
name="maxTokens"
|
||||
value={maxTokens}
|
||||
onChange={handleChange}
|
||||
min={0}
|
||||
max={2 ** 15}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={12} md={6} lg={3}></Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label={"PROMPT"}
|
||||
name="prompt"
|
||||
value={prompt}
|
||||
label={"SYSTEM PROMPT"}
|
||||
name="systemPrompt"
|
||||
value={systemPrompt}
|
||||
onChange={handleChange}
|
||||
multiline
|
||||
maxRows={10}
|
||||
helperText={i18n("system_prompt_helper")}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label={"SUBTITLE PROMPT"}
|
||||
name="subtitlePrompt"
|
||||
value={subtitlePrompt}
|
||||
onChange={handleChange}
|
||||
multiline
|
||||
maxRows={10}
|
||||
helperText={i18n("system_prompt_helper")}
|
||||
/>
|
||||
{/* <TextField
|
||||
size="small"
|
||||
label={"USER PROMPT"}
|
||||
name="userPrompt"
|
||||
value={userPrompt}
|
||||
onChange={handleChange}
|
||||
multiline
|
||||
maxRows={10}
|
||||
/> */}
|
||||
</>
|
||||
)}
|
||||
|
||||
{apiType === OPT_TRANS_OLLAMA && (
|
||||
<>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
name="think"
|
||||
value={think}
|
||||
label={i18n("if_think")}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<MenuItem value={false}>{i18n("nothink")}</MenuItem>
|
||||
<MenuItem value={true}>{i18n("think")}</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
size="small"
|
||||
label={i18n("think_ignore")}
|
||||
name="thinkIgnore"
|
||||
value={thinkIgnore}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{apiType === OPT_TRANS_NIUTRANS && (
|
||||
<>
|
||||
<TextField
|
||||
size="small"
|
||||
label={"DictNo"}
|
||||
name="dictNo"
|
||||
value={dictNo}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label={"MemoryNo"}
|
||||
name="memoryNo"
|
||||
value={memoryNo}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{apiType === OPT_TRANS_CUSTOMIZE && (
|
||||
<>
|
||||
<TextField
|
||||
size="small"
|
||||
label={"Request Hook"}
|
||||
name="reqHook"
|
||||
value={reqHook}
|
||||
onChange={handleChange}
|
||||
multiline
|
||||
maxRows={10}
|
||||
FormHelperTextProps={{
|
||||
component: "div",
|
||||
}}
|
||||
helperText={
|
||||
<Box component="pre" sx={{ overflowX: "auto" }}>
|
||||
{i18n("request_hook_helper")}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label={"Response Hook"}
|
||||
name="resHook"
|
||||
value={resHook}
|
||||
onChange={handleChange}
|
||||
multiline
|
||||
maxRows={10}
|
||||
FormHelperTextProps={{
|
||||
component: "div",
|
||||
}}
|
||||
helperText={
|
||||
<Box component="pre" sx={{ overflowX: "auto" }}>
|
||||
{i18n("response_hook_helper")}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{API_SPE_TYPES.batch.has(api.apiType) && (
|
||||
<Box>
|
||||
<Grid container spacing={2} columns={12}>
|
||||
<Grid item xs={12} sm={12} 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={12} sm={12} md={6} lg={3}>
|
||||
<ValidationInput
|
||||
size="small"
|
||||
fullWidth
|
||||
label={i18n("batch_interval")}
|
||||
type="number"
|
||||
name="batchInterval"
|
||||
value={batchInterval}
|
||||
onChange={handleChange}
|
||||
min={100}
|
||||
max={10000}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={12} md={6} lg={3}>
|
||||
<ValidationInput
|
||||
size="small"
|
||||
fullWidth
|
||||
label={i18n("batch_size")}
|
||||
type="number"
|
||||
name="batchSize"
|
||||
value={batchSize}
|
||||
onChange={handleChange}
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={12} md={6} lg={3}>
|
||||
<ValidationInput
|
||||
size="small"
|
||||
fullWidth
|
||||
label={i18n("batch_length")}
|
||||
type="number"
|
||||
name="batchLength"
|
||||
value={batchLength}
|
||||
onChange={handleChange}
|
||||
min={1000}
|
||||
max={100000}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{API_SPE_TYPES.context.has(api.apiType) && (
|
||||
<>
|
||||
<Box>
|
||||
<Grid container spacing={2} columns={12}>
|
||||
<Grid item xs={12} sm={12} 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={12} sm={12} md={6} lg={3}>
|
||||
{" "}
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
label={i18n("context_size")}
|
||||
type="number"
|
||||
name="contextSize"
|
||||
value={contextSize}
|
||||
onChange={handleChange}
|
||||
min={1}
|
||||
max={20}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Grid container spacing={2} columns={12}>
|
||||
<Grid item xs={12} sm={12} md={6} lg={3}>
|
||||
<ValidationInput
|
||||
size="small"
|
||||
fullWidth
|
||||
label={i18n("fetch_limit")}
|
||||
type="number"
|
||||
name="fetchLimit"
|
||||
value={fetchLimit}
|
||||
onChange={handleChange}
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={12} md={6} lg={3}>
|
||||
<ValidationInput
|
||||
size="small"
|
||||
fullWidth
|
||||
label={i18n("fetch_interval")}
|
||||
type="number"
|
||||
name="fetchInterval"
|
||||
value={fetchInterval}
|
||||
onChange={handleChange}
|
||||
min={0}
|
||||
max={5000}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={12} md={6} lg={3}>
|
||||
<ValidationInput
|
||||
size="small"
|
||||
fullWidth
|
||||
label={i18n("http_timeout")}
|
||||
type="number"
|
||||
name="httpTimeout"
|
||||
value={httpTimeout}
|
||||
onChange={handleChange}
|
||||
min={5000}
|
||||
max={60000}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={12} md={6} lg={3}></Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TestButton translator={translator} api={api} />
|
||||
{showMore && (
|
||||
<>
|
||||
<Box>
|
||||
<Grid container spacing={2} columns={12}>
|
||||
<Grid item xs={12} sm={12} md={6} lg={3}>
|
||||
<TextField
|
||||
select
|
||||
fullWidth
|
||||
size="small"
|
||||
name="placeholder"
|
||||
value={placeholder}
|
||||
label={i18n("api_placeholder")}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{BUILTIN_PLACEHOLDERS.map((item) => (
|
||||
<MenuItem key={item} value={item}>
|
||||
{item}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={12} md={6} lg={3}>
|
||||
<TextField
|
||||
select
|
||||
fullWidth
|
||||
size="small"
|
||||
name="placetag"
|
||||
value={placetag}
|
||||
label={i18n("api_placetag")}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{BUILTIN_PLACETAGS.map((item) => (
|
||||
<MenuItem key={item} value={item}>
|
||||
{`<${item}>`}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{apiType !== OPT_TRANS_BUILTINAI && (
|
||||
<>
|
||||
{" "}
|
||||
<TextField
|
||||
size="small"
|
||||
label={i18n("custom_header")}
|
||||
name="customHeader"
|
||||
value={customHeader}
|
||||
onChange={handleChange}
|
||||
multiline
|
||||
maxRows={10}
|
||||
helperText={i18n("custom_header_help")}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label={i18n("custom_body")}
|
||||
name="customBody"
|
||||
value={customBody}
|
||||
onChange={handleChange}
|
||||
multiline
|
||||
maxRows={10}
|
||||
helperText={i18n("custom_body_help")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{apiType !== OPT_TRANS_CUSTOMIZE &&
|
||||
apiType !== OPT_TRANS_BUILTINAI && (
|
||||
<>
|
||||
<TextField
|
||||
size="small"
|
||||
label={"Request Hook"}
|
||||
name="reqHook"
|
||||
value={reqHook}
|
||||
onChange={handleChange}
|
||||
multiline
|
||||
maxRows={10}
|
||||
FormHelperTextProps={{
|
||||
component: "div",
|
||||
}}
|
||||
helperText={
|
||||
<Box component="pre" sx={{ overflowX: "auto" }}>
|
||||
{i18n("request_hook_helper")}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label={"Response Hook"}
|
||||
name="resHook"
|
||||
value={resHook}
|
||||
onChange={handleChange}
|
||||
multiline
|
||||
maxRows={10}
|
||||
FormHelperTextProps={{
|
||||
component: "div",
|
||||
}}
|
||||
helperText={
|
||||
<Box component="pre" sx={{ overflowX: "auto" }}>
|
||||
{i18n("response_hook_helper")}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={2}
|
||||
useFlexGap
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
disabled={!isModified}
|
||||
>
|
||||
{i18n("save")}
|
||||
</Button>
|
||||
<TestButton api={formData} />
|
||||
<Button size="small" variant="outlined" onClick={handleReset}>
|
||||
{i18n("restore_default")}
|
||||
</Button>
|
||||
{isUserApi && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
resetApi();
|
||||
}}
|
||||
color="error"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{i18n("restore_default")}
|
||||
{i18n("delete")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
name="isDisabled"
|
||||
checked={isDisabled}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
}
|
||||
label={i18n("is_disabled")}
|
||||
/>
|
||||
|
||||
<ShowMoreButton showMore={showMore} onChange={setShowMore} />
|
||||
</Stack>
|
||||
|
||||
{translator === 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 handleChange = (e) => {
|
||||
@@ -225,10 +746,23 @@ function ApiAccordion({ translator }) {
|
||||
return (
|
||||
<Accordion expanded={expanded} onChange={handleChange}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography>{translator}</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
opacity: api.isDisabled ? 0.5 : 1,
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{`[${api.apiType}] ${api.apiName}`}
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{expanded && <ApiFields translator={translator} />}
|
||||
{expanded && (
|
||||
<ApiFields
|
||||
apiSlug={api.apiSlug}
|
||||
isUserApi={isUserApi}
|
||||
deleteApi={deleteApi}
|
||||
/>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
@@ -236,18 +770,91 @@ 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">
|
||||
<Link href={URL_KISS_PROXY} target="_blank">
|
||||
{i18n("about_api_proxy")}
|
||||
</Link>
|
||||
{i18n("about_api")}
|
||||
<br />
|
||||
{i18n("about_api_2")}
|
||||
<br />
|
||||
{i18n("about_api_3")}
|
||||
</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>
|
||||
|
||||
@@ -2,12 +2,19 @@ import IconButton from "@mui/material/IconButton";
|
||||
import { useDarkMode } from "../../hooks/ColorMode";
|
||||
import LightModeIcon from "@mui/icons-material/LightMode";
|
||||
import DarkModeIcon from "@mui/icons-material/DarkMode";
|
||||
import BrightnessAutoIcon from "@mui/icons-material/BrightnessAuto";
|
||||
|
||||
export default function DarkModeButton() {
|
||||
const { darkMode, toggleDarkMode } = useDarkMode();
|
||||
return (
|
||||
<IconButton onClick={toggleDarkMode} color="inherit">
|
||||
{darkMode ? <LightModeIcon /> : <DarkModeIcon />}
|
||||
<IconButton sx={{ ml: 1 }} onClick={toggleDarkMode} color="inherit">
|
||||
{darkMode === "dark" ? (
|
||||
<DarkModeIcon />
|
||||
) : darkMode === "light" ? (
|
||||
<LightModeIcon />
|
||||
) : (
|
||||
<BrightnessAutoIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user