Compare commits

..

299 Commits

Author SHA1 Message Date
Gabe
562559a1b0 update version number 2025-10-14 19:30:33 +08:00
Gabe
0f0b7313bb fix: styles 2025-10-14 19:15:53 +08:00
Gabe
412fc87d1e fix: bugs 2025-10-14 19:03:18 +08:00
Gabe
0a1abab475 fix: add notranslate class 2025-10-14 11:13:03 +08:00
Gabe
b63ef8c1aa fix: throw more errors in translate api 2025-10-14 10:31:49 +08:00
Gabe
c38a3d439d fix: readme 2025-10-13 23:53:34 +08:00
Gabe
dd395e668f fix blacklist 2025-10-13 23:46:04 +08:00
Gabe
8a5ef441f9 fix: rules 2025-10-13 23:32:14 +08:00
Gabe
d9acbe865f fix: rules 2025-10-13 23:26:29 +08:00
Gabe
dc35fef873 feat: add more styles 2025-10-13 22:15:42 +08:00
Gabe
4e9293ae0f fix: stubtitle 2025-10-13 14:15:14 +08:00
Gabe
36973b8693 fix: bing dict 2025-10-13 11:29:22 +08:00
Gabe
0ab734d1a5 fix: update readme 2025-10-13 00:54:02 +08:00
Gabe
bfce9b525a feat: support dynamically set log level 2025-10-12 23:17:50 +08:00
Gabe
f19b6ef02f fix: update readme 2025-10-12 15:40:37 +08:00
Gabe
1992908a85 fix: subtitle 2025-10-12 14:36:28 +08:00
Gabe
a6bcafa8f6 feat: subtitle can be draggleable 2025-10-12 14:16:26 +08:00
Gabe
eabcb06eeb fix: youtube rule 2025-10-12 12:37:29 +08:00
Gabe
21dcbfa4c4 fix: subtitle 2025-10-12 12:25:11 +08:00
Gabe
b6c074a242 fix: subtitle 2025-10-12 12:05:18 +08:00
Gabe
f6d095d533 fix: subtitle 2025-10-12 12:02:41 +08:00
Gabe
7a36251d3f fix: subtitle 2025-10-12 11:51:41 +08:00
Gabe
3fda4d0da9 fix: subtitle 2025-10-12 11:45:31 +08:00
Gabe
2fa8917d5e fix: subtitle 2025-10-12 02:57:45 +08:00
Gabe
67149af64b fix: number input 2025-10-12 02:19:25 +08:00
Gabe
0104cb9f29 feat: add notice for subtitle 2025-10-12 01:41:53 +08:00
Gabe
1afe976777 feat: support subtitle chunks for AI 2025-10-11 21:06:38 +08:00
Gabe
d9b4399c57 feat: support AzureAI 2025-10-11 14:46:14 +08:00
Gabe
ac7b3b9824 fix: switch button 2025-10-11 12:11:13 +08:00
Gabe
359206630d feat: add default styles example 2025-10-11 12:01:36 +08:00
Gabe
96dfee90ab fix: firefox bugs 2025-10-11 11:19:33 +08:00
Gabe
9ace600fce feat: support local html 2025-10-11 01:26:09 +08:00
Gabe
549b945d0f fix: svg 2025-10-11 00:19:48 +08:00
Gabe
4528a79c87 fix: svg 2025-10-11 00:15:10 +08:00
Gabe
951ce985b5 fix: batch queue 2025-10-10 23:07:18 +08:00
Gabe
001f04a9ee fix: subtitle 2025-10-10 21:17:51 +08:00
Gabe
3844d2eb75 fix: rules 2025-10-10 15:05:47 +08:00
Gabe
e593221e02 fix: i18n 2025-10-10 14:17:07 +08:00
Gabe
8cc3801dc5 fix: youtube rule 2025-10-10 13:59:17 +08:00
Gabe
251e57ec61 fix: subtitle 2025-10-10 13:49:04 +08:00
Gabe
769a4f00aa fix: bing dict 2025-10-10 00:08:38 +08:00
Gabe
9bafc937d5 feat: subtitle: support ai segmentation 2025-10-09 23:55:06 +08:00
Gabe
2d0ea09e06 fix: subtitle 2025-10-09 11:55:02 +08:00
Gabe
aeec5e361c fix: subtitle 2025-10-09 09:49:12 +08:00
Gabe
71b2d62c9f feat: format subtitle 2025-10-09 02:15:58 +08:00
Gabe
40b3072e5f fix: subtitles 2025-10-08 00:02:12 +08:00
Gabe
cae9338274 fix: subtitles 2025-10-08 00:01:22 +08:00
Gabe
4c2781b3b6 fix: gemini api 2025-10-07 21:10:51 +08:00
Gabe
b2b5bef9f5 feat: support subtitle translate 2025-10-07 16:35:00 +08:00
Gabe
df8c96569a fix: rename data to body 2025-10-05 17:47:29 +08:00
Gabe
e562f0b851 fix: youdao dict 2025-10-04 22:29:03 +08:00
Gabe
7b2b48f0d1 feat: support builtin AI 2025-10-04 21:25:54 +08:00
Gabe
c353c88db8 feat: support bing dict 2025-10-03 22:07:48 +08:00
Gabe
171dbb7509 feat: support youdao dict 2025-10-03 18:28:50 +08:00
Gabe
65e8fabe7d feat: add playground 2025-10-02 21:59:31 +08:00
Gabe
389f0b6f82 feat: Support for multiple translation services in tranbox 2025-10-02 11:33:33 +08:00
Gabe
039566ded5 fix: deeplfree api 2025-10-01 23:22:14 +08:00
Gabe
d18b31692b fix: Minimize the rule file 2025-10-01 22:46:55 +08:00
Gabe
c993c15c92 feat: support translate all now 2025-10-01 21:18:53 +08:00
Gabe
3c5ffc045f feat: support AI terms 2025-10-01 16:18:19 +08:00
Gabe
261bb7aa6f fix: placeholder 2025-10-01 15:35:20 +08:00
Gabe
96a7a41759 feat: support placeholder selection 2025-10-01 13:22:22 +08:00
Gabe
d563521eb1 feat: confirm_before clear rules 2025-10-01 12:30:58 +08:00
Gabe
a08c42db8b feat: add button tips 2025-10-01 12:08:10 +08:00
Gabe
8e026238ae fix: show error message dom 2025-10-01 11:18:41 +08:00
Gabe
7412b3a5c8 feat: Add more shortcut keys to popup 2025-10-01 01:47:15 +08:00
Gabe
b60b770ed6 feat: move 'remote detect' from rule to setting 2025-09-30 01:38:07 +08:00
Gabe
20c4d6f6eb feat: remove overwrite subrule 2025-09-30 00:51:31 +08:00
Gabe
2437c75d75 feat: support batch langdetect 2025-09-27 23:34:27 +08:00
Gabe
867c2209b1 feat: support batch langdetect 2025-09-27 23:33:33 +08:00
Gabe
fffa448425 fix: lang detect 2025-09-27 21:21:56 +08:00
Gabe
b1142b88f1 feat: add tarnbox title 2025-09-26 21:13:04 +08:00
Gabe
6d95e7debc feat: add import words helper 2025-09-26 20:56:52 +08:00
Gabe
6bafcb0ec0 fix: change default selectors 2025-09-26 20:45:48 +08:00
Gabe
4935abcf33 feat: Merge default items when saving rule 2025-09-26 12:40:19 +08:00
Gabe
14f74b76bb fix: update rules 2025-09-26 01:46:58 +08:00
Gabe
6b9a1a49bb fix: api hooks 2025-09-25 23:08:39 +08:00
Gabe
533a0e2d5b feat: export old setting 2025-09-25 12:53:46 +08:00
Gabe
393f1a29d5 feat: add preinit setting 2025-09-25 11:31:12 +08:00
Gabe
1dabbfc4de fix: slice check 2025-09-25 01:50:22 +08:00
Gabe
7665f8c260 fix: text styles 2025-09-25 01:13:40 +08:00
Gabe
eef5e25a00 fix: text styles 2025-09-25 01:02:38 +08:00
Gabe
261f29c185 fix: mousehover keys can be set blank 2025-09-25 00:21:51 +08:00
Gabe
2a46939aa5 feat: Extensive refactoring and modification to support any number of interfaces 2025-09-24 23:24:00 +08:00
Gabe
779c9fc850 fix: Identify empty text in advance 2025-09-21 20:55:39 +08:00
Gabe
a20a06320d Merge remote-tracking branch 'origin/dev' into dev 2025-09-21 19:52:16 +08:00
Gabe
943a9e86f0 feat: Restructured core logic to support automatic page scanning and rich text translation 2025-09-21 19:51:57 +08:00
Zack C
563242c5f1 添加可以选择悬浮按钮单击操作的设置选项 (#313)
* Add an option in Settings to provide choices for the FAB Single Click action.

* Complete Translation

---------

Co-authored-by: 1030283726 <1030283726@qq.com>
2025-09-20 22:50:39 +08:00
叫我沈同学
d39a016d5f macOS and iOS support (#312)
* Update README.md

* Update README.en.md
2025-09-16 08:47:36 +08:00
@刘羡鱼
fa87d87011 feat(sync): 通过 base64 复制粘贴配置 (#311)
motivation: 同浏览器多个 profile 简化配置流程,通过同一个同步配置即可实现
2025-09-16 08:44:07 +08:00
Gabe
7dc847fca2 refactor: rewrite task pool 2025-09-03 22:07:49 +08:00
Gabe
343edcdbad feat: support ai context 2025-09-03 20:43:07 +08:00
Gabe
b631703aa6 fix: update ollama api 2025-09-03 14:02:55 +08:00
Gabe
6dd6b73c2f refactor: rename fn name 2025-09-03 13:05:41 +08:00
Gabe
2b496bda31 refactor: rename api args 2025-09-03 12:24:18 +08:00
Gabe
17c8d198c3 fix: api settings 2025-09-03 12:16:41 +08:00
Gabe
86f8d9694d fix: update ai format 2025-09-03 12:04:56 +08:00
Gabe
3948cb74ca refactor: optimize batchqueue 2025-09-03 10:16:21 +08:00
Gabe
4ebced1e71 refactor: rename fn 2025-09-03 09:40:25 +08:00
Gabe
d4e58fc925 feat: Support batch fetch, and update AI prompt 2025-09-03 00:37:35 +08:00
Gabe
2bfb27f346 refactor: Split config files 2025-09-01 18:56:48 +08:00
Gabe
c4fba1c905 refactor: Optimize data and cache request logic 2025-08-31 23:37:29 +08:00
Gabe
4a5e6c2a23 fix: Adjust the order of hook function parameters 2025-08-30 18:21:28 +08:00
Gabe
5fb7157f57 feat: can restore default rule in editor 2025-08-29 23:03:41 +08:00
Gabe
a8c38d2a00 fix: change default sub-rules url 2025-08-29 22:40:01 +08:00
Gabe
cbc82fff64 fix: dashbox style 2025-08-27 01:05:35 +08:00
Gabe
5c44ba1da8 feat: enhanced hook function functionality 2025-08-27 01:02:11 +08:00
Gabe
fd2f0e513b fix: i18n text 2025-08-26 21:22:21 +08:00
Gabe
7fd2a0f187 feat: move global rule to a separate tab 2025-08-26 21:19:47 +08:00
Gabe
fd355eeeab fix: revert shadow dom PR (c6f4fe2b7b) 2025-08-26 00:28:31 +08:00
Gabe
c93e370370 fix: rm tsx package 2025-08-25 00:25:25 +08:00
Gabe
57d218a17f Merge remote-tracking branch 'origin/master' into dev 2025-08-25 00:18:13 +08:00
rxliuli
ffc43a67f2 chore: add safari build script (#299) 2025-08-25 00:17:51 +08:00
Gabe
7deb5b885a doc: remove .obsidian files 2025-08-24 22:51:43 +08:00
GoldenXPig
5d5e23482f 添加 OpenRouter 支持 (#300)
* add openrouter provider

* remove package-lock.json
2025-08-23 11:01:54 +08:00
Gabe
36c1e40d64 feat: compatible with json strings with curly braces { 2025-08-23 00:10:42 +08:00
rxliuli
c6f4fe2b7b feat: auto pierce deep shadow dom (#297)
* feat: auto pierce deep shadow dom

* fix: observe all shadow DOM child element changes

* chore: remove >>> selector require note for shadow DOM
2025-08-22 21:58:57 +08:00
Gabe
1a23627193 doc: update readme 2025-08-22 21:50:35 +08:00
Gabe
d73b2377bf Merge remote-tracking branch 'origin/master' into dev 2025-08-22 21:46:07 +08:00
Gabe
4559ab7ec2 Merge pull request #296 from GinWU05/local
fix: `TypeError: Cannot read properties of undefined (reading 'trim')`
2025-08-22 15:38:29 +08:00
ginwu
1d9679e516 fix: prevent TypeError when processing DOM elements without innerText 2025-08-22 14:33:31 +08:00
Gabe
4c30f6b012 Merge pull request #294 from hoilc/patch-1
fix(claude): enable CORS support
2025-08-22 10:47:42 +08:00
Gabe
511210939f Merge pull request #293 from zh1030283726/master
Add zh-TW support
2025-08-22 10:47:17 +08:00
hoilc
d2addf58cb fix(claude): enable CORS support 2025-08-22 08:03:10 +08:00
1030283726
f7db410235 Add zh-TW support 2025-08-21 23:34:28 +10:00
1030283726
dd18b04cea Add zh-TW support 2025-08-21 23:32:56 +10:00
1030283726
58d8009e91 Add zh-TW support 2025-08-21 23:25:06 +10:00
Gabe
663407b95d Merge pull request #291 from lrsister/master 2025-08-19 13:36:51 +08:00
lrsister
f78901603e docs: add future plans section to README 2025-08-19 10:18:31 +08:00
lrsister
5e1101baeb docs: update README with future plans 2025-08-19 10:11:40 +08:00
Gabe
2ba2441900 Merge pull request #281 from dev-soragoto/dev
增加虚线框的翻译样式
2025-08-12 12:00:04 +08:00
soragoto
4446ae3dbd 增加虚线框的翻译样式 2025-08-12 11:23:19 +08:00
Gabe
72668f0386 Merge pull request #272 from FlyLoongZ/master
添加AI API 接口参数自定义(Body和Header)
2025-08-11 22:47:33 +08:00
FlyLoongZ
a9b858ec6f feat: Add AI API Custom Header 2025-08-11 16:28:23 +08:00
FlyLoongZ
e1f902c203 Rename apiCustomParams to customBody 2025-08-11 16:03:30 +08:00
FlyLoongZ
be6e34ba52 feat: Add AI API Custom Params 2025-08-11 12:12:03 +08:00
Gabe
39b3b00117 release: v1.9.2 2025-08-10 23:00:21 +08:00
Gabe
763019f0c5 doc: update custom api help 2025-08-10 22:56:36 +08:00
Gabe
d743271be8 fix: show custom api name 2025-08-10 22:32:38 +08:00
Gabe
992dad26aa doc: update readme 2025-08-10 22:01:50 +08:00
Gabe
9bd0e67474 doc: update readme 2025-08-10 21:57:26 +08:00
Gabe
5767a4afb2 doc: update readme 2025-08-10 21:54:24 +08:00
Gabe
9e4c510684 doc: update custom api help 2025-08-10 21:50:33 +08:00
Gabe
16607fb069 doc: add custom api help 2025-08-10 21:40:58 +08:00
Gabe
e047a06432 doc: update readme 2025-08-10 20:12:12 +08:00
Gabe
9e09fd898a doc: update readme 2025-08-10 17:37:15 +08:00
Gabe
799c32a871 Merge remote-tracking branch 'origin/master' 2025-08-10 17:33:14 +08:00
Gabe
483f33b5c9 Merge pull request #269 from WilliamK7/patch-1
doc: update readme
2025-08-10 17:32:23 +08:00
Gabe
d444fd4fba fix: set max-width to loading svg 2025-08-10 17:28:28 +08:00
Gabe
0aae93ba2e doc: update help text 2025-08-10 16:56:34 +08:00
Gabe
9608bea3bf doc: update warn text 2025-08-10 16:44:57 +08:00
WilliamK7
a038a1ecdc doc: update readme
Remove duplicates in Chinese README
2025-08-10 13:56:29 +08:00
Gabe
c82cdd7f8f doc: update readme 2025-08-10 12:48:40 +08:00
Gabe
0c6d5c3c61 fix: replace deault tag from span to font 2025-08-09 22:32:41 +08:00
Gabe
900426f359 Merge branch 'master' into dev 2025-08-09 22:03:52 +08:00
Gabe
1d760fc93a Merge pull request #258 from mumu-lhl/push-mlqssmoqoqko
API: Replace max_tokens with max_completion_tokens
2025-08-09 22:03:12 +08:00
Gabe
61571e0f61 fix: remove browser contextMenus (issue #262) 2025-08-09 21:36:43 +08:00
Gabe
c9eb423c89 doc: update readme 2025-08-09 21:06:18 +08:00
Gabe
45b294a121 fix: retranslate loadmore text (issue #257) 2025-08-09 20:55:04 +08:00
Gabe
3a3f1fabe1 doc: update readme 2025-08-09 11:41:24 +08:00
Mumulhl
03177a09b3 API: Replace max_tokens with max_completion_tokens 2025-08-09 11:35:47 +08:00
Gabe
cae391f62b fix: zip script 2025-07-23 21:56:54 +08:00
Gabe
2a5e9db079 v1.9.1 2025-07-23 21:03:02 +08:00
Gabe
650d6e8b41 fix: workflows 2025-07-23 20:58:50 +08:00
Gabe
1daf134b31 fix: gemini api 2025-07-23 20:03:54 +08:00
Gabe
e1dfa35c6c v1.9.0 2025-07-23 00:38:33 +08:00
Gabe
73f80692d3 feat: add format script (prettier) 2025-07-03 19:08:33 +08:00
Gabe
42c7dae495 fix: log 2025-07-03 18:08:49 +08:00
Gabe
b2a1309caa feat: add gemini2 api 2025-07-02 21:54:18 +08:00
Gabe
94bf5f9580 fix: gemini api 2025-07-02 16:37:57 +08:00
Gabe
704ebdc9d7 fix: variable name 2025-07-02 13:38:30 +08:00
Gabe
165da4e559 fix: rule page: show more button 2025-07-01 23:34:16 +08:00
Gabe
d3e3b484bf fix: remove excess code 2025-07-01 22:47:04 +08:00
Gabe
192f8faa5b feat: support keypick for customize API 2025-07-01 22:42:57 +08:00
Gabe
579d5cb0a3 fix: update userscript 2025-07-01 17:55:57 +08:00
Gabe
07fca5b9af fix: #209 2025-07-01 17:03:52 +08:00
Gabe
866a63ab6c feat: move selected translation switch from setting to rule 2025-07-01 16:44:46 +08:00
Gabe
97b4935bc4 feat: api fetch timeout 2025-07-01 12:38:06 +08:00
Gabe
30129abef3 feat: custom API name 2025-07-01 10:54:30 +08:00
Gabe
24d904b32c feat: support volcengine api 2025-06-30 21:34:37 +08:00
Gabe
5f0ce57ead feat: qq transmart 2025-06-27 20:03:58 +08:00
Gabe
51f58d095a feat: add new google translate api (issue: 225, by: Bush2021) 2025-06-27 19:29:00 +08:00
Gabe
d22e3838c4 refactor: deepModels -> tinkIgnore 2025-06-27 16:33:30 +08:00
Gabe
adbb421b7b refactor: fetch timeout 2025-06-27 12:31:32 +08:00
Gabe
eaa47af269 fix: revert old google translate api 2025-06-26 11:13:51 +08:00
Gabe
a6cb5544f8 fix: browser.menus -> browser.contextMenus 2025-06-26 10:01:43 +08:00
Gabe
9e91faa660 Merge pull request #240 from unclemcz/dev
feat:ollama接口设置新增是否禁用深度思考参数
2025-06-25 20:42:31 +08:00
mcz
8636fadc72 接口设置ollama新增是否禁用深度思考参数 2025-06-03 23:07:10 +08:00
Gabe
0621957592 chore: thunderbird 2025-05-18 00:34:20 +08:00
Gabe
8ec06b0c84 chore: defined messenger in package.json 2025-05-18 00:29:06 +08:00
Gabe
d47f8d7ee9 Merge remote-tracking branch 'origin/dev' into dev 2025-05-17 23:22:09 +08:00
Gabe
24f8959525 fix: Ignore html comment elements 2025-05-17 23:19:38 +08:00
Gabe
983740578b Merge pull request #235 from unclemcz/dev
feat:基本设置增加请求超时参数&ollama接口配置增加<think>块忽略参数
2025-05-09 14:09:18 +08:00
Gabe
b5f79ed7cd Merge pull request #232 from Bush2021/fix-workflow
build: fix build errors caused by deprecated actions
2025-05-09 14:08:47 +08:00
Gabe
bbb0e79d4e Merge pull request #231 from Bush2021/fix-google-translate
fix: update API for Google Translate
2025-05-09 14:08:16 +08:00
mcz
471dc05897 ollama接口设置增加<think>块忽略参数 2025-05-01 23:41:08 +08:00
mcz
7a772d2459 在基本设置页面增加接口请求超时时间设置 2025-05-01 20:04:58 +08:00
mcz
1d92421960 Remove the <think></think> tags in qwen3 too. 2025-04-29 19:49:14 +08:00
Bush2021
aeaaf429d7 build: fix build errors caused by deprecated actions 2025-04-16 18:45:41 -04:00
Bush2021
84432e98ae fix: update API for Google Translate 2025-04-16 18:00:54 -04:00
Gabe
77c6102de7 Merge pull request #219 from unclemcz/dev
fix:(Ollama)Remove the <think></think> tags in deepseek-r1.
2025-03-12 17:18:26 +08:00
mcz
ab5dd82169 When using the deepseek-r1 model in Ollama, remove the content between the <think></think> tags. 2025-02-23 11:54:48 +08:00
Gabe
23e7b69dc5 Merge pull request #213 from htyxyt/master
采用更好的方式支持thunderbird
2025-02-19 18:09:01 +08:00
htyxyt
3dc8f393f2 Update package.json 2025-02-18 12:56:29 +08:00
htyxyt
8a2144f263 Update background.js 2025-02-18 12:44:06 +08:00
htyxyt
c1c59caa10 Update package.json 2025-02-18 12:29:43 +08:00
htyxyt
d27ebd90b6 Update package.json 2025-02-18 12:14:02 +08:00
htyxyt
467745c1e9 Update package.json 2025-02-18 11:58:39 +08:00
htyxyt
537378a038 Update background.js 2025-02-18 11:45:57 +08:00
htyxyt
4b5ed30e5b Delete src/background.thunderbird.js 2025-02-18 11:42:57 +08:00
htyxyt
52b7f6a225 Update background.js 2025-02-18 11:42:47 +08:00
htyxyt
f31675d8a2 Update index.js 2025-02-18 11:42:05 +08:00
htyxyt
dd46a8450c Update config-overrides.js 2025-02-18 11:41:28 +08:00
htyxyt
b0843f7d66 Update package.json 2025-02-18 11:39:58 +08:00
htyxyt
daadc0195c Update background.thunderbird.js 2025-02-18 10:25:12 +08:00
Gabe
298dec6957 Merge pull request #210 from htyxyt/master
添加对Thunderbird的支持
2025-02-17 23:48:40 +08:00
Gabe
bf39d85dfa Merge pull request #212 from qonmnop/patch-1
允许用户自定义 hooks 中重写signal 参数,自定义超时时间
2025-02-17 23:30:19 +08:00
htyxyt
30a9de25a8 Update config-overrides.js 2025-02-17 14:01:17 +08:00
htyxyt
af1ecf0bd4 Update package.json 2025-02-17 14:00:35 +08:00
qonmnop
fe55a2cd3c 允许用户自定义 hooks 中重写signal 参数,自定义超时时间 2025-02-17 12:14:10 +08:00
htyxyt
5a33d4e57e Add files via upload 2025-02-17 11:57:40 +08:00
htyxyt
7f46a9023c Delete public/background.thunderbird.js 2025-02-17 11:57:12 +08:00
htyxyt
dfd943b621 Rename manifest.thunderfird.json to manifest.thunderbird.json 2025-02-17 11:10:29 +08:00
htyxyt
7007d0d922 Update package.json 2025-02-17 10:39:08 +08:00
htyxyt
601678500d Update package.json 2025-02-14 15:23:03 +08:00
htyxyt
9bfb504381 Add files via upload 2025-02-14 15:13:40 +08:00
htyxyt
8a03b0cf15 Delete src/background.thunderfird.js 2025-02-14 15:12:57 +08:00
htyxyt
11ba89de0a Update manifest.thunderfird.json 2025-02-14 14:57:38 +08:00
htyxyt
bac7f62eea Update background.thunderfird.js 2025-02-14 14:56:58 +08:00
htyxyt
eef90ea02b Delete src/popup.thunderfird.js 2025-02-14 14:56:10 +08:00
htyxyt
0650df534a Delete src/content.thunderfird.js 2025-02-14 14:56:00 +08:00
htyxyt
9ef8c8b823 Delete src/options.thunderfird.js 2025-02-14 14:55:49 +08:00
htyxyt
d7e08da0b2 Add files via upload 2025-02-13 14:53:06 +08:00
htyxyt
ef361e0798 Add files via upload 2025-02-13 14:50:33 +08:00
Gabe
6855332092 fix: modify systemPrompt & userPrompt 2024-11-30 00:41:29 +08:00
Gabe
121d523e02 Merge remote-tracking branch 'origin/dev' into dev 2024-11-30 00:09:48 +08:00
Gabe
42a375c4c7 Merge pull request #188 from unclemcz/dev
给ollama增加system message
2024-11-30 00:07:14 +08:00
Gabe
a1dd705d97 fix: update pnpm-lock file 2024-11-29 21:14:30 +08:00
mcz
71f90b36ca 给ollama增加system message 2024-09-30 16:41:58 +08:00
Gabe
37facdc3c1 Merge pull request #186 from hoilc/dev
feat: enhance openai prompt
2024-09-26 23:38:34 +08:00
hoilc
66b4f547ff feat: enhance openai prompt 2024-09-25 14:03:12 +08:00
Gabe
d27b9c7f2d Merge pull request #185 from hoilc/dev
feat: support claude api
2024-09-24 23:30:00 +08:00
hoilc
278ff9c6bc feat: support claude api 2024-09-23 18:22:19 +08:00
Gabe Yuan
d6fe1ce9d7 fix: try detect language only when fromLang is auto 2024-05-30 21:05:05 +08:00
Gabe Yuan
0bfa5256b8 fix: simplify keepSelector logic 2024-05-30 17:18:39 +08:00
Gabe Yuan
72ccfc8aec v1.8.11 2024-05-23 20:06:48 +08:00
Gabe Yuan
d117c5dc10 feat: baidu dict can be disabled 2024-05-23 00:08:10 +08:00
Gabe Yuan
9312783f44 feat: lang detector can be selected 2024-05-22 23:33:30 +08:00
Gabe Yuan
e5b16ebfd3 Merge remote-tracking branch 'origin/master' into dev 2024-05-22 10:19:04 +08:00
Gabe Yuan
5d1d65c2d3 feat: the temperature and maxTokens of the openai can be configured 2024-05-21 23:15:46 +08:00
Gabe
9ca1309cec Merge pull request #126 from kebyn/master
fix: option translation
2024-05-21 20:02:53 +08:00
kebyn
a03afc05f5 fix: option translation 2024-05-21 08:56:09 +00:00
Gabe Yuan
0198963584 fix: deeplx: replace auto to blank string 2024-05-21 11:55:17 +08:00
Gabe Yuan
58e745d967 v1.8.10 2024-05-17 10:38:11 +08:00
Gabe Yuan
377e347d68 feat: support translate hooks 2024-05-15 11:07:13 +08:00
Gabe Yuan
bac0704d3d feat: download and upload settings 2024-05-12 20:24:40 +08:00
Gabe Yuan
d2ff46edf6 fix: show full gemini url 2024-05-12 16:25:20 +08:00
Gabe Yuan
f908372b4e feat: support hook for custom api 2024-05-12 16:10:11 +08:00
Gabe Yuan
5d44ff4913 v1.8.9 2024-04-28 22:23:53 +08:00
Gabe Yuan
4c9aa66048 feat: support ollama api 2024-04-28 21:45:20 +08:00
Gabe Yuan
b6a09b99ab feat: support ollama api 2024-04-28 21:43:20 +08:00
Gabe Yuan
3a0dcb1a52 feat: add more openai translator 2024-04-28 16:58:09 +08:00
Gabe Yuan
5015503b4c feat: hide transbox header when mouseleave 2024-04-28 14:56:49 +08:00
Gabe Yuan
16423feea4 fix: update readme 2024-04-21 22:21:29 +08:00
Gabe Yuan
9703514698 v1.8.8 2024-04-21 19:25:00 +08:00
Gabe Yuan
de7a97fb76 feat: tranbox offset 2024-04-21 19:19:06 +08:00
Gabe Yuan
319aaf8132 fix: move taskpool from background to content 2024-04-21 13:16:44 +08:00
Gabe Yuan
74bc58ba91 feat: transbox follow selection 2024-04-20 18:07:16 +08:00
Gabe Yuan
d622db0d7c fix: i18n menu command for userscript 2024-04-20 15:54:41 +08:00
Gabe Yuan
de1ddf2362 fix: stopPropagation when close tranbox 2024-04-20 15:12:25 +08:00
Gabe Yuan
32c0fc860b fix: update readme 2024-04-20 14:11:32 +08:00
Gabe Yuan
1938f432dd feat: support multi url for DEEPLX 2024-04-20 14:01:34 +08:00
Gabe Yuan
a5cfb0ca1d fix: fetch pool retry 2024-04-20 11:52:16 +08:00
Gabe Yuan
a172234fb0 fix: niutrans i18n text 2024-04-18 12:31:16 +08:00
Gabe Yuan
63f989b31a v1.8.7 2024-04-18 10:07:53 +08:00
Gabe Yuan
2ae5d01d5c fix: custom option 2024-04-18 09:48:07 +08:00
Gabe Yuan
130f1deed1 fix: remove encodeURIComponent 2024-04-18 00:31:36 +08:00
Gabe Yuan
5880d85b48 fix: encodeURIComponent text 2024-04-17 23:44:53 +08:00
Gabe Yuan
9455670e80 feat: custom request 2024-04-17 22:35:12 +08:00
Gabe Yuan
e369321c66 feat: custom request 2024-04-17 17:38:54 +08:00
Gabe Yuan
efc51b0d46 feat: extend styles for transbox 2024-04-17 15:35:44 +08:00
Gabe Yuan
d6f3b23b88 fix: tranbox ui 2024-04-17 10:31:37 +08:00
Gabe Yuan
0a4fa7b9f8 fix: tranbox ui 2024-04-17 10:03:56 +08:00
Gabe Yuan
2b3e4a8d25 fix: reaplce loading button 2024-04-16 16:39:11 +08:00
Gabe Yuan
bf3a16f96d feat: export word & translation 2024-04-16 16:29:59 +08:00
Gabe Yuan
b416e72820 fix: transbox ui 2024-04-16 15:22:27 +08:00
Gabe Yuan
ca84bdb227 feat: tranbox hover trigger 2024-04-16 12:47:55 +08:00
Gabe Yuan
148a4e97a6 fix: tranbox ui 2024-04-16 11:25:04 +08:00
Gabe Yuan
a13493ebc2 feat: simple style tranbox 2024-04-16 00:54:37 +08:00
Gabe Yuan
ce4ac79e5f fix: optimization tranbox 2024-04-15 18:04:35 +08:00
Gabe Yuan
8f76ea49e7 fix: loading icon 2024-04-13 21:23:58 +08:00
Gabe Yuan
923d3293cd fix: limit selection btn in window & click to hide 2024-04-12 22:28:40 +08:00
Gabe Yuan
7379ff8d15 v1.8.6 2024-04-12 14:39:17 +08:00
Gabe Yuan
18ebec350d fix: clean env 2024-04-12 14:33:29 +08:00
Gabe Yuan
3b0cbc53aa fix: response err data: url 2024-04-12 11:47:22 +08:00
Gabe Yuan
f00e8ffa4d feat: add niutrans api 2024-04-12 11:31:01 +08:00
Gabe Yuan
d6f7aad1c3 fix: utils func 2024-04-11 10:44:25 +08:00
Gabe Yuan
092ea6e836 fix: custom api 2024-04-10 13:37:16 +08:00
Gabe Yuan
d565e2464a feat: tranbox: mobile support 2024-04-07 16:55:54 +08:00
126 changed files with 21556 additions and 9819 deletions

16
.env
View File

@@ -2,26 +2,18 @@ GENERATE_SOURCEMAP=false
REACT_APP_NAME=KISS Translator
REACT_APP_NAME_CN=简约翻译
REACT_APP_VERSION=1.8.5
REACT_APP_VERSION=2.0.0
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

View File

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

@@ -3,6 +3,7 @@
# dependencies
/node_modules
/.pnp
/.obsidian
.pnp.js
.yarn

4
.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
build
public
package.json

24
.prettierrc Normal file
View 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"
}

View File

@@ -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/Orion
- [ ] 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-Workercloudflare/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
@@ -40,7 +96,7 @@ A simple, open source [bilingual translation extension & Greasemonkey script](ht
> Note: For the following reasons, it is recommended to use browser extensions first
>
> - Browser extensions have more complete functions (local language recognition, context menu, etc.)
> - Browser extensions have more complete functions (subtitle translation, local language recognition, context menu, etc.)
> - Grease Monkey script will encounter more usage problems (cross domain issues, script conflicts, etc.)
- [x] Browser extension
@@ -50,6 +106,9 @@ A simple, open source [bilingual translation extension & Greasemonkey script](ht
- [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](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator)
@@ -64,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
```

109
README.md
View File

@@ -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/Orion
- [ ] 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-Workercloudflare/docker
- [x] WebDAV
- [x] 自定义翻译规则
- [x] 规则订阅/规则分享
- [x] 自定义专业术语
- [x] 自定义译文样式
- [x] 自定义快捷键
- `Alt+Q` 开启翻译
- `Alt+C` 切换样式
@@ -40,7 +92,7 @@
> 注:基于以下原因,建议优先使用浏览器扩展
>
> - 浏览器扩展的功能更完整(本地语言识别、右键菜单等)
> - 浏览器扩展的功能更完整(字幕翻译、本地语言识别、右键菜单等)
> - 油猴脚本会遇到更多使用上的问题(跨域问题、脚本冲突等)
- [x] 浏览器扩展
@@ -50,6 +102,9 @@
- [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)
@@ -64,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
```

View File

@@ -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
@@ -108,10 +112,14 @@ const userscriptWebpack = (config, env) => {
// @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==
@@ -122,6 +130,7 @@ const userscriptWebpack = (config, env) => {
config.entry = {
main: paths.appIndexJs,
options: paths.appSrc + "/options.js",
injector: paths.appSrc + "/injector.js",
"kiss-translator.user": paths.appSrc + "/userscript.js",
};

346
custom-api.md Normal file
View 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]]
```

View File

@@ -1,21 +1,24 @@
{
"name": "kiss-translator",
"description": "A minimalist bilingual translation Extension & Greasemonkey Script",
"version": "1.8.5",
"version": "2.0.0",
"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.11.0",
"@mui/icons-material": "^5.14.9",
"@mui/material": "^5.14.10",
"@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.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": {
@@ -62,6 +73,10 @@
"@babel/node": "^7.22.19",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-env": "^7.22.20",
"react-app-rewired": "^2.2.1"
"dotenv": "^17.2.1",
"find-up": "^7.0.0",
"prettier": "3.6.2",
"react-app-rewired": "^2.2.1",
"zx": "^8.8.1"
}
}

12410
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -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>Weve first shared our research on RSC in an introductory talk and an RFC.</h2>
<br />
<br />
<br />
@@ -208,52 +238,10 @@
<br />
<br />
<h2>
Weve 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

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />

View File

@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_app_name__",
"description": "__MSG_app_description__",
"version": "1.8.5",
"version": "2.0.0",
"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": {

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "__MSG_app_name__",
"description": "__MSG_app_description__",
"version": "1.8.5",
"version": "2.0.0",
"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,12 +51,7 @@
"description": "__MSG_open_options__"
}
},
"permissions": [
"storage",
"contextMenus",
"scripting",
"declarativeNetRequest"
],
"permissions": ["storage", "contextMenus", "scripting", "declarativeNetRequest", "declarativeNetRequestWithHostAccess"],
"host_permissions": ["<all_urls>"],
"icons": {
"16": "images/logo16.png",

View File

@@ -0,0 +1,81 @@
{
"manifest_version": 2,
"name": "__MSG_app_name__",
"description": "__MSG_app_description__",
"version": "2.0.0",
"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
}
}

View File

@@ -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: {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
},
method: "POST",
body: queryString.stringify(data),
const url = "https://fanyi.baidu.com/transapi";
const headers = {
// Origin: "https://fanyi.baidu.com",
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"User-Agent": DEFAULT_USER_AGENT,
};
return [URL_BAIDU_TRANSAPI, init];
return { url, body, headers };
};

View File

@@ -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,29 +31,20 @@ export const genDeeplFree = ({ text, from, to }) => {
},
],
},
});
body = body.replace(
'method":"',
(id + 3) % 13 === 0 || (id + 5) % 29 === 0 ? 'method" : "' : 'method": "'
);
const init = {
headers: {
"Content-Type": "application/json",
Accept: "*/*",
"x-app-os-name": "iOS",
"x-app-os-version": "16.3.0",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"x-app-device": "iPhone13,2",
"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];
const headers = {
"Content-Type": "application/json",
Accept: "*/*",
"x-app-os-name": "iOS",
"x-app-os-version": "16.3.0",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"x-app-device": "iPhone13,2",
"User-Agent": "DeepL-iOS/2.9.1 iOS 16.3.0 (iPhone13,2)",
"x-app-build": "510265",
"x-app-version": "2.9.1",
};
return { url, body, headers };
};

39
src/apis/history.js Normal file
View 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;
};

View File

@@ -1,28 +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,
URL_BAIDU_TTS,
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";
/**
* 同步数据
@@ -32,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)}`,
@@ -46,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;
};
/**
* 百度语言识别
@@ -54,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",
},
@@ -62,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 "";
@@ -78,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",
},
@@ -86,16 +222,88 @@ 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
@@ -104,10 +312,8 @@ export const apiBaiduSuggest = async (text) => {
* @returns
*/
export const apiBaiduTTS = (text, lan = "uk", spd = 3) => {
const url = `${URL_BAIDU_TTS}?${queryString.stringify({ lan, text, spd })}`;
return fetchPolyfill(url, {
useCache: false, // 为避免缓存过快增长,禁用缓存语音数据
});
const input = `https://fanyi.baidu.com/gettts?${queryString.stringify({ lan, text, spd })}`;
return fetchData(input);
};
/**
@@ -116,23 +322,90 @@ export const apiBaiduTTS = (text, lan = "uk", spd = 3) => {
* @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];
};
/**
@@ -141,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,
text,
from,
to,
};
const res = await fetchPolyfill(
`${URL_CACHE_TRAN}?${queryString.stringify(cacheOpts)}`,
{
useCache,
usePool,
transOpts,
apiSetting,
// 查询缓存数据
if (useCache) {
const cache = await getHttpCachePolyfill(cacheInput);
if (cache?.trText) {
return [cache.trText, cache.isSame];
}
);
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:
}
return [trText, isSame, res];
// 请求接口数据
let tranlation = [];
if (apiType === OPT_TRANS_BUILTINAI) {
tranlation = await apiBuiltinAITranslate({
text,
from,
to,
apiSetting,
});
} 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,
});
}
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

File diff suppressed because it is too large Load Diff

View File

@@ -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,
@@ -13,26 +13,34 @@ import {
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,
} 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 } from "./libs/log";
import { kissLog, logger } from "./libs/log";
import { chromeDetect, chromeTranslate } from "./libs/builtinAI";
globalThis.ContextType = "BACKGROUND";
const REMOVE_HEADERS = [
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`,
@@ -47,7 +55,7 @@ async function addContextMenus(contextMenuType = 1) {
try {
await browser.contextMenus.removeAll();
} catch (err) {
//
kissLog("remove contextMenus", err);
}
switch (contextMenuType) {
@@ -93,17 +101,34 @@ async function addContextMenus(contextMenuType = 1) {
* 更新CSP策略
* @param {*} csplist
*/
async function updateCspRules(csplist = DEFAULT_CSPLIST.join(",\n")) {
async function updateCspRules({ csplist, orilist }) {
try {
const newRules = csplist
.split(/\n|,/)
.map((url) => url.trim())
.filter(Boolean)
.map((url, idx) => ({
id: idx + 1,
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: REMOVE_HEADERS.map((header) => ({
responseHeaders: CSP_REMOVE_HEADERS.map((header) => ({
operation: "remove",
header,
})),
@@ -113,95 +138,164 @@ async function updateCspRules(csplist = DEFAULT_CSPLIST.join(",\n")) {
resourceTypes: ["main_frame", "sub_frame"],
},
}));
const oldRules = await browser.declarativeNetRequest.getDynamicRules();
const oldRuleIds = oldRules.map((rule) => rule.id);
await browser.declarativeNetRequest.updateDynamicRules({
removeRuleIds: oldRuleIds,
addRules: newRules,
});
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(err, "update csp rules");
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();
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, csplist } =
await getSettingWithDefault();
// 设置日志
logger.setLevel(logLevel);
// 清除缓存
if (clearCache) {
tryClearCaches();
}
//在thunderbird中注册脚本
if (process.env.REACT_APP_CLIENT === CLIENT_THUNDERBIRD) {
registerMsgDisplayScript();
}
// 右键菜单
// firefox重启后菜单会消失,故重复添加
addContextMenus(contextMenuType);
// 禁用CSP
updateCspRules(csplist);
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),
};
/**
* 监听消息
* 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_UPDATE_CSP:
return await updateCspRules(args);
case MSG_CONTEXT_MENUS:
return await addContextMenus(args);
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;
}
});

View File

@@ -6,24 +6,20 @@ 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";
/**
* 油猴脚本设置页面
@@ -45,37 +41,6 @@ function runSettingPage() {
}
}
/**
* 插件监听后端事件
* @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 +71,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 +80,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 +97,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.innerHTML = "&times;";
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 +182,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 +207,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 +215,17 @@ export async function run(isUserscript = false) {
return;
}
// 字幕翻译
runSubtitle({ href, setting, rule });
// 监听消息
!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
View 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,
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];

View File

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

View File

@@ -1,551 +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 MSG_UPDATE_CSP = "update_csp";
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_TTS = "https://fanyi.baidu.com/gettts";
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_CUSTOMIZE_2 = "Custom2";
export const OPT_TRANS_CUSTOMIZE_3 = "Custom3";
export const OPT_TRANS_CUSTOMIZE_4 = "Custom4";
export const OPT_TRANS_CUSTOMIZE_5 = "Custom5";
export const OPT_TRANS_ALL = [
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,
OPT_TRANS_CUSTOMIZE_2,
OPT_TRANS_CUSTOMIZE_3,
OPT_TRANS_CUSTOMIZE_4,
OPT_TRANS_CUSTOMIZE_5,
];
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, // 是否隐藏翻译按钮
hideClickAway: 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,
},
];
// 翻译接口
const defaultCustomApi = {
url: "",
key: "",
fetchLimit: DEFAULT_FETCH_LIMIT,
fetchInterval: DEFAULT_FETCH_INTERVAL,
};
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]: defaultCustomApi,
[OPT_TRANS_CUSTOMIZE_2]: defaultCustomApi,
[OPT_TRANS_CUSTOMIZE_3]: defaultCustomApi,
[OPT_TRANS_CUSTOMIZE_4]: defaultCustomApi,
[OPT_TRANS_CUSTOMIZE_5]: defaultCustomApi,
};
// 默认快捷键
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_CSPLIST = ["https://github.com"]; // 禁用CSP名单
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"), // 禁用翻译名单
csplist: DEFAULT_CSPLIST.join(",\n"), // 禁用CSP名单
// 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";

31
src/config/msg.js Normal file
View File

@@ -0,0 +1,31 @@
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 = "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_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";

View File

@@ -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, .kiss-p)`;
export const DEFAULT_KEEP_SELECTOR = `code, img, svg, pre`;
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`,
ignoreSelector: `.button, code, footer, form, mark, pre, .mwe-math-element, .mw-editsection`,
},
"stackoverflow.com, serverfault.com, superuser.com, stackexchange.com, askubuntu.com, stackapps.com, mathoverflow.net":
{
selector: `.s-prose ${DEFAULT_SELECTOR}, .comment-copy, .question-hyperlink, .s-post-summary--content-title, .s-post-summary--content-excerpt`,
keepSelector: `${DEFAULT_KEEP_SELECTOR}, .math-container`,
},
"www.npmjs.com/package, developer.chrome.com/docs, medium.com, react.dev, create-react-app.dev, pytorch.org":
{
selector: `article ${DEFAULT_SELECTOR}`,
},
"news.ycombinator.com": {
selector: `.title, p`,
fixerSelector: `.toptext, .commtext`,
fixerFunc: FIXER_BR,
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
View 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: false, // 深色模式
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
View 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
View 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";

View File

@@ -1,3 +1,5 @@
import { run } from "./common";
run();
if (document.documentElement && document.documentElement.tagName === "HTML") {
run();
}

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -1,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]);
setLoading(true);
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 };
};

View File

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

View File

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

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

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

11
src/hooks/MouseHover.js Normal file
View 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 };
}

View File

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

View File

@@ -1,51 +1,94 @@
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";
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(() => {
(async () => {
try {
logger.setLevel(setting?.logLevel);
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>
);
}

View File

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

View File

@@ -1,70 +1,144 @@
import { useCallback, useEffect, useState } from "react";
import { storage } from "../libs/storage";
import { kissLog } from "../libs/log";
import { syncData } from "../libs/sync";
import { useDebouncedCallback } from "./DebouncedCallback";
/**
* 用于将组件状态与 Storage 同步
*
* @param {*} key
* @param {*} defaultVal 需为调用hook外的常量
* @returns
* @param {string} key 用于在 Storage 中存取值的键
* @param {*} defaultVal 默认值。建议在组件外定义为常量
* @param {string} [syncKey=""] 用于远端同步的可选键名
* @returns {{
* data: *,
* save: (valueOrFn: any | ((prevData: any) => any)) => void,
* update: (partialDataOrFn: object | ((prevData: object) => object)) => void,
* remove: () => Promise<void>,
* reload: () => Promise<void>
* }}
*/
export function useStorage(key, defaultVal) {
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
export function useStorage(key, defaultVal = null, syncKey = "") {
const [isLoading, setIsLoading] = useState(true);
const [data, setData] = useState(defaultVal);
const save = useCallback(
async (val) => {
setData(val);
await storage.setObj(key, val);
},
[key]
);
// 首次加载数据
useEffect(() => {
let isMounted = true;
const update = useCallback(
async (obj) => {
setData((pre = {}) => ({ ...pre, ...obj }));
await storage.putObj(key, obj);
},
[key]
);
const remove = useCallback(async () => {
setData(null);
await storage.del(key);
}, [key]);
const reload = useCallback(async () => {
try {
setLoading(true);
const val = await storage.getObj(key);
if (val) {
setData(val);
const loadInitialData = async () => {
try {
const storedVal = await storage.getObj(key);
if (storedVal === undefined || storedVal === null) {
await storage.setObj(key, defaultVal);
} else if (isMounted) {
setData(storedVal);
}
} catch (err) {
kissLog(`storage load error for key: ${key}`, err);
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
loadInitialData();
return () => {
isMounted = false;
};
}, [key, defaultVal]);
// 远端同步
const runSync = useCallback(async (keyToSync, valueToSync) => {
try {
const 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(err, "storage reload");
} finally {
setLoading(false);
kissLog(`storage remove error for key: ${key}`, err);
}
}, [key]);
useEffect(() => {
(async () => {
try {
setLoading(true);
const val = await storage.getObj(key);
if (val) {
setData(val);
} else if (defaultVal) {
setData(defaultVal);
await storage.setObj(key, defaultVal);
}
} catch (err) {
kissLog(err, "storage load");
} finally {
setLoading(false);
}
})();
/**
* 从 Storage 重新加载数据以覆盖当前状态。
*/
const reload = useCallback(async () => {
// kissLog("reload storage:");
try {
const storedVal = await storage.getObj(key);
setData(storedVal ?? defaultVal);
} catch (err) {
kissLog(`storage reload error for key: ${key}`, err);
// setData(defaultVal);
}
}, [key, defaultVal]);
return { data, save, update, remove, reload, loading };
return { data, save, update, remove, reload, isLoading };
}

View File

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

10
src/hooks/Subtitle.js Normal file
View 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 };
}

View File

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

View File

@@ -1,6 +1,6 @@
import { useMemo } 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,7 +9,7 @@ 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 theme = useMemo(() => {
let htmlFontSize = 16;
@@ -38,6 +38,7 @@ export default function Theme({ children, options }) {
<ThemeProvider theme={theme}>
{/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
<CssBaseline />
<GlobalStyles styles={styles} />
{children}
</ThemeProvider>
);

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
import { useState, useEffect } from "react";
import TextField from "@mui/material/TextField";
import { limitNumber } from "../libs/utils";
function ValidationInput({ value, onChange, name, min, max, ...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 = 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;

View File

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

21
src/injector.js Normal file
View File

@@ -0,0 +1,21 @@
import { MSG_XHR_DATA_YOUTUBE } from "./config";
(function () {
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: MSG_XHR_DATA_YOUTUBE,
url: this.responseURL,
response: this.responseText,
},
window.location.origin
);
});
}
return originalOpen.apply(this, args);
};
})();

View File

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

View File

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

154
src/libs/cache.js Normal file
View File

@@ -0,0 +1,154 @@
import {
CACHE_NAME,
DEFAULT_CACHE_TIMEOUT,
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 {
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 });
};

View File

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

View File

@@ -1,20 +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";
import { blobToBase64 } from "./utils";
const TIMEOUT = 5000;
import { getFetchPool } from "./pool";
import { getHttpCachePolyfill, parseResponse } from "./cache";
/**
* 油猴脚本的请求封装
@@ -22,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,
@@ -30,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());
@@ -52,165 +46,101 @@ 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));
const { body, headers, status, statusText } = window.KISS_GM
? await window.KISS_GM.fetch(input, init)
: await fetchGM(input, init);
if (isSafe) {
const { body, headers, status, statusText } = window.KISS_GM
? await window.KISS_GM.fetch(input, init)
: await fetchGM(input, init);
return new Response(body, {
headers: new Headers(headers),
status,
statusText,
});
}
return new Response(body, {
headers: new Headers(headers),
status,
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();
} else if (contentType?.includes("audio")) {
const blob = await res.blob();
return await blobToBase64(blob);
}
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 });
};

View File

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

View File

@@ -1,25 +1,34 @@
// Function to inject inline JavaScript code
export const injectInlineJs = (code) => {
const el = document.createElement("script");
el.setAttribute("data-source", "KISS-Calendar injectInlineJs");
el.setAttribute("data-source", "kiss-inject injectInlineJs");
el.setAttribute("type", "text/javascript");
el.textContent = code;
document.body?.appendChild(el);
};
// Function to inject external JavaScript file
export const injectExternalJs = (src) => {
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);
export const injectExternalJs = (src, id = "kiss-translator-injector") => {
if (document.getElementById(id)) {
return;
}
// const el = document.createElement("script");
// el.setAttribute("data-source", "kiss-inject injectExternalJs");
// el.setAttribute("type", "text/javascript");
// el.setAttribute("src", src);
// el.setAttribute("id", id);
// document.body?.appendChild(el);
const script = document.createElement("script");
script.id = id;
script.src = src;
(document.head || document.documentElement).appendChild(script);
};
// 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 +36,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);

View File

@@ -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,143 +43,204 @@ 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;
text-align: center;
left: ${node.offsetLeft}px;
top: ${node.offsetTop}px;
z-index: 2147483647;
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;
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,
triggerCount,
triggerTime,
transSign,
} = DEFAULT_INPUT_RULE,
transApis,
}) {
if (!transOpen) {
return;
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();
}
}
const apiSetting = transApis?.[translator] || DEFAULT_TRANS_APIS[translator];
if (triggerShortcut.length === 0) {
triggerShortcut = DEFAULT_INPUT_SHORTCUT;
triggerCount = 1;
/**
* 启用输入翻译功能
*/
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
);
this.#isEnabled = true;
kissLog("Input Translator enabled.");
}
stepShortcutRegister(
triggerShortcut,
async () => {
let node = document.activeElement;
/**
* 禁用输入翻译功能
*/
disable() {
if (!this.#isEnabled) {
return;
}
if (this.#unregisterShortcut) {
this.#unregisterShortcut();
this.#unregisterShortcut = null;
}
this.#isEnabled = false;
kissLog("Input Translator disabled.");
}
if (!node) {
return;
}
/**
* 切换启用/禁用状态
*/
toggle() {
if (this.#isEnabled) {
this.disable();
} else {
this.enable();
}
}
while (node.shadowRoot) {
node = node.shadowRoot.activeElement;
}
/**
* 翻译核心逻辑
* @private
*/
async #handleTranslate() {
let node = document.activeElement;
if (!node) return;
if (!isInputNode(node) && !isEditAbleNode(node)) {
return;
}
while (node.shadowRoot && node.shadowRoot.activeElement) {
node = node.shadowRoot.activeElement;
}
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 (!isInputNode(node) && !isEditAbleNode(node)) {
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 && OPT_LANGS_LIST.includes(lang)) {
toLang = lang;
}
text = res[2];
const { apiSlug, transSign, triggerCount } = this.#config.inputRule;
let { fromLang, toLang } = this.#config.inputRule;
let initText = getNodeText(node);
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 && OPT_LANGS_LIST.includes(lang)) {
toLang = lang;
}
text = res[2];
}
}
// 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);
try {
addLoading(node, loadingId);
const [trText, isSame] = await apiTranslate({
translator,
text,
fromLang,
toLang,
apiSetting,
});
if (!trText || isSame) {
return;
}
const [trText, isSame] = await apiTranslate({
text,
fromLang,
toLang,
apiSetting,
});
if (isInputNode(node)) {
node.value = trText;
node.dispatchEvent(
new Event("input", { bubbles: true, cancelable: true })
);
return;
}
if (!trText || isSame) 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);
}
} catch (err) {
kissLog(err, "translate input");
} finally {
removeLoading(node, loadingId);
if (isInputNode(node)) {
node.value = trText;
node.dispatchEvent(
new Event("input", { bubbles: true, cancelable: true })
);
} else {
replaceContentEditableText(node, trText);
}
},
triggerCount,
triggerTime
);
} catch (err) {
kissLog("Translate input error:", err);
} finally {
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();
}
}
}

16
src/libs/interpreter.js Normal file
View 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;

View File

@@ -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);
// tododebug日志埋点

View File

@@ -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; // 任务最小启动间隔
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 });
resolve(res);
} catch (err) {
kissLog(err, "task");
if (retry < maxRetry) {
const retryTimer = setTimeout(() => {
clearTimeout(retryTimer);
pool.push({ args, resolve, reject, retry: retry + 1 });
}, _retryInteral);
} else {
reject(err);
}
} finally {
curCount--;
#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);
}
}
}
};
return {
push: async (args) => {
if (!timer) {
run();
if (this.#pool.length > 0) {
this.#scheduleNext();
}
return new Promise((resolve, reject) => {
pool.push({ args, resolve, reject, retry: 0 });
});
},
update: (_interval = 100, _limit = 100) => {
if (_interval >= 0 && _interval <= 5000 && _interval !== interval) {
interval = _interval;
}, delay);
}
/**
* 执行单个任务
* @param {object} task - 任务对象
*/
async #execute(task) {
this.#currentConcurrent++;
const { fn, args, resolve, reject, retry } = task;
try {
const res = await fn(args);
resolve(res);
} catch (err) {
kissLog("task pool", err);
if (retry < this.#maxRetry) {
setTimeout(() => {
this.#pool.unshift({ ...task, retry: retry + 1 }); // unshift 保证重试任务优先
this.#scheduleNext();
}, this.#retryInterval);
} else {
reject(err);
}
if (_limit >= 1 && _limit <= 100 && _limit !== maxCount) {
maxCount = _limit;
}
},
clear: () => {
pool.length = 0;
curCount = 0;
timer && clearTimeout(timer);
timer = null;
},
};
} finally {
this.#currentConcurrent--;
this.#scheduleNext();
}
}
/**
* 向任务池中添加一个新任务
* @param {Function} fn - 要执行的异步函数
* @param {*} args - 函数的参数
* @returns {Promise}
*/
push(fn, args) {
return new Promise((resolve, reject) => {
this.#pool.push({ fn, args, resolve, reject, retry: 0 });
this.#scheduleNext();
});
}
/**
* 更新任务池的配置
* @param {number} interval - 新的最小任务间隔
* @param {number} limit - 新的最大并发数
*/
update(interval, limit) {
if (interval >= 0) {
this.#interval = interval;
}
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();
};

View File

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

View File

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

View File

@@ -1,112 +1,114 @@
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);
target.addEventListener("keydown", handleKeyDown);
target.addEventListener("keyup", handleKeyUp);
return () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
target.removeEventListener("keydown", handleKeydown);
target.removeEventListener("keyup", handleKeyup);
target.removeEventListener("keydown", handleKeyDown);
target.removeEventListener("keyup", handleKeyUp);
pressedKeys.clear();
};
};
/**
* 注册键盘快捷键
* @param {*} targetKeys
* @param {*} fn
* @param {*} target
* @returns
* @param {string[]} targetKeys - 目标快捷键数组
* @param {() => void} fn - 匹配成功后执行的回调
* @param {EventTarget} target - 监听目标
* @returns {() => void} - 注销函数
*/
export const shortcutRegister = (targetKeys = [], fn, target = document) => {
return shortcutListener((curkeys) => {
if (
targetKeys.length > 0 &&
isSameSet(new Set(targetKeys), new Set(curkeys))
) {
if (targetKeys.length === 0) return () => {};
const targetKeySet = new Set(targetKeys);
const onKeyDown = (pressedKeys, event) => {
if (isSameSet(targetKeySet, pressedKeys)) {
event.preventDefault();
event.stopPropagation();
fn();
}
}, target);
};
const onKeyUp = () => {};
return shortcutListener(onKeyDown, onKeyUp, target);
};
/**
* 高阶函数:为目标函数增加计次和超时重置功能
* @param {() => void} fn - 需要被包装的函数
* @param {number} step - 需要触发的次数
* @param {number} timeout - 超时毫秒数
* @returns {() => void} - 包装后的新函数
*/
const withStepCounter = (fn, step, timeout) => {
let count = 0;
let timer = null;
return () => {
timer && clearTimeout(timer);
timer = setTimeout(() => {
count = 0;
}, timeout);
count++;
if (count === step) {
count = 0;
clearTimeout(timer);
fn();
}
};
};
/**
* 注册连续快捷键
* @param {*} targetKeys
* @param {*} fn
* @param {*} step
* @param {*} timeout
* @param {*} target
* @returns
* @param {string[]} targetKeys - 目标快捷键数组
* @param {() => void} fn - 成功回调
* @param {number} step - 连续触发次数
* @param {number} timeout - 每次触发的间隔超时
* @param {EventTarget} target - 监听目标
* @returns {() => void} - 注销函数
*/
export const stepShortcutRegister = (
targetKeys = [],
fn,
step = 3,
step = 2,
timeout = 500,
target = document
) => {
let count = 0;
let pre = Date.now();
let timer;
return shortcutListener((curkeys, allkeys) => {
timer && clearTimeout(timer);
timer = setTimeout(() => {
clearTimeout(timer);
count = 0;
}, timeout);
if (targetKeys.length > 0 && curkeys.length === 0) {
const now = Date.now();
if (
(count === 0 || now - pre < timeout) &&
isSameSet(new Set(targetKeys), new Set(allkeys))
) {
count++;
if (count === step) {
count = 0;
fn();
}
} else {
count = 0;
}
pre = now;
}
}, target);
const steppedFn = withStepCounter(fn, step, timeout);
return shortcutRegister(targetKeys, steppedFn, target);
};

View File

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

View File

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

View File

@@ -1,34 +1,109 @@
export const loadingSvg = `
<svg viewBox="0 0 100 100" style="display:inline-block; width:100%; height: 100%;">
<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"
/>
</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"
/>
</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"
/>
</circle>
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"/>
</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"/>
</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"/>
</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;
}

View File

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

View File

@@ -15,6 +15,16 @@ export const limitNumber = (num, min = 0, max = 100) => {
return number;
};
export const limitFloat = (num, min = 0, max = 100) => {
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+([^]+)/);
};
/**
@@ -246,3 +252,124 @@ export const blobToBase64 = (blob) => {
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;
};

View 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();

View 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.originStyle;
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();
}
}

View File

@@ -0,0 +1,914 @@
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.source !== window) return;
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);
});
}
#moAds(adContainer) {
const adSlector = ".ytp-ad-player-overlay-layout";
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === "childList") {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1 && node.matches(adSlector)) {
logger.debug("Youtube Provider: AD start playing!", node);
// todo: 顺带把广告快速跳过
if (this.#managerInstance) {
this.#managerInstance.setIsAdPlaying(true);
}
}
});
mutation.removedNodes.forEach((node) => {
if (node.nodeType === 1 && node.matches(adSlector)) {
logger.debug("Youtube Provider: Ad ends!");
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 = document.querySelector(VIDEO_SELECT);
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 = document.querySelector(VIDEO_SELECT);
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();
};
})();

43
src/subtitle/subtitle.js Normal file
View File

@@ -0,0 +1,43 @@
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";
const providers = [
{ pattern: "https://www.youtube.com", start: YouTubeInitializer },
];
export function runSubtitle({ href, setting }) {
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-injector";
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
View 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;
}

View File

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

View File

@@ -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,7 +217,13 @@ export default function Action({ translator, fab }) {
color="primary"
onClick={(e) => {
if (!moved) {
setShowPopup((pre) => !pre);
if (fabClickAction === 1) {
translator.toggle();
sendIframeMsg(MSG_TRANS_TOGGLE);
setShowPopup(false);
} else {
setShowPopup((pre) => !pre);
}
}
}}
>

View File

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

View File

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

View File

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

View File

@@ -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,14 +76,24 @@ function TestButton({ translator, api }) {
alert.error(
<>
<div>{i18n("test_failed")}</div>
<pre
style={{
maxWidth: 400,
overflow: "auto",
}}
>
{msg}
</pre>
{msg === err.message ? (
<div
style={{
maxWidth: 400,
}}
>
{msg}
</div>
) : (
<pre
style={{
maxWidth: 400,
overflow: "auto",
}}
>
{msg}
</pre>
)}
</>
);
} finally {
@@ -77,145 +101,641 @@ 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"
label={"URL"}
name="url"
value={url}
onChange={handleChange}
multiline={apiType === OPT_TRANS_DEEPLX}
maxRows={10}
helperText={
apiType === OPT_TRANS_DEEPLX ? i18n("mulkeys_help") : ""
}
/>
<TextField
size="small"
label={"KEY"}
name="key"
value={key}
onChange={handleChange}
multiline={API_SPE_TYPES.mulkeys.has(apiType)}
maxRows={10}
helperText={keyHelper}
/>
</>
)}
{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}
max={2}
/>
</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={"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={"URL"}
name="url"
value={url}
label={"DictNo"}
name="dictNo"
value={dictNo}
onChange={handleChange}
/>
<TextField
size="small"
label={"KEY"}
name="key"
value={key}
label={"MemoryNo"}
name="memoryNo"
value={memoryNo}
onChange={handleChange}
multiline={mulkeysTranslators.includes(translator)}
/>
</>
)}
{apiType === OPT_TRANS_CUSTOMIZE && (
<>
<TextField
size="small"
label={"Request Hook"}
name="reqHook"
value={reqHook}
onChange={handleChange}
multiline
maxRows={10}
FormHelperTextProps={{
component: "div",
}}
helperText={
mulkeysTranslators.includes(translator)
? i18n("mulkeys_help")
: ""
<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>
}
/>
</>
)}
{(translator === OPT_TRANS_OPENAI || translator === OPT_TRANS_GEMINI) && (
{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) && (
<>
<TextField
size="small"
label={"MODEL"}
name="model"
value={model}
onChange={handleChange}
/>
<TextField
size="small"
label={"PROMPT"}
name="prompt"
value={prompt}
onChange={handleChange}
multiline
/>
<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>
</>
)}
<TextField
size="small"
label={i18n("fetch_limit")}
type="number"
name="fetchLimit"
value={fetchLimit}
onChange={handleChange}
/>
<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}
/>
</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>
<TextField
size="small"
label={i18n("fetch_interval")}
type="number"
name="fetchInterval"
value={fetchInterval}
onChange={handleChange}
/>
{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>
<Stack direction="row" spacing={2}>
<TestButton translator={translator} api={api} />
{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="outlined"
onClick={() => {
resetApi();
}}
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"
color="error"
onClick={handleDelete}
>
{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.startsWith(OPT_TRANS_CUSTOMIZE) && (
<pre>{i18n("custom_api_help")}</pre>
)}
{/* {apiType === OPT_TRANS_CUSTOMIZE && <pre>{i18n("custom_api_help")}</pre>} */}
</Stack>
);
}
function ApiAccordion({ translator }) {
function ApiAccordion({ api, isUserApi, deleteApi }) {
const [expanded, setExpanded] = useState(false);
const handleChange = (e) => {
@@ -225,10 +745,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 +769,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>

View File

@@ -1,10 +1,15 @@
import FileDownloadIcon from "@mui/icons-material/FileDownload";
import Button from "@mui/material/Button";
import LoadingButton from "@mui/lab/LoadingButton";
import { useState } from "react";
import { kissLog } from "../../libs/log";
export default function DownloadButton({ data, text, fileName }) {
const handleClick = (e) => {
export default function DownloadButton({ handleData, text, fileName }) {
const [loading, setLoading] = useState(false);
const handleClick = async (e) => {
e.preventDefault();
if (data) {
try {
setLoading(true);
const data = await handleData();
const url = window.URL.createObjectURL(new Blob([data]));
const link = document.createElement("a");
link.href = url;
@@ -12,16 +17,21 @@ export default function DownloadButton({ data, text, fileName }) {
document.body.appendChild(link);
link.click();
link.remove();
} catch (err) {
kissLog("download", err);
} finally {
setLoading(false);
}
};
return (
<Button
<LoadingButton
size="small"
variant="outlined"
onClick={handleClick}
loading={loading}
startIcon={<FileDownloadIcon />}
>
{text}
</Button>
</LoadingButton>
);
}

View File

@@ -1,63 +1,30 @@
import Stack from "@mui/material/Stack";
import { OPT_TRANS_BAIDU } from "../../config";
import { useEffect, useState } from "react";
import { useState } from "react";
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 CircularProgress from "@mui/material/CircularProgress";
import { useI18n } from "../../hooks/I18n";
import Alert from "@mui/material/Alert";
import { apiTranslate } from "../../apis";
import Box from "@mui/material/Box";
import { useFavWords } from "../../hooks/FavWords";
import DictCont from "../Selection/DictCont";
import SugCont from "../Selection/SugCont";
import DownloadButton from "./DownloadButton";
import UploadButton from "./UploadButton";
import Button from "@mui/material/Button";
import ClearAllIcon from "@mui/icons-material/ClearAll";
import Alert from "@mui/material/Alert";
import { isValidWord } from "../../libs/utils";
import { kissLog } from "../../libs/log";
function DictField({ word }) {
const [dictResult, setDictResult] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
(async () => {
try {
setLoading(true);
setError("");
const dictRes = await apiTranslate({
text: word,
translator: OPT_TRANS_BAIDU,
fromLang: "en",
toLang: "zh-CN",
});
dictRes[2].type === 1 && setDictResult(JSON.parse(dictRes[2].result));
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
})();
}, [word]);
if (loading) {
return <CircularProgress size={24} />;
}
if (error) {
return <Alert severity="error">{error}</Alert>;
}
return <DictCont dictResult={dictResult} />;
}
import { useConfirm } from "../../hooks/Confirm";
import { useSetting } from "../../hooks/Setting";
import { dictHandlers } from "../Selection/DictHandler";
function FavAccordion({ word, index }) {
const [expanded, setExpanded] = useState(false);
const { setting } = useSetting();
const { enDict, enSug } = setting?.tranboxSetting || {};
const handleChange = (e) => {
setExpanded((pre) => !pre);
@@ -72,7 +39,12 @@ function FavAccordion({ word, index }) {
<Typography>{`${index + 1}. ${word}`}</Typography>
</AccordionSummary>
<AccordionDetails>
{expanded && <DictField word={word} />}
{expanded && (
<Stack spacing={2}>
<DictCont text={word} enDict={enDict} />
<SugCont text={word} enSug={enSug} />
</Stack>
)}
</AccordionDetails>
</Accordion>
);
@@ -80,27 +52,60 @@ function FavAccordion({ word, index }) {
export default function FavWords() {
const i18n = useI18n();
const { loading, favWords, mergeWords, clearWords } = useFavWords();
const favList = Object.entries(favWords).sort((a, b) =>
a[0].localeCompare(b[0])
);
const downloadList = favList.map(([word]) => word);
const { favList, wordList, mergeWords, clearWords } = useFavWords();
const { setting } = useSetting();
const confirm = useConfirm();
const handleImport = async (data) => {
const handleImport = (data) => {
try {
const newWords = data
.split("\n")
.map((line) => line.split(",")[0].trim())
.filter(isValidWord);
await mergeWords(newWords);
mergeWords(newWords);
} catch (err) {
kissLog(err, "import rules");
kissLog("import rules", err);
}
};
const handleClearWords = async () => {
const isConfirmed = await confirm({
confirmText: i18n("confirm_title"),
cancelText: i18n("cancel"),
});
if (isConfirmed) {
clearWords();
}
};
const handleTranslation = async () => {
const { enDict } = setting?.tranboxSetting;
const dict = dictHandlers[enDict];
if (!dict) return "";
const tranList = [];
for (const word of wordList) {
try {
const data = await dict.apiFn(word);
const title = `## ${dict.reWord(data) || word}`;
const tran = dict
.toText(data)
.map((line) => `- ${line}`)
.join("\n");
tranList.push([title, tran].join("\n"));
} catch (err) {
kissLog("export translation", err);
}
}
return tranList.join("\n\n");
};
return (
<Box>
<Stack spacing={3}>
<Alert severity="info">{i18n("favorite_words_helper")}</Alert>
<Stack
direction="row"
alignItems="center"
@@ -115,16 +120,19 @@ export default function FavWords() {
fileExts={[".txt", ".csv"]}
/>
<DownloadButton
data={downloadList.join("\n")}
handleData={() => wordList.join("\n")}
text={i18n("export")}
fileName={`kiss-words_${Date.now()}.txt`}
/>
<DownloadButton
handleData={handleTranslation}
text={i18n("export_translation")}
fileName={`kiss-words_${Date.now()}.md`}
/>
<Button
size="small"
variant="outlined"
onClick={() => {
clearWords();
}}
onClick={handleClearWords}
startIcon={<ClearAllIcon />}
>
{i18n("clear_all")}
@@ -132,18 +140,14 @@ export default function FavWords() {
</Stack>
<Box>
{loading ? (
<CircularProgress size={24} />
) : (
favList.map(([word, { createdAt }], index) => (
<FavAccordion
key={word}
index={index}
word={word}
createdAt={createdAt}
/>
))
)}
{favList.map(([word, { createdAt }], index) => (
<FavAccordion
key={word}
index={index}
word={word}
createdAt={createdAt}
/>
))}
</Box>
</Stack>
</Box>

View File

@@ -4,7 +4,6 @@ import TextField from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem";
import { useI18n } from "../../hooks/I18n";
import {
OPT_TRANS_ALL,
OPT_LANGS_FROM,
OPT_LANGS_TO,
OPT_INPUT_TRANS_SIGNS,
@@ -15,21 +14,17 @@ import Switch from "@mui/material/Switch";
import { useInputRule } from "../../hooks/InputRule";
import { useCallback } from "react";
import Grid from "@mui/material/Grid";
import { limitNumber } from "../../libs/utils";
import { useApiList } from "../../hooks/Api";
import ValidationInput from "../../hooks/ValidationInput";
export default function InputSetting() {
const i18n = useI18n();
const { inputRule, updateInputRule } = useInputRule();
const { enabledApis } = useApiList();
const handleChange = (e) => {
e.preventDefault();
let { name, value } = e.target;
switch (name) {
case "triggerTime":
value = limitNumber(value, 10, 1000);
break;
default:
}
updateInputRule({
[name]: value,
});
@@ -44,7 +39,7 @@ export default function InputSetting() {
const {
transOpen,
translator,
apiSlug,
fromLang,
toLang,
triggerShortcut,
@@ -68,73 +63,87 @@ export default function InputSetting() {
/>
}
label={i18n("use_input_box_translation")}
sx={{ width: "fit-content" }}
/>
<TextField
select
size="small"
name="translator"
value={translator}
label={i18n("translate_service")}
onChange={handleChange}
>
{OPT_TRANS_ALL.map((item) => (
<MenuItem key={item} value={item}>
{item}
</MenuItem>
))}
</TextField>
<TextField
select
size="small"
name="fromLang"
value={fromLang}
label={i18n("from_lang")}
onChange={handleChange}
>
{OPT_LANGS_FROM.map(([lang, name]) => (
<MenuItem key={lang} value={lang}>
{name}
</MenuItem>
))}
</TextField>
<TextField
select
size="small"
name="toLang"
value={toLang}
label={i18n("to_lang")}
onChange={handleChange}
>
{OPT_LANGS_TO.map(([lang, name]) => (
<MenuItem key={lang} value={lang}>
{name}
</MenuItem>
))}
</TextField>
<TextField
select
size="small"
name="transSign"
value={transSign}
label={i18n("input_trans_start_sign")}
onChange={handleChange}
helperText={i18n("input_trans_start_sign_help")}
>
<MenuItem value={""}>{i18n("style_none")}</MenuItem>
{OPT_INPUT_TRANS_SIGNS.map((item) => (
<MenuItem key={item} value={item}>
{item}
</MenuItem>
))}
</TextField>
<Box>
<Grid container spacing={2} columns={12}>
<Grid item xs={12} sm={12} md={4} lg={4}>
<Grid item xs={12} sm={12} md={6} lg={3}>
<TextField
select
fullWidth
size="small"
name="apiSlug"
value={apiSlug}
label={i18n("translate_service")}
onChange={handleChange}
>
{enabledApis.map((api) => (
<MenuItem key={api.apiSlug} value={api.apiSlug}>
{api.apiName}
</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={12} sm={12} md={6} lg={3}>
<TextField
select
fullWidth
size="small"
name="fromLang"
value={fromLang}
label={i18n("from_lang")}
onChange={handleChange}
>
{OPT_LANGS_FROM.map(([lang, name]) => (
<MenuItem key={lang} value={lang}>
{name}
</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={12} sm={12} md={6} lg={3}>
<TextField
select
fullWidth
size="small"
name="toLang"
value={toLang}
label={i18n("to_lang")}
onChange={handleChange}
>
{OPT_LANGS_TO.map(([lang, name]) => (
<MenuItem key={lang} value={lang}>
{name}
</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={12} sm={12} md={6} lg={3}>
<TextField
select
fullWidth
size="small"
name="transSign"
value={transSign}
label={i18n("input_trans_start_sign")}
onChange={handleChange}
helperText={i18n("input_trans_start_sign_help")}
>
<MenuItem value={""}>{i18n("style_none")}</MenuItem>
{OPT_INPUT_TRANS_SIGNS.map((item) => (
<MenuItem key={item} value={item}>
{item}
</MenuItem>
))}
</TextField>
</Grid>
</Grid>
</Box>
<Box>
<Grid container spacing={2} columns={12}>
<Grid item xs={12} sm={12} md={6} lg={3}>
<ShortcutInput
value={triggerShortcut}
onChange={handleShortcutInput}
@@ -142,7 +151,7 @@ export default function InputSetting() {
helperText={i18n("trigger_trans_shortcut_help")}
/>
</Grid>
<Grid item xs={12} sm={12} md={4} lg={4}>
<Grid item xs={12} sm={12} md={6} lg={3}>
<TextField
select
fullWidth
@@ -159,15 +168,17 @@ export default function InputSetting() {
))}
</TextField>
</Grid>
<Grid item xs={12} sm={12} md={4} lg={4}>
<TextField
<Grid item xs={12} sm={12} md={6} lg={3}>
<ValidationInput
fullWidth
size="small"
label={i18n("combo_timeout")}
type="number"
name="triggerTime"
defaultValue={triggerTime}
value={triggerTime}
onChange={handleChange}
min={10}
max={1000}
/>
</Grid>
</Grid>

Some files were not shown because too many files have changed in this diff Show More