Compare commits
822 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30c2cca2e1 | ||
|
|
af3241b773 | ||
|
|
eca0a63273 | ||
|
|
f15cdb38d6 | ||
|
|
5b8577aaa7 | ||
|
|
0a4a2b46c1 | ||
|
|
53d441b3f5 | ||
|
|
d4c346e40a | ||
|
|
2dcacf71e4 | ||
|
|
9ded6446a7 | ||
|
|
0a6f4a9f02 | ||
|
|
635e588bcc | ||
|
|
7343db78a5 | ||
|
|
ccd457c992 | ||
|
|
97676f114e | ||
|
|
e83c1eb017 | ||
|
|
e417c0106a | ||
|
|
3c09840d35 | ||
|
|
7361a94f8c | ||
|
|
a9bffe3913 | ||
|
|
5322555eba | ||
|
|
172dce2867 | ||
|
|
5735fee36e | ||
|
|
91642d8784 | ||
|
|
9d8f3f4211 | ||
|
|
66d39da80a | ||
|
|
fbd4a31a9c | ||
|
|
3d7e03ddaf | ||
|
|
1f213bf257 | ||
|
|
b38f079611 | ||
|
|
21e639cacd | ||
|
|
bdaf665b7c | ||
|
|
61a515c1d2 | ||
|
|
1b646df908 | ||
|
|
5550f939b2 | ||
|
|
b34fb5a600 | ||
|
|
c0dce5c0b1 | ||
|
|
d56bd2920f | ||
|
|
48ad100a64 | ||
|
|
ef07a172a9 | ||
|
|
f492d47719 | ||
|
|
ac8c07deb4 | ||
|
|
ca48ab639e | ||
|
|
7c5232c1a1 | ||
|
|
4fac7fdfe1 | ||
|
|
f7fc9560d5 | ||
|
|
f7ba744e7f | ||
|
|
315164f142 | ||
|
|
429cab5223 | ||
|
|
deecbc874b | ||
|
|
504f4cafa0 | ||
|
|
6d327d17af | ||
|
|
74290eb52b | ||
|
|
60b9653fd3 | ||
|
|
53e32d3031 | ||
|
|
ed279cf8a1 | ||
|
|
296b898bda | ||
|
|
2325155b1e | ||
|
|
b6ff4aae6a | ||
|
|
f04002fdb8 | ||
|
|
b8cb254f56 | ||
|
|
3983477904 | ||
|
|
f011f5ae38 | ||
|
|
18ecab18df | ||
|
|
793c481221 | ||
|
|
4fee3688ea | ||
|
|
b9693436bb | ||
|
|
6b18d8f934 | ||
|
|
65c325de9a | ||
|
|
8da5aaf259 | ||
|
|
00e8fdd3e6 | ||
|
|
b2eea5d0d7 | ||
|
|
9a8e24f590 | ||
|
|
32c6d45cb0 | ||
|
|
74ce6f2f1f | ||
|
|
573865cf10 | ||
|
|
56d4733e2a | ||
|
|
a8965a01e3 | ||
|
|
beef51ef38 | ||
|
|
b5b3ee8709 | ||
|
|
4f1e01dde0 | ||
|
|
d42ff51de5 | ||
|
|
c39861b7b7 | ||
|
|
d39b9fd73e | ||
|
|
ecab4ab634 | ||
|
|
5e67e15842 | ||
|
|
2af1a8b72c | ||
|
|
2510ed0ebb | ||
|
|
6827985289 | ||
|
|
a095a2c01c | ||
|
|
2033ff6777 | ||
|
|
0c22288833 | ||
|
|
0576150067 | ||
|
|
9cdcf616f7 | ||
|
|
2de10364f3 | ||
|
|
562559a1b0 | ||
|
|
0f0b7313bb | ||
|
|
412fc87d1e | ||
|
|
0a1abab475 | ||
|
|
b63ef8c1aa | ||
|
|
c38a3d439d | ||
|
|
dd395e668f | ||
|
|
8a5ef441f9 | ||
|
|
d9acbe865f | ||
|
|
dc35fef873 | ||
|
|
4e9293ae0f | ||
|
|
36973b8693 | ||
|
|
0ab734d1a5 | ||
|
|
bfce9b525a | ||
|
|
f19b6ef02f | ||
|
|
1992908a85 | ||
|
|
a6bcafa8f6 | ||
|
|
eabcb06eeb | ||
|
|
21dcbfa4c4 | ||
|
|
b6c074a242 | ||
|
|
f6d095d533 | ||
|
|
7a36251d3f | ||
|
|
3fda4d0da9 | ||
|
|
2fa8917d5e | ||
|
|
67149af64b | ||
|
|
0104cb9f29 | ||
|
|
1afe976777 | ||
|
|
d9b4399c57 | ||
|
|
ac7b3b9824 | ||
|
|
359206630d | ||
|
|
96dfee90ab | ||
|
|
9ace600fce | ||
|
|
549b945d0f | ||
|
|
4528a79c87 | ||
|
|
951ce985b5 | ||
|
|
001f04a9ee | ||
|
|
3844d2eb75 | ||
|
|
e593221e02 | ||
|
|
8cc3801dc5 | ||
|
|
251e57ec61 | ||
|
|
769a4f00aa | ||
|
|
9bafc937d5 | ||
|
|
2d0ea09e06 | ||
|
|
aeec5e361c | ||
|
|
71b2d62c9f | ||
|
|
40b3072e5f | ||
|
|
cae9338274 | ||
|
|
4c2781b3b6 | ||
|
|
b2b5bef9f5 | ||
|
|
df8c96569a | ||
|
|
e562f0b851 | ||
|
|
7b2b48f0d1 | ||
|
|
c353c88db8 | ||
|
|
171dbb7509 | ||
|
|
65e8fabe7d | ||
|
|
389f0b6f82 | ||
|
|
039566ded5 | ||
|
|
d18b31692b | ||
|
|
c993c15c92 | ||
|
|
3c5ffc045f | ||
|
|
261bb7aa6f | ||
|
|
96a7a41759 | ||
|
|
d563521eb1 | ||
|
|
a08c42db8b | ||
|
|
8e026238ae | ||
|
|
7412b3a5c8 | ||
|
|
b60b770ed6 | ||
|
|
20c4d6f6eb | ||
|
|
2437c75d75 | ||
|
|
867c2209b1 | ||
|
|
fffa448425 | ||
|
|
b1142b88f1 | ||
|
|
6d95e7debc | ||
|
|
6bafcb0ec0 | ||
|
|
4935abcf33 | ||
|
|
14f74b76bb | ||
|
|
6b9a1a49bb | ||
|
|
533a0e2d5b | ||
|
|
393f1a29d5 | ||
|
|
1dabbfc4de | ||
|
|
7665f8c260 | ||
|
|
eef5e25a00 | ||
|
|
261f29c185 | ||
|
|
2a46939aa5 | ||
|
|
779c9fc850 | ||
|
|
a20a06320d | ||
|
|
943a9e86f0 | ||
|
|
563242c5f1 | ||
|
|
d39a016d5f | ||
|
|
fa87d87011 | ||
|
|
7dc847fca2 | ||
|
|
343edcdbad | ||
|
|
b631703aa6 | ||
|
|
6dd6b73c2f | ||
|
|
2b496bda31 | ||
|
|
17c8d198c3 | ||
|
|
86f8d9694d | ||
|
|
3948cb74ca | ||
|
|
4ebced1e71 | ||
|
|
d4e58fc925 | ||
|
|
2bfb27f346 | ||
|
|
c4fba1c905 | ||
|
|
4a5e6c2a23 | ||
|
|
5fb7157f57 | ||
|
|
a8c38d2a00 | ||
|
|
cbc82fff64 | ||
|
|
5c44ba1da8 | ||
|
|
fd2f0e513b | ||
|
|
7fd2a0f187 | ||
|
|
fd355eeeab | ||
|
|
c93e370370 | ||
|
|
57d218a17f | ||
|
|
ffc43a67f2 | ||
|
|
7deb5b885a | ||
|
|
5d5e23482f | ||
|
|
36c1e40d64 | ||
|
|
c6f4fe2b7b | ||
|
|
1a23627193 | ||
|
|
d73b2377bf | ||
|
|
4559ab7ec2 | ||
|
|
1d9679e516 | ||
|
|
4c30f6b012 | ||
|
|
511210939f | ||
|
|
d2addf58cb | ||
|
|
f7db410235 | ||
|
|
dd18b04cea | ||
|
|
58d8009e91 | ||
|
|
663407b95d | ||
|
|
f78901603e | ||
|
|
5e1101baeb | ||
|
|
2ba2441900 | ||
|
|
4446ae3dbd | ||
|
|
72668f0386 | ||
|
|
a9b858ec6f | ||
|
|
e1f902c203 | ||
|
|
be6e34ba52 | ||
|
|
39b3b00117 | ||
|
|
763019f0c5 | ||
|
|
d743271be8 | ||
|
|
992dad26aa | ||
|
|
9bd0e67474 | ||
|
|
5767a4afb2 | ||
|
|
9e4c510684 | ||
|
|
16607fb069 | ||
|
|
e047a06432 | ||
|
|
9e09fd898a | ||
|
|
799c32a871 | ||
|
|
483f33b5c9 | ||
|
|
d444fd4fba | ||
|
|
0aae93ba2e | ||
|
|
9608bea3bf | ||
|
|
a038a1ecdc | ||
|
|
c82cdd7f8f | ||
|
|
0c6d5c3c61 | ||
|
|
900426f359 | ||
|
|
1d760fc93a | ||
|
|
61571e0f61 | ||
|
|
c9eb423c89 | ||
|
|
45b294a121 | ||
|
|
3a3f1fabe1 | ||
|
|
03177a09b3 | ||
|
|
cae391f62b | ||
|
|
2a5e9db079 | ||
|
|
650d6e8b41 | ||
|
|
1daf134b31 | ||
|
|
e1dfa35c6c | ||
|
|
73f80692d3 | ||
|
|
42c7dae495 | ||
|
|
b2a1309caa | ||
|
|
94bf5f9580 | ||
|
|
704ebdc9d7 | ||
|
|
165da4e559 | ||
|
|
d3e3b484bf | ||
|
|
192f8faa5b | ||
|
|
579d5cb0a3 | ||
|
|
07fca5b9af | ||
|
|
866a63ab6c | ||
|
|
97b4935bc4 | ||
|
|
30129abef3 | ||
|
|
24d904b32c | ||
|
|
5f0ce57ead | ||
|
|
51f58d095a | ||
|
|
d22e3838c4 | ||
|
|
adbb421b7b | ||
|
|
eaa47af269 | ||
|
|
a6cb5544f8 | ||
|
|
9e91faa660 | ||
|
|
8636fadc72 | ||
|
|
0621957592 | ||
|
|
8ec06b0c84 | ||
|
|
d47f8d7ee9 | ||
|
|
24f8959525 | ||
|
|
983740578b | ||
|
|
b5f79ed7cd | ||
|
|
bbb0e79d4e | ||
|
|
471dc05897 | ||
|
|
7a772d2459 | ||
|
|
1d92421960 | ||
|
|
aeaaf429d7 | ||
|
|
84432e98ae | ||
|
|
77c6102de7 | ||
|
|
ab5dd82169 | ||
|
|
23e7b69dc5 | ||
|
|
3dc8f393f2 | ||
|
|
8a2144f263 | ||
|
|
c1c59caa10 | ||
|
|
d27ebd90b6 | ||
|
|
467745c1e9 | ||
|
|
537378a038 | ||
|
|
4b5ed30e5b | ||
|
|
52b7f6a225 | ||
|
|
f31675d8a2 | ||
|
|
dd46a8450c | ||
|
|
b0843f7d66 | ||
|
|
daadc0195c | ||
|
|
298dec6957 | ||
|
|
bf39d85dfa | ||
|
|
30a9de25a8 | ||
|
|
af1ecf0bd4 | ||
|
|
fe55a2cd3c | ||
|
|
5a33d4e57e | ||
|
|
7f46a9023c | ||
|
|
dfd943b621 | ||
|
|
7007d0d922 | ||
|
|
601678500d | ||
|
|
9bfb504381 | ||
|
|
8a03b0cf15 | ||
|
|
11ba89de0a | ||
|
|
bac7f62eea | ||
|
|
eef90ea02b | ||
|
|
0650df534a | ||
|
|
9ef8c8b823 | ||
|
|
d7e08da0b2 | ||
|
|
ef361e0798 | ||
|
|
6855332092 | ||
|
|
121d523e02 | ||
|
|
42a375c4c7 | ||
|
|
a1dd705d97 | ||
|
|
71f90b36ca | ||
|
|
37facdc3c1 | ||
|
|
66b4f547ff | ||
|
|
d27b9c7f2d | ||
|
|
278ff9c6bc | ||
|
|
d6fe1ce9d7 | ||
|
|
0bfa5256b8 | ||
|
|
72ccfc8aec | ||
|
|
d117c5dc10 | ||
|
|
9312783f44 | ||
|
|
e5b16ebfd3 | ||
|
|
5d1d65c2d3 | ||
|
|
9ca1309cec | ||
|
|
a03afc05f5 | ||
|
|
0198963584 | ||
|
|
58e745d967 | ||
|
|
377e347d68 | ||
|
|
bac0704d3d | ||
|
|
d2ff46edf6 | ||
|
|
f908372b4e | ||
|
|
5d44ff4913 | ||
|
|
4c9aa66048 | ||
|
|
b6a09b99ab | ||
|
|
3a0dcb1a52 | ||
|
|
5015503b4c | ||
|
|
16423feea4 | ||
|
|
9703514698 | ||
|
|
de7a97fb76 | ||
|
|
319aaf8132 | ||
|
|
74bc58ba91 | ||
|
|
d622db0d7c | ||
|
|
de1ddf2362 | ||
|
|
32c0fc860b | ||
|
|
1938f432dd | ||
|
|
a5cfb0ca1d | ||
|
|
a172234fb0 | ||
|
|
63f989b31a | ||
|
|
2ae5d01d5c | ||
|
|
130f1deed1 | ||
|
|
5880d85b48 | ||
|
|
9455670e80 | ||
|
|
e369321c66 | ||
|
|
efc51b0d46 | ||
|
|
d6f3b23b88 | ||
|
|
0a4fa7b9f8 | ||
|
|
2b3e4a8d25 | ||
|
|
bf3a16f96d | ||
|
|
b416e72820 | ||
|
|
ca84bdb227 | ||
|
|
148a4e97a6 | ||
|
|
a13493ebc2 | ||
|
|
ce4ac79e5f | ||
|
|
8f76ea49e7 | ||
|
|
923d3293cd | ||
|
|
7379ff8d15 | ||
|
|
18ebec350d | ||
|
|
3b0cbc53aa | ||
|
|
f00e8ffa4d | ||
|
|
d6f7aad1c3 | ||
|
|
092ea6e836 | ||
|
|
d565e2464a | ||
|
|
2f5d875c47 | ||
|
|
fdb2ddc5f7 | ||
|
|
7a12c5315a | ||
|
|
60d788288d | ||
|
|
dc3c510d57 | ||
|
|
ec6a49f01e | ||
|
|
2b9bfbc20d | ||
|
|
06a51df834 | ||
|
|
6fa183dc56 | ||
|
|
b3cb4049ed | ||
|
|
602b51b1f5 | ||
|
|
a83039577c | ||
|
|
1c77a289a6 | ||
|
|
6278b9124d | ||
|
|
f94cafdbcc | ||
|
|
e13da2caba | ||
|
|
d833fa8dfd | ||
|
|
c921cc59b9 | ||
|
|
7a2c594324 | ||
|
|
0eeb9c2fbf | ||
|
|
ac921cd5a0 | ||
|
|
3ea14c1687 | ||
|
|
6e927473b9 | ||
|
|
1d9e9c1b7d | ||
|
|
54a6189b0c | ||
|
|
85aa9f39c2 | ||
|
|
bda83ce76e | ||
|
|
9ee4c20250 | ||
|
|
fbc70e43e3 | ||
|
|
bc4b4a2171 | ||
|
|
96f9bf6f6f | ||
|
|
9f0986536a | ||
|
|
f668aa7acd | ||
|
|
fc50f4784a | ||
|
|
1fa58cad31 | ||
|
|
469e62557c | ||
|
|
75830aaea7 | ||
|
|
61ef5df559 | ||
|
|
14b5ba9c4c | ||
|
|
9e9c56a3b4 | ||
|
|
3a79f55614 | ||
|
|
af25ee5c11 | ||
|
|
6dd581d5e2 | ||
|
|
746ec019c4 | ||
|
|
2b70f28b0b | ||
|
|
45127646e8 | ||
|
|
83e9c1dd97 | ||
|
|
2eabb7d5ac | ||
|
|
9d4c596b4b | ||
|
|
cc38ab6c45 | ||
|
|
586fa09c7b | ||
|
|
0c45bc5ea8 | ||
|
|
9d9c0633f0 | ||
|
|
47f9635b10 | ||
|
|
68088f5e17 | ||
|
|
77a37cc6df | ||
|
|
420e59bf81 | ||
|
|
dbc5135301 | ||
|
|
8c7d6bb552 | ||
|
|
2b5c1952c0 | ||
|
|
85a82618e5 | ||
|
|
0280ac34c3 | ||
|
|
439900154b | ||
|
|
4a6e902684 | ||
|
|
71bbd2e54a | ||
|
|
3083d8e147 | ||
|
|
e74883e9c2 | ||
|
|
0816a9d167 | ||
|
|
4b3e91fa84 | ||
|
|
0973a0b60e | ||
|
|
de5f61126d | ||
|
|
0c20ca761f | ||
|
|
4bce56207e | ||
|
|
dca54e0033 | ||
|
|
309646bf1d | ||
|
|
18b9961b39 | ||
|
|
1e51ff17f2 | ||
|
|
63b5f707e2 | ||
|
|
30efb6ee7a | ||
|
|
61b017618a | ||
|
|
1e0397adc9 | ||
|
|
48b34bf95f | ||
|
|
d5fc69e210 | ||
|
|
59f9dd697f | ||
|
|
c9d72323f1 | ||
|
|
e87f7f3abe | ||
|
|
82ebbcb6d6 | ||
|
|
2db11070c5 | ||
|
|
5efd2517e7 | ||
|
|
c0ba654678 | ||
|
|
546a5a549b | ||
|
|
cbf02c34e3 | ||
|
|
74a7258f10 | ||
|
|
1006c044bc | ||
|
|
ef4ea719f3 | ||
|
|
8b34afe69f | ||
|
|
01292af298 | ||
|
|
cff8b2fe39 | ||
|
|
2cb20b5cc0 | ||
|
|
8f2aed18fe | ||
|
|
d85831cc9a | ||
|
|
55dc3a5556 | ||
|
|
591afe08bd | ||
|
|
748f2002ab | ||
|
|
d2d18a2384 | ||
|
|
35f4fa6aa7 | ||
|
|
66fc2d22ed | ||
|
|
16cf9ee1ed | ||
|
|
d9d97bf14c | ||
|
|
dc811bd3c7 | ||
|
|
b939d1849a | ||
|
|
beca31f55d | ||
|
|
c7df103950 | ||
|
|
4bf7972ad5 | ||
|
|
534eaed1ed | ||
|
|
7e014e7385 | ||
|
|
34adb2660b | ||
|
|
b6bc165cf0 | ||
|
|
bdd5ed7fc7 | ||
|
|
95d19417c3 | ||
|
|
30ebebdd71 | ||
|
|
e9c557776d | ||
|
|
535a43b698 | ||
|
|
59752ed4aa | ||
|
|
b3e7b8f3f1 | ||
|
|
c4e9365512 | ||
|
|
7d3972d3a8 | ||
|
|
52ca4306fd | ||
|
|
da368ee612 | ||
|
|
22c50e7765 | ||
|
|
7bc39dd1bc | ||
|
|
c80ead6116 | ||
|
|
67e76e4009 | ||
|
|
b213218a30 | ||
|
|
c629a1252c | ||
|
|
64d2481e93 | ||
|
|
e7d6a6add8 | ||
|
|
edc25f7da4 | ||
|
|
5bff84ace1 | ||
|
|
f8bfcba317 | ||
|
|
013a05201b | ||
|
|
433e811821 | ||
|
|
df4cfc0fbc | ||
|
|
1bfb465fd6 | ||
|
|
d5d5ec3fef | ||
|
|
0a32f94d32 | ||
|
|
8067f34ce6 | ||
|
|
214c189a7c | ||
|
|
1f67afc8d8 | ||
|
|
7d4af27919 | ||
|
|
2d651abfdd | ||
|
|
6e06fe79cd | ||
|
|
6093577591 | ||
|
|
4b23ee733f | ||
|
|
46428b7c7f | ||
|
|
6805340a9a | ||
|
|
df36ca8d8b | ||
|
|
fe13de7c30 | ||
|
|
b00f636b72 | ||
|
|
8d074e63e1 | ||
|
|
37989b0089 | ||
|
|
477361eb40 | ||
|
|
94288b5dc3 | ||
|
|
84de1e0f12 | ||
|
|
06f93c1c10 | ||
|
|
450283b80a | ||
|
|
44aeed03a6 | ||
|
|
fa4569415d | ||
|
|
a341bf30ba | ||
|
|
34a7354c84 | ||
|
|
21b5dfbe98 | ||
|
|
c1920f5cdd | ||
|
|
3e24568df9 | ||
|
|
b785cfe854 | ||
|
|
15367bd117 | ||
|
|
d7eaac5aca | ||
|
|
d4526d605c | ||
|
|
52979356ca | ||
|
|
c6d3d6454f | ||
|
|
0d7112187d | ||
|
|
045ff3c3d9 | ||
|
|
dd68a73efd | ||
|
|
5947dc182e | ||
|
|
e185bbdb4d | ||
|
|
9368320c38 | ||
|
|
f65314bc2d | ||
|
|
1791e36038 | ||
|
|
8d93094af5 | ||
|
|
43f34fe6ed | ||
|
|
83911b2164 | ||
|
|
94c7494e90 | ||
|
|
65f2177299 | ||
|
|
f033b11e63 | ||
|
|
02f26af592 | ||
|
|
4125aba808 | ||
|
|
e89da9120c | ||
|
|
160fc218fc | ||
|
|
507d54dba0 | ||
|
|
f3029a0f76 | ||
|
|
53181588cf | ||
|
|
f88aa159fc | ||
|
|
fb2b517a67 | ||
|
|
6e952a9530 | ||
|
|
d9acef8d56 | ||
|
|
113a4d8eca | ||
|
|
6d5b93c01b | ||
|
|
5746911651 | ||
|
|
7173692db7 | ||
|
|
b13a63e568 | ||
|
|
791ec65579 | ||
|
|
dd99fddc07 | ||
|
|
5cd6977a6e | ||
|
|
5af66204c4 | ||
|
|
595efe808f | ||
|
|
7b4b3b020c | ||
|
|
3e96540b56 | ||
|
|
88b791bd73 | ||
|
|
7817019e70 | ||
|
|
6013bbd32c | ||
|
|
40e0b96f39 | ||
|
|
16560fbdf0 | ||
|
|
16fdd704aa | ||
|
|
44e84d9259 | ||
|
|
18d29461ce | ||
|
|
31fb749e93 | ||
|
|
e17931493b | ||
|
|
a395f0b31b | ||
|
|
c819896b43 | ||
|
|
733ec92c9c | ||
|
|
7c67bb7181 | ||
|
|
87f099dd7f | ||
|
|
5306d81284 | ||
|
|
471a4a3159 | ||
|
|
a2f99da3b4 | ||
|
|
accab22d56 | ||
|
|
6ea5228a5f | ||
|
|
a07d2cafb6 | ||
|
|
1b38f19cc1 | ||
|
|
aa5b286e0b | ||
|
|
6b6bbed330 | ||
|
|
489bc9534b | ||
|
|
01ebc184ad | ||
|
|
f591d66365 | ||
|
|
80782287d8 | ||
|
|
3494bb1297 | ||
|
|
92ffda5220 | ||
|
|
fbaeff6b7b | ||
|
|
248d3726dd | ||
|
|
1553559b1a | ||
|
|
8935ced75a | ||
|
|
a865d6d74f | ||
|
|
6d976554fd | ||
|
|
189b7f480a | ||
|
|
5e3aa7e2d1 | ||
|
|
730be678ef | ||
|
|
9293f422f3 | ||
|
|
6e8158bb34 | ||
|
|
3078d3ca91 | ||
|
|
947e1c7f08 | ||
|
|
938c123412 | ||
|
|
e7a57ad3b2 | ||
|
|
1e40f81bf7 | ||
|
|
72b2f44e32 | ||
|
|
76f54461e7 | ||
|
|
14ca13e31d | ||
|
|
556fd71275 | ||
|
|
a8002bba9f | ||
|
|
ddd9371fbd | ||
|
|
0ea97b73e3 | ||
|
|
f8c8a4ebeb | ||
|
|
5f613ab558 | ||
|
|
56281f9e82 | ||
|
|
5e8743dbb7 | ||
|
|
f4e4c84712 | ||
|
|
c57a0a11fa | ||
|
|
fa244b2097 | ||
|
|
79612f8a1b | ||
|
|
2bf79dbc51 | ||
|
|
c2658d5dd0 | ||
|
|
13684884c7 | ||
|
|
f216a9254e | ||
|
|
dbdbcbba2d | ||
|
|
2ee4609192 | ||
|
|
0d93cf78f7 | ||
|
|
3398ca0dd7 | ||
|
|
c1778fbcbb | ||
|
|
1ef9974c05 | ||
|
|
399c6b6fed | ||
|
|
62a60eee44 | ||
|
|
54339af885 | ||
|
|
06cfd33e60 | ||
|
|
08c9d78d2a | ||
|
|
e7a5e5dce1 | ||
|
|
3a59a127d1 | ||
|
|
26f213cad2 | ||
|
|
7b6148302d | ||
|
|
38c781b8f3 | ||
|
|
64d827fdcd | ||
|
|
74ad812f37 | ||
|
|
364c829119 | ||
|
|
1ac2c5b61e | ||
|
|
0766199353 | ||
|
|
878bccf151 | ||
|
|
acbd258296 | ||
|
|
54a14e6e5a | ||
|
|
298e4b52f0 | ||
|
|
bee1fbcf88 | ||
|
|
345a34287e | ||
|
|
441a2ca2da | ||
|
|
1ff1b21355 | ||
|
|
117ca4e05b | ||
|
|
07d457be4e | ||
|
|
d48296046e | ||
|
|
56350de2cf | ||
|
|
850dc0e83b | ||
|
|
35f01478b1 | ||
|
|
f9a3ec012f | ||
|
|
3b9b404482 | ||
|
|
d8b0cc4834 | ||
|
|
da13f5e218 | ||
|
|
08e14ae11c | ||
|
|
c2902dff28 | ||
|
|
c4fb39f02f | ||
|
|
b7df44c35a | ||
|
|
9a2b21eee5 | ||
|
|
bdac67df88 | ||
|
|
0b8f19bfad | ||
|
|
c7c5866131 | ||
|
|
f772fa000c | ||
|
|
93fd82fcd9 | ||
|
|
3ae10bfd04 | ||
|
|
a44747ccad | ||
|
|
87ab45f936 | ||
|
|
37b046eb46 | ||
|
|
c6f8a45027 | ||
|
|
6ec16e1f98 | ||
|
|
40adf85b20 | ||
|
|
4c78f469c1 | ||
|
|
55af58faac | ||
|
|
4200caa641 | ||
|
|
0ac06f8e3d | ||
|
|
966c78fb16 | ||
|
|
5c5a35d3bb | ||
|
|
2c24214f48 | ||
|
|
67d9e70b3c | ||
|
|
000a55f43b | ||
|
|
4096a6976c | ||
|
|
df4c4ebd50 | ||
|
|
b43bd4e0e2 | ||
|
|
2660dbf866 | ||
|
|
e0b7c60099 | ||
|
|
536b58bf67 | ||
|
|
6bb742f828 | ||
|
|
72742e5e12 | ||
|
|
3667e0a509 | ||
|
|
c2d7668ba7 | ||
|
|
aa830f5e20 | ||
|
|
b593fa4146 | ||
|
|
b00b906484 | ||
|
|
c1bd6a1be6 | ||
|
|
36739f04b3 | ||
|
|
23eb92853e | ||
|
|
5ab2910dc7 | ||
|
|
40d07f6764 | ||
|
|
5c8e216169 | ||
|
|
5ba061deda | ||
|
|
935c83185d | ||
|
|
6327391e65 | ||
|
|
3d656cf5b0 | ||
|
|
d570a0f1a2 | ||
|
|
503a71302c | ||
|
|
3e36ceb5b9 | ||
|
|
cde7a1d49f | ||
|
|
b14a38e4fb | ||
|
|
732a526a8e | ||
|
|
2da5ffef44 | ||
|
|
2e6e52004f | ||
|
|
4486ad353c | ||
|
|
aa795e2731 | ||
|
|
c46fe7d1c6 | ||
|
|
d7cee8cca6 | ||
|
|
11f790ace5 | ||
|
|
13e7c1b754 | ||
|
|
d314d5515f | ||
|
|
09b19e3ca0 | ||
|
|
687bd11fd1 | ||
|
|
56cb1cd30d | ||
|
|
7a3df25521 | ||
|
|
ea8919ba07 | ||
|
|
3dece4fcdb | ||
|
|
df950a1bd2 | ||
|
|
74b9ee31fa | ||
|
|
64cd55fe58 | ||
|
|
e80ede14fb | ||
|
|
45ba9d3320 | ||
|
|
47c7048538 | ||
|
|
f9bfa8101f | ||
|
|
620ac464eb | ||
|
|
62289f8ab8 | ||
|
|
d84594da96 | ||
|
|
e1d74aae6a | ||
|
|
c4980d9eb7 | ||
|
|
882d83c6b7 | ||
|
|
c4a7fd81f8 | ||
|
|
0e55799109 | ||
|
|
a3cdcb2a1a | ||
|
|
e0ccc298f9 | ||
|
|
36b49bb577 | ||
|
|
2636c24e84 | ||
|
|
6bcf294635 | ||
|
|
c5fa6689a4 | ||
|
|
3bf0cb2485 | ||
|
|
19c9335527 | ||
|
|
20da2e1b97 | ||
|
|
9eceb8641d | ||
|
|
86bc915d74 | ||
|
|
6b35525207 | ||
|
|
4633bf4fc6 | ||
|
|
2665f31d94 | ||
|
|
6c4d3149eb | ||
|
|
a2762e6ce6 | ||
|
|
792a1bfcad | ||
|
|
a0eba9d60e | ||
|
|
c2e0064253 | ||
|
|
f246efc84b | ||
|
|
4a3bf7e96c | ||
|
|
523b81090d | ||
|
|
d706c405d9 |
20
.env
20
.env
@@ -2,12 +2,18 @@ GENERATE_SOURCEMAP=false
|
||||
|
||||
REACT_APP_NAME=KISS Translator
|
||||
REACT_APP_NAME_CN=简约翻译
|
||||
REACT_APP_VERSION=1.5.4
|
||||
REACT_APP_VERSION=2.0.5
|
||||
|
||||
REACT_APP_HOMEPAGE=https://github.com/fishjar/kiss-translator
|
||||
REACT_APP_OPTIONSPAGE=https://kiss-translator.rayjar.com/options
|
||||
REACT_APP_OPTIONSPAGE2=https://fishjar.github.io/kiss-translator/options.html
|
||||
|
||||
REACT_APP_OPTIONSPAGE=https://fishjar.github.io/kiss-translator/options.html
|
||||
REACT_APP_OPTIONSPAGE_DEV=http://localhost:3000/options.html
|
||||
REACT_APP_LOGOURL=https://kiss-translator.rayjar.com/images/logo192.png
|
||||
REACT_APP_RULESURL=https://kiss-translator.rayjar.com/kiss-translator-rules.json
|
||||
REACT_APP_USERSCRIPT_DOWNLOADURL=https://kiss-translator.rayjar.com/kiss-translator.user.js
|
||||
REACT_APP_USERSCRIPT_DOWNLOADURL2=https://fishjar.github.io/kiss-translator/kiss-translator.user.js
|
||||
|
||||
REACT_APP_LOGOURL=https://fishjar.github.io/kiss-translator/images/logo192.png
|
||||
|
||||
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_IOS_DOWNLOADURL=https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js
|
||||
|
||||
40
.github/workflows/release.yml
vendored
40
.github/workflows/release.yml
vendored
@@ -7,25 +7,28 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
node-version: "18.17.0"
|
||||
cache: "yarn"
|
||||
- run: yarn install
|
||||
- run: yarn build
|
||||
- uses: actions/upload-artifact@v3
|
||||
version: latest
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
- run: pnpm install
|
||||
- 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
|
||||
@@ -34,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:
|
||||
@@ -51,18 +55,14 @@ jobs:
|
||||
needs: [build, create-release]
|
||||
strategy:
|
||||
matrix:
|
||||
client: ["chrome", "edge", "firefox", "userscript"]
|
||||
runs-on: ubuntu-22.04
|
||||
client: ["chrome", "edge", "firefox", "userscript", "thunderbird"]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/download-artifact@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: build
|
||||
- name: Zip Release
|
||||
run: |
|
||||
cd build
|
||||
zip -r ${{ matrix.client }}.zip ${{ matrix.client }}
|
||||
- uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
/.obsidian
|
||||
.pnp.js
|
||||
.yarn
|
||||
|
||||
|
||||
4
.prettierignore
Normal file
4
.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
build
|
||||
public
|
||||
package.json
|
||||
24
.prettierrc
Normal file
24
.prettierrc
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"arrowParens": "always",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "lf",
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"insertPragma": false,
|
||||
"singleAttributePerLine": false,
|
||||
"bracketSameLine": false,
|
||||
"jsxBracketSameLine": false,
|
||||
"jsxSingleQuote": false,
|
||||
"printWidth": 80,
|
||||
"proseWrap": "preserve",
|
||||
"quoteProps": "as-needed",
|
||||
"requirePragma": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"useTabs": false,
|
||||
"embeddedLanguageFormatting": "auto",
|
||||
"vueIndentScriptAndStyle": false,
|
||||
"experimentalTernaries": false,
|
||||
"parser": "babel"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
nodeLinker: node-modules
|
||||
205
README.en.md
205
README.en.md
@@ -1,62 +1,173 @@
|
||||
## KISS Translator
|
||||
# KISS Translator
|
||||
|
||||
A minimalist [bilingual translation Extension & Greasemonkey Script](https://github.com/fishjar/kiss-translator).
|
||||
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)
|
||||
|
||||
### Inspiration
|
||||
## Features
|
||||
|
||||
The inspiration for this project comes from [Immersive Translate](https://github.com/immersive-translate/immersive-translate). After trying it out, I found that it can be used together with the [Webpage Word Translation Extension](https://github.com/fishjar/kiss-dictionary) developed by me earlier, which just forms a very good supplement.
|
||||
|
||||
But the function of this extension is a bit complicated for me, and only the compiled and obfuscated installation package is provided, and the source code is not provided, which cannot meet some of my personalized customization needs.
|
||||
|
||||
It just so happens that I am obsessed with translation tools. Based on the concept of "mainly for personal use, as long as you can use it", I made one. At present, the first version is completed, which basically meets the needs of personal use.
|
||||
|
||||
If you also like a little more simplicity, welcome to pick it up.
|
||||
|
||||
### Features
|
||||
|
||||
- Keep it simple, smart
|
||||
|
||||
### Schedule
|
||||
|
||||
- [x] Provide trial installation package
|
||||
- [x] Adapt browser
|
||||
- [x] Chrome
|
||||
- [x] Edge
|
||||
- [x] Firefox
|
||||
- [ ] Safari
|
||||
- [x] Kiwi
|
||||
- [x] Support translation services
|
||||
- [x] Google
|
||||
- [x] Microsoft
|
||||
- [x] OpenAI
|
||||
- [ ] DeepL
|
||||
- [ ] Upload to app Store
|
||||
- [x] [Chrome](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof)
|
||||
- [ ] Edge
|
||||
- [x] [Firefox](https://addons.mozilla.org/zh-CN/firefox/addon/kiss-translator/)
|
||||
- [ ] Safari
|
||||
- [x] [Greasy Fork](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator)
|
||||
- [x] Keep it simple, smart
|
||||
- [x] Open source
|
||||
- [x] Data Synchronization Function
|
||||
- [x] Greasemonkey Script ([link 1](https://fishjar.github.io/kiss-translator/kiss-translator.user.js)、[link 2](https://kiss-translator.rayjar.com/kiss-translator.user.js))
|
||||
- [x] [Tampermonkey](https://www.tampermonkey.net/) (Chrome/Edge/Firefox)
|
||||
- [ ] [Userscripts Safari](https://github.com/quoid/userscripts) (need test)
|
||||
- [x] Adapt to common browsers
|
||||
- [x] Chrome/Edge
|
||||
- [x] Firefox
|
||||
- [x] Kiwi (Android)
|
||||
- [x] Orion (iOS)
|
||||
- [x] Safari
|
||||
- [x] Thunderbird
|
||||
- [x] Supports multiple translation services
|
||||
- [x] Google/Microsoft
|
||||
- [x] Tencent/Volcengine
|
||||
- [x] OpenAI/Gemini/Claude/Ollama/DeepSeek/OpenRouter
|
||||
- [x] DeepL/DeepLX/NiuTrans
|
||||
- [x] AzureAI / CloudflareAI
|
||||
- [x] Chrome built-in AI translation (BuiltinAI)
|
||||
- [x] Covers common translation scenarios
|
||||
- [x] Webpage bilingual translation
|
||||
- [x] Input-box translation
|
||||
- Instantly translate text in input fields into other languages via shortcut keys
|
||||
- [x] Text selection translation
|
||||
- [x] Open translation popup on any page, support multiple translation services for comparison
|
||||
- [x] English dictionary lookup
|
||||
- [x] Save vocabulary
|
||||
- [x] Hover translation
|
||||
- [x] YouTube subtitle translation
|
||||
- Support translating video subtitles with any translation service and display bilingually
|
||||
- Built-in basic subtitle merging and sentence-splitting algorithm to improve translation quality
|
||||
- Supports AI-powered sentence segmentation for even better translation
|
||||
- Custom subtitle style
|
||||
- [x] Supports diverse translation modes
|
||||
- [x] Supports both automatic text recognition and manual rule modes
|
||||
- Automatic text recognition mode allows most sites to be translated fully without writing rules
|
||||
- Manual rule mode enables extreme optimization for specific sites
|
||||
- [x] Custom translation styling
|
||||
- [x] Supports rich-text translation and rendering, preserving links and other text styles where possible
|
||||
- [x] Option to show only translation (hide original text)
|
||||
- [x] Advanced translation API features
|
||||
- [x] With custom API support, theoretically works with any translation service
|
||||
- [x] Batch aggregation of translation requests
|
||||
- [x] Supports AI conversation context memory to improve translation quality
|
||||
- [x] Custom AI terminology dictionary
|
||||
- [x] All APIs support hooks and custom parameters for advanced usage
|
||||
- [x] Cross-client data synchronization
|
||||
- [x] KISS-Worker(cloudflare/docker)
|
||||
- [x] WebDAV
|
||||
- [x] Custom translation rules
|
||||
- [x] Rule subscription/rule sharing
|
||||
- [x] Customized terminology
|
||||
- [x] Custom shortcut keys
|
||||
- `Alt+Q` Toggle Translation
|
||||
- `Alt+C` Toggle Styles
|
||||
- `Alt+K` Open Setting Popup
|
||||
- `Alt+S` Open Translate Popup / Translate Selected Text
|
||||
- `Alt+O` Open Options Page
|
||||
- `Alt+I` Input Box Translation
|
||||
|
||||
### Guide
|
||||
## Install
|
||||
|
||||
> 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.)
|
||||
> - Grease Monkey script will encounter more usage problems (cross domain issues, script conflicts, etc.)
|
||||
|
||||
- [x] Browser extension
|
||||
- [x] Chrome [Installation address](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=zh-CN)
|
||||
- [x] Kiwi (Android)
|
||||
- [x] Orion (iOS)
|
||||
- [x] Edge [Installation address](https://microsoftedge.microsoft.com/addons/detail/%E7%AE%80%E7%BA%A6%E7%BF%BB%E8%AF%91/jemckldkclkinpjighnoilpbldbdmmlh?hl=zh-CN)
|
||||
- [x] Firefox [Installation address](https://addons.mozilla.org/zh-CN/firefox/addon/kiss-translator/)
|
||||
- [ ] Safari
|
||||
- [ ] Safari (Mac)
|
||||
- [ ] Safari (iOS)
|
||||
- [x] Thunderbird [Download address](https://github.com/fishjar/kiss-translator/releases)
|
||||
- [x] GreaseMonkey Script
|
||||
- [x] Chrome/Edge/Firefox ([Tampermonkey](https://www.tampermonkey.net/)/[Violentmonkey](https://violentmonkey.github.io/)) [Installation link](https://fishjar.github.io/kiss-translator/kiss-translator.user.js)
|
||||
- [Greasy Fork](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator)
|
||||
- [x] iOS Safari ([Userscripts Safari](https://github.com/quoid/userscripts)) [Installation link](https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js)
|
||||
|
||||
## Associated Projects
|
||||
|
||||
- Data synchronization service: [https://github.com/fishjar/kiss-worker](https://github.com/fishjar/kiss-worker)
|
||||
- Data synchronization service available for this project.
|
||||
- Can also be used to share personal private rule lists.
|
||||
- Deploy by yourself, manage by yourself, data is private.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
|
||||
### API (Ollama, etc.) Test Failure
|
||||
|
||||
Common reasons for API test failures include:
|
||||
|
||||
- Incorrect address:
|
||||
- For example, `Ollama` has a native API address and an `Openai`-compatible address. This plugin currently supports the `Openai`-compatible address and does not support the `Ollama` native API address.
|
||||
- Some AI models do not support batch translation:
|
||||
- In this case, you can choose to disable batch translation or use a custom API.
|
||||
- Alternatively, you can use a custom API. For details, please refer to: [Custom API Example Documentation](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)
|
||||
- Some AI models have inconsistent parameters:
|
||||
- For example, the parameters of the `Gemini` native API are highly inconsistent. Some model versions do not support certain parameters, leading to errors.
|
||||
- In this case, you can modify the request body using a `Hook`, or replace it with `Gemini2` (an OpenAI-compatible address).
|
||||
- The server restricts cross-origin access, returning a 403 error:
|
||||
- For example, `Ollama` requires adding the environment variable `OLLAMA_ORIGINS=*` when starting. See: 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.
|
||||
|
||||
### How to set up a hook function for a custom API
|
||||
|
||||
Custom APIs are very powerful and flexible, and can theoretically connect to any translation API.
|
||||
|
||||
Example reference: [custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)
|
||||
|
||||
### How to directly access the Tampermonkey script settings page
|
||||
|
||||
Settings page address: https://fishjar.github.io/kiss-translator/options.html
|
||||
|
||||
## 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
|
||||
yarn install
|
||||
yarn build
|
||||
git checkout dev # Submit a PR suggestion to push to the dev branch
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Data Sync
|
||||
|
||||
Goto: [https://github.com/fishjar/kiss-worker](https://github.com/fishjar/kiss-worker)
|
||||
|
||||
### Discussion
|
||||
## Discussion
|
||||
|
||||
- Join [Telegram Group](https://t.me/+RRCu_4oNwrM2NmFl)
|
||||
|
||||
## Appreciate
|
||||
|
||||

|
||||
|
||||
205
README.md
205
README.md
@@ -1,62 +1,173 @@
|
||||
## 简约翻译
|
||||
# 简约翻译
|
||||
|
||||
一个简约的 [双语网页翻译扩展 & 油猴脚本](https://github.com/fishjar/kiss-translator)。
|
||||
[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)
|
||||
|
||||
### 缘由
|
||||
## 特性
|
||||
|
||||
本项目灵感来源于 [Immersive Translate](https://github.com/immersive-translate/immersive-translate),在试用了后,发现搭配本人早前开发的 [网页划词翻译扩展](https://github.com/fishjar/kiss-dictionary) 一起使用,刚好形成很好补充。
|
||||
|
||||
但该扩展的功能对我来说有些繁杂了,而且只提供编译混淆后的安装包,没有提供源代码,无法满足我的一些个性化定制需求。
|
||||
|
||||
恰巧本人对翻译类工具有些执念,本着`“自用为主,能用就行”`的理念,于是动手撸了一个,目前初版完成,基本达到个人使用需求。
|
||||
|
||||
如果你也喜欢简约一点的,欢迎自取。
|
||||
|
||||
### 特点
|
||||
|
||||
- 保持简约
|
||||
|
||||
### 进度
|
||||
|
||||
- [x] 提供试用安装包
|
||||
- [x] 适配浏览器
|
||||
- [x] Chrome
|
||||
- [x] Edge
|
||||
- [x] Firefox
|
||||
- [ ] Safari
|
||||
- [x] Kiwi
|
||||
- [x] 支持翻译服务
|
||||
- [x] Google
|
||||
- [x] Microsoft
|
||||
- [x] OpenAI
|
||||
- [ ] DeepL
|
||||
- [ ] 上架应用市场
|
||||
- [x] [Chrome](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=zh-CN)
|
||||
- [ ] Edge
|
||||
- [x] [Firefox](https://addons.mozilla.org/zh-CN/firefox/addon/kiss-translator/)
|
||||
- [ ] Safari
|
||||
- [x] [Greasy Fork](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator)
|
||||
- [x] 保持简约
|
||||
- [x] 开放源代码
|
||||
- [x] 数据同步功能
|
||||
- [x] 油猴脚本([链接 1](https://fishjar.github.io/kiss-translator/kiss-translator.user.js)、[链接 2](https://kiss-translator.rayjar.com/kiss-translator.user.js))
|
||||
- [x] [Tampermonkey](https://www.tampermonkey.net/) (Chrome/Edge/Firefox)
|
||||
- [ ] [Userscripts Safari](https://github.com/quoid/userscripts) (待测)
|
||||
- [x] 适配常见浏览器
|
||||
- [x] Chrome/Edge
|
||||
- [x] Firefox
|
||||
- [x] Kiwi (Android)
|
||||
- [x] Orion (iOS)
|
||||
- [x] Safari
|
||||
- [x] Thunderbird
|
||||
- [x] 支持多种翻译服务
|
||||
- [x] Google/Microsoft
|
||||
- [x] Tencent/Volcengine
|
||||
- [x] OpenAI/Gemini/Claude/Ollama/DeepSeek/OpenRouter
|
||||
- [x] DeepL/DeepLX/NiuTrans
|
||||
- [x] AzureAI/CloudflareAI
|
||||
- [x] Chrome浏览器内置AI翻译(BuiltinAI)
|
||||
- [x] 覆盖常见翻译场景
|
||||
- [x] 网页双语对照翻译
|
||||
- [x] 输入框翻译
|
||||
- 通过快捷键立即将输入框内文本翻译成其他语言
|
||||
- [x] 划词翻译
|
||||
- [x] 任意页面打开翻译框,可用多种翻译服务对比翻译
|
||||
- [x] 英文词典翻译
|
||||
- [x] 收藏词汇
|
||||
- [x] 鼠标悬停翻译
|
||||
- [x] YouTube 字幕翻译
|
||||
- 支持任意翻译服务对视频字幕进行翻译并双语显示
|
||||
- 内置基础的字幕合并与断句算法,提升翻译效果
|
||||
- 支持AI断句功能,可进一步提升翻译质量
|
||||
- 自定义字幕样式
|
||||
- [x] 支持多样翻译效果
|
||||
- [x] 支持自动识别文本与手动规则两种模式
|
||||
- 自动识别文本模式使得绝大部分网站无需编写规则也能翻译完整
|
||||
- 手动规则模式,可以针对特定网站极致优化
|
||||
- [x] 自定义译文样式
|
||||
- [x] 支持富文本翻译及显示,能够尽量保留原文中的链接及其他文本样式
|
||||
- [x] 支持仅显示译文(隐藏原文)
|
||||
- [x] 翻译接口高级功能
|
||||
- [x] 通过自定义接口,理论上支持任何翻译接口
|
||||
- [x] 聚合批量发送翻译文本
|
||||
- [x] 支持AI上下文会话记忆功能,提升翻译效果
|
||||
- [x] 自定义AI术语词典
|
||||
- [x] 所有接口均支持Hook和自定义参数等高级功能
|
||||
- [x] 跨客户端数据同步
|
||||
- [x] KISS-Worker(cloudflare/docker)
|
||||
- [x] WebDAV
|
||||
- [x] 自定义翻译规则
|
||||
- [x] 规则订阅/规则分享
|
||||
- [x] 自定义专业术语
|
||||
- [x] 自定义快捷键
|
||||
- `Alt+Q` 开启翻译
|
||||
- `Alt+C` 切换样式
|
||||
- `Alt+K` 打开设置弹窗
|
||||
- `Alt+S` 打开翻译弹窗/翻译选中文字
|
||||
- `Alt+O` 打开设置页面
|
||||
- `Alt+I` 输入框翻译
|
||||
|
||||
### 指引
|
||||
## 安装
|
||||
|
||||
> 注:基于以下原因,建议优先使用浏览器扩展
|
||||
>
|
||||
> - 浏览器扩展的功能更完整(本地语言识别、右键菜单等)
|
||||
> - 油猴脚本会遇到更多使用上的问题(跨域问题、脚本冲突等)
|
||||
|
||||
- [x] 浏览器扩展
|
||||
- [x] Chrome [安装地址](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=zh-CN)
|
||||
- [x] Kiwi (Android)
|
||||
- [x] Orion (iOS)
|
||||
- [x] Edge [安装地址](https://microsoftedge.microsoft.com/addons/detail/%E7%AE%80%E7%BA%A6%E7%BF%BB%E8%AF%91/jemckldkclkinpjighnoilpbldbdmmlh?hl=zh-CN)
|
||||
- [x] Firefox [安装地址](https://addons.mozilla.org/zh-CN/firefox/addon/kiss-translator/)
|
||||
- [ ] Safari
|
||||
- [ ] Safari (Mac)
|
||||
- [ ] Safari (iOS)
|
||||
- [x] Thunderbird [下载地址](https://github.com/fishjar/kiss-translator/releases)
|
||||
- [x] 油猴脚本
|
||||
- [x] Chrome/Edge/Firefox ([Tampermonkey](https://www.tampermonkey.net/)/[Violentmonkey](https://violentmonkey.github.io/)) [安装链接](https://fishjar.github.io/kiss-translator/kiss-translator.user.js)
|
||||
- [Greasy Fork](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator)
|
||||
- [x] iOS Safari ([Userscripts Safari](https://github.com/quoid/userscripts)) [安装链接](https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js)
|
||||
|
||||
## 关联项目
|
||||
|
||||
- 数据同步服务: [https://github.com/fishjar/kiss-worker](https://github.com/fishjar/kiss-worker)
|
||||
- 可用于本项目的数据同步服务。
|
||||
- 亦可用于分享个人的私有规则列表。
|
||||
- 自己部署,自己管理,数据私有。
|
||||
- 社区订阅规则: [https://github.com/fishjar/kiss-rules](https://github.com/fishjar/kiss-rules)
|
||||
- 提供社区维护的,最新最全的订阅规则列表。
|
||||
- 求助规则相关的问题。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 如何设置快捷键
|
||||
|
||||
在插件管理那里设置,例如:
|
||||
|
||||
- chrome [chrome://extensions/shortcuts](chrome://extensions/shortcuts)
|
||||
- firefox [about:addons](about:addons)
|
||||
|
||||
### 规则设置的优先级是如何的
|
||||
|
||||
个人规则 > 订阅规则 > 全局规则
|
||||
|
||||
其中全局规则优先级最低,但非常重要,相当于兜底规则。
|
||||
|
||||
### 接口(Ollama等)测试失败
|
||||
|
||||
一般接口测试失败常见有以下几种原因:
|
||||
|
||||
- 地址填错了:
|
||||
- 比如 `Ollama` 有原生接口地址和 `Openai` 兼容的地址,本插件目前统一支持 `Openai` 兼容的地址,不支持 `Ollama` 原生接口地址
|
||||
- 某些AI模型不支持聚合翻译:
|
||||
- 此种情况可以选择禁用聚合翻译或通过自定义接口的方式来使用。
|
||||
- 或通过自定义接口的方式来使用,详情参考: [自定义接口示例文档](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)
|
||||
- 某些AI模型的参数不一致:
|
||||
- 比如 `Gemini` 原生接口参数非常不一致,部分版本的模型不支持某些参数会导致返回错误。
|
||||
- 此种情况可以通过 `Hook` 修改请求 `body` ,或者更换为 `Gemini2` (`Openai` 兼容的地址)
|
||||
- 服务器跨域限制访问,返回403错误:
|
||||
- 比如 `Ollama` 启动时须添加环境变量 `OLLAMA_ORIGINS=*`, 参考:https://github.com/fishjar/kiss-translator/issues/174
|
||||
|
||||
### 填写的接口在油猴脚本不能使用
|
||||
|
||||
油猴脚本需要增加域名白名单,否则不能发出请求。
|
||||
|
||||
### 如何设置自定义接口的hook函数
|
||||
|
||||
自定义接口功能非常强大、灵活,理论可以接入任何翻译接口。
|
||||
|
||||
示例参考: [custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)
|
||||
|
||||
### 如何直接进入油猴脚本设置页面
|
||||
|
||||
设置页面地址: https://fishjar.github.io/kiss-translator/options.html
|
||||
|
||||
## 未来规划
|
||||
|
||||
本项目为业余开发,无严格时间表,欢迎社区共建。以下为初步设想的功能方向:
|
||||
|
||||
- [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
|
||||
yarn install
|
||||
yarn build
|
||||
git checkout dev # 提交PR建议推送到dev分支
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### 数据同步
|
||||
|
||||
移步: [https://github.com/fishjar/kiss-worker](https://github.com/fishjar/kiss-worker)
|
||||
|
||||
### 交流
|
||||
## 交流
|
||||
|
||||
- 加入 [Telegram 群](https://t.me/+RRCu_4oNwrM2NmFl)
|
||||
|
||||
## 赞赏
|
||||
|
||||

|
||||
|
||||
@@ -32,6 +32,8 @@ const extWebpack = (config, env) => {
|
||||
options: paths.appSrc + "/options.js",
|
||||
background: paths.appSrc + "/background.js",
|
||||
content: paths.appSrc + "/content.js",
|
||||
"injector-subtitle": paths.appSrc + "/injector-subtitle.js",
|
||||
"injector-shadowroot": paths.appSrc + "/injector-shadowroot.js",
|
||||
};
|
||||
|
||||
config.output.filename = "[name].js";
|
||||
@@ -75,7 +77,7 @@ const userscriptWebpack = (config, env) => {
|
||||
// @name ${process.env.REACT_APP_NAME}
|
||||
// @namespace ${process.env.REACT_APP_HOMEPAGE}
|
||||
// @version ${process.env.REACT_APP_VERSION}
|
||||
// @description A minimalist bilingual translation Extension & Greasemonkey Script (一个简约的双语网页翻译扩展 & 油猴脚本)
|
||||
// @description A simple bilingual translation extension & Greasemonkey script (一个简约的双语对照翻译扩展 & 油猴脚本)
|
||||
// @author Gabe<yugang2002@gmail.com>
|
||||
// @homepageURL ${process.env.REACT_APP_HOMEPAGE}
|
||||
// @license GPL-3.0
|
||||
@@ -85,21 +87,41 @@ const userscriptWebpack = (config, env) => {
|
||||
// @updateURL ${process.env.REACT_APP_USERSCRIPT_DOWNLOADURL}
|
||||
// @grant GM.xmlHttpRequest
|
||||
// @grant GM.registerMenuCommand
|
||||
// @grant GM.unregisterMenuCommand
|
||||
// @grant GM.setValue
|
||||
// @grant GM.getValue
|
||||
// @grant GM.deleteValue
|
||||
// @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
|
||||
// @connect api.openai.com
|
||||
// @connect generativelanguage.googleapis.com
|
||||
// @connect openai.azure.com
|
||||
// @connect workers.dev
|
||||
// @connect github.io
|
||||
// @connect github.com
|
||||
// @connect githubusercontent.com
|
||||
// @connect kiss-translator.rayjar.com
|
||||
// @connect ghproxy.com
|
||||
// @connect dav.jianguoyun.com
|
||||
// @connect fanyi.baidu.com
|
||||
// @connect transmart.qq.com
|
||||
// @connect 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==
|
||||
|
||||
|
||||
348
custom-api.md
Normal file
348
custom-api.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# 自定义接口示例(本文档已过期,新版不再适用)
|
||||
|
||||
V2版的示例请查看这里:[custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)
|
||||
|
||||
以下示例为网友提供,仅供学习参考。
|
||||
|
||||
## 本地运行 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]]
|
||||
```
|
||||
|
||||
|
||||
295
custom-api_v2.md
Normal file
295
custom-api_v2.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# 自定义接口示例
|
||||
|
||||
## 默认接口规范
|
||||
|
||||
如果接口的请求数据和返回数据符合以下规范,
|
||||
则无需填写 `Request Hook` 或 `Response Hook`。
|
||||
|
||||
Request body
|
||||
|
||||
```json
|
||||
{
|
||||
"texts": ["hello"], // 需要翻译的文本列表
|
||||
"from":"auto", // 原文语言
|
||||
"to": "zh-CN" // 目标语言
|
||||
}
|
||||
```
|
||||
|
||||
Response
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"text": "你好", // 译文
|
||||
"src": "en" // 原文语言
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
v2.0.4版后亦支持以下 Response 格式
|
||||
|
||||
```json
|
||||
{
|
||||
"translations": [ // 译文列表
|
||||
{
|
||||
"text": "你好", // 译文
|
||||
"src": "en" // 原文语言
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 谷歌翻译接口
|
||||
|
||||
> 此接口不支持聚合
|
||||
|
||||
URL
|
||||
|
||||
```
|
||||
https://translate.googleapis.com/translate_a/single?client=gtx&dj=1&dt=t&ie=UTF-8&q={{text}}&sl=en&tl=zh-CN
|
||||
```
|
||||
|
||||
Request Hook
|
||||
|
||||
```js
|
||||
async (args) => {
|
||||
const url = args.url.replace("{{text}}", args.texts[0]);
|
||||
const method = "GET";
|
||||
return { url, method };
|
||||
};
|
||||
```
|
||||
|
||||
Response Hook
|
||||
|
||||
```js
|
||||
async ({ res }) => {
|
||||
return { translations: [[res?.sentences?.[0]?.trans || "", res?.src]] };
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
## Ollama
|
||||
|
||||
> 此示例为支持聚合的模型类(要支持上下文,需进一步改动)
|
||||
|
||||
* 注意 ollama 启动参数需要添加环境变量 `OLLAMA_ORIGINS=*`
|
||||
* 检查环境变量生效命令:`systemctl show ollama | grep OLLAMA_ORIGINS`
|
||||
|
||||
URL
|
||||
|
||||
```
|
||||
http://localhost:11434/v1/chat/completions
|
||||
```
|
||||
|
||||
Request Hook
|
||||
|
||||
```js
|
||||
async (args) => {
|
||||
const url = args.url;
|
||||
const method = "POST";
|
||||
const headers = { "Content-type": "application/json" };
|
||||
const body = {
|
||||
model: "gemma3",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
'Act as a translation API. Output a single raw JSON object only. No extra text or fences.\n\nInput:\n{"targetLanguage":"<lang>","title":"<context>","description":"<context>","segments":[{"id":1,"text":"..."}],"glossary":{"sourceTerm":"targetTerm"},"tone":"<formal|casual>"}\n\nOutput:\n{"translations":[{"id":1,"text":"...","sourceLanguage":"<detected>"}]}\n\nRules:\n1. Use title/description for context only; do not output them.\n2. Keep id, order, and count of segments.\n3. Preserve whitespace, HTML entities, and all HTML-like tags (e.g., <i1>, <a1>). Translate inner text only.\n4. Highest priority: Follow \'glossary\'. Use value for translation; if value is "", keep the key.\n5. Do not translate: content in <code>, <pre>, text enclosed in backticks, or placeholders like {1}, {{1}}, [1], [[1]].\n6. Apply the specified tone to the translation.\n7. Detect sourceLanguage for each segment.\n8. Return empty or unchanged inputs as is.\n\nExample:\nInput: {"targetLanguage":"zh-CN","segments":[{"id":1,"text":"A <b>React</b> component."}],"glossary":{"component":"组件","React":""}}\nOutput: {"translations":[{"id":1,"text":"一个<b>React</b>组件","sourceLanguage":"en"}]}\n\nFail-safe: On any error, return {"translations":[]}.',
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: JSON.stringify({
|
||||
targetLanguage: args.to,
|
||||
segments: args.texts.map((text, id) => ({ id, text })),
|
||||
glossary: {},
|
||||
}),
|
||||
},
|
||||
],
|
||||
temperature: 0,
|
||||
max_tokens: 20480,
|
||||
think: false,
|
||||
stream: false,
|
||||
};
|
||||
|
||||
return { url, body, headers, method };
|
||||
};
|
||||
```
|
||||
|
||||
v2.0.2 Request Hook 可以简化为:
|
||||
|
||||
```js
|
||||
async (args) => {
|
||||
const url = args.url;
|
||||
const method = "POST";
|
||||
const headers = { "Content-type": "application/json" };
|
||||
const body = {
|
||||
model: "gemma3", // v2.0.2 版后此处可填 args.model
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: args.defaultSystemPrompt, // 或者 args.systemPrompt
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: JSON.stringify({
|
||||
targetLanguage: args.to,
|
||||
segments: args.texts.map((text, id) => ({ id, text })),
|
||||
glossary: {},
|
||||
}),
|
||||
},
|
||||
],
|
||||
temperature: 0,
|
||||
max_tokens: 20480,
|
||||
think: false,
|
||||
stream: false,
|
||||
};
|
||||
|
||||
return { url, body, headers, method };
|
||||
};
|
||||
```
|
||||
|
||||
Response Hook
|
||||
|
||||
```js
|
||||
async ({ res }) => {
|
||||
const extractJson = (raw) => {
|
||||
const jsonRegex = /({.*}|\[.*\])/s;
|
||||
const match = raw.match(jsonRegex);
|
||||
return match ? match[0] : null;
|
||||
};
|
||||
|
||||
const parseAIRes = (raw) => {
|
||||
if (!raw) return [];
|
||||
|
||||
try {
|
||||
const jsonString = extractJson(raw);
|
||||
if (!jsonString) return [];
|
||||
|
||||
const data = JSON.parse(jsonString);
|
||||
if (Array.isArray(data.translations)) {
|
||||
return data.translations.map((item) => [
|
||||
item?.text ?? "",
|
||||
item?.sourceLanguage ?? "",
|
||||
]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("parseAIRes", err);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const translations = parseAIRes(res?.choices?.[0]?.message?.content);
|
||||
|
||||
return { translations };
|
||||
};
|
||||
```
|
||||
|
||||
v2.0.2 版后内置`parseAIRes`函数,Response Hook 可以简化为:
|
||||
|
||||
```js
|
||||
async ({ res, parseAIRes }) => {
|
||||
const translations = parseAIRes(res?.choices?.[0]?.message?.content);
|
||||
return { translations };
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
## 硅基流动
|
||||
|
||||
> 此示例为不支持聚合模型类,支持聚合的模型类参考上面 Ollama 的写法
|
||||
|
||||
URL
|
||||
|
||||
```
|
||||
https://api.siliconflow.cn/v1/chat/completions
|
||||
```
|
||||
|
||||
Request Hook
|
||||
|
||||
```js
|
||||
async (args) => {
|
||||
const url = args.url;
|
||||
const method = "POST";
|
||||
const headers = {
|
||||
"Content-type": "application/json",
|
||||
Authorization: `Bearer ${args.key}`,
|
||||
};
|
||||
const body = {
|
||||
model: "tencent/Hunyuan-MT-7B", // v2.0.2 版后此处可填 args.model
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"You are a professional, authentic machine translation engine.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `Translate the following source text to ${args.to}. Output translation directly without any additional text.\n\nSource Text: ${args.texts[0]}\n\nTranslated Text:`,
|
||||
},
|
||||
],
|
||||
temperature: 0,
|
||||
max_tokens: 20480,
|
||||
};
|
||||
|
||||
return { url, body, headers, method };
|
||||
};
|
||||
```
|
||||
|
||||
Response Hook
|
||||
|
||||
```js
|
||||
async ({ res }) => {
|
||||
return { translations: [[res?.choices?.[0]?.message?.content || ""]] };
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
## 语言代码表及说明
|
||||
|
||||
Hook参数里面的语言含义说明:
|
||||
|
||||
- `toLang`, `fromLang` 是本插件支持的标准语言代码
|
||||
- `to`, `from` 是转换后的适用于特定接口的语言代码
|
||||
|
||||
如果你的自定义接口与下面的标准语言代码不匹配,需要自行映射转换。
|
||||
|
||||
```
|
||||
["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"],
|
||||
```
|
||||
48
package.json
48
package.json
@@ -1,33 +1,44 @@
|
||||
{
|
||||
"name": "kiss-translator",
|
||||
"description": "A minimalist bilingual translation Extension & Greasemonkey Script",
|
||||
"version": "1.5.4",
|
||||
"version": "2.0.5",
|
||||
"author": "Gabe<yugang2002@gmail.com>",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/cache": "^11.11.0",
|
||||
"@emotion/css": "^11.13.5",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.10.8",
|
||||
"@mui/icons-material": "^5.11.11",
|
||||
"@mui/material": "^5.11.12",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.15",
|
||||
"@mui/lab": "5.0.0-alpha.170",
|
||||
"@mui/material": "^5.15.15",
|
||||
"query-string": "^8.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-router-dom": "^6.10.0",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"sval": "^0.5.2",
|
||||
"webdav": "^5.3.0",
|
||||
"webextension-polyfill": "^0.10.0"
|
||||
},
|
||||
"scripts": {
|
||||
"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",
|
||||
"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",
|
||||
"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": "rm -rf build/userscript && mkdir build/userscript && cp build/web/kiss-translator.user.js build/userscript/kiss-translator.user.js",
|
||||
"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": "yarn build:chrome && yarn build:edge && yarn build:firefox && yarn build:web && yarn build:userscript && yarn build:rules",
|
||||
"deploy:web": "wrangler pages deploy ./build/web --project-name kiss-translator",
|
||||
"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"
|
||||
},
|
||||
@@ -38,7 +49,11 @@
|
||||
],
|
||||
"globals": {
|
||||
"GM": true,
|
||||
"unsafeWindow": true
|
||||
"unsafeWindow": true,
|
||||
"globalThis": true,
|
||||
"messenger": true,
|
||||
"LanguageDetector": true,
|
||||
"Translator": true
|
||||
}
|
||||
},
|
||||
"browserslist": {
|
||||
@@ -54,11 +69,14 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.22.10",
|
||||
"@babel/node": "^7.22.10",
|
||||
"@babel/core": "^7.22.20",
|
||||
"@babel/node": "^7.22.19",
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@babel/preset-env": "^7.22.10",
|
||||
"@babel/preset-env": "^7.22.20",
|
||||
"dotenv": "^17.2.1",
|
||||
"find-up": "^7.0.0",
|
||||
"prettier": "3.6.2",
|
||||
"react-app-rewired": "^2.2.1",
|
||||
"wrangler": "^3.4.0"
|
||||
"zx": "^8.8.1"
|
||||
}
|
||||
}
|
||||
|
||||
13177
pnpm-lock.yaml
generated
Normal file
13177
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,12 +3,18 @@
|
||||
"message": "KISS Translator"
|
||||
},
|
||||
"app_description": {
|
||||
"message": "A minimalist bilingual translation Extension & Greasemonkey Script"
|
||||
"message": "A simple bilingual translation extension & Greasemonkey script"
|
||||
},
|
||||
"toggle_translate": {
|
||||
"message": "Toggle Translate"
|
||||
},
|
||||
"toggle_style": {
|
||||
"message": "Toggle Style"
|
||||
},
|
||||
"open_options": {
|
||||
"message": "Open Options"
|
||||
},
|
||||
"open_tranbox": {
|
||||
"message": "Translate Popup/Selected"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,18 @@
|
||||
"message": "简约翻译"
|
||||
},
|
||||
"app_description": {
|
||||
"message": "一个简约的双语网页翻译扩展 & 油猴脚本"
|
||||
"message": "一个简约的双语对照翻译扩展 & 油猴脚本"
|
||||
},
|
||||
"toggle_translate": {
|
||||
"message": "切换翻译"
|
||||
"message": "开启翻译"
|
||||
},
|
||||
"toggle_style": {
|
||||
"message": "切换样式"
|
||||
},
|
||||
"open_options": {
|
||||
"message": "打开设置"
|
||||
},
|
||||
"open_tranbox": {
|
||||
"message": "翻译弹窗/选中文字"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@@ -15,12 +15,56 @@
|
||||
max-height: 1.2em;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// (() => {
|
||||
// var shadow = document.querySelector("#shadow1");
|
||||
// var root = shadow.attachShadow({ mode: "open" });
|
||||
// var newLine = document.createElement("p");
|
||||
// newLine.innerText = "new line";
|
||||
// root.appendChild(newLine);
|
||||
// })();
|
||||
|
||||
// setTimeout(function () {
|
||||
// var shadow = document.querySelector("#shadow2");
|
||||
// var root = shadow.attachShadow({ mode: "open" });
|
||||
// }, 1000);
|
||||
|
||||
// setTimeout(() => {
|
||||
// var newLine = document.createElement("p");
|
||||
// newLine.innerText = "new line";
|
||||
// var shadow = document.querySelector("#shadow2");
|
||||
// shadow.shadowRoot.appendChild(newLine);
|
||||
// }, 2000);
|
||||
|
||||
// setTimeout(() => {
|
||||
// var newLine = document.createElement("div");
|
||||
// newLine.innerHTML = "<p>second line</p><p>third line</p>";
|
||||
// var shadow = document.querySelector("#shadow2");
|
||||
// shadow.shadowRoot.appendChild(newLine);
|
||||
// }, 3000);
|
||||
|
||||
// setTimeout(function () {
|
||||
// var el = document.querySelector("h2");
|
||||
// el.innerText = "hello world";
|
||||
|
||||
// var title = document.querySelector("#addtitle");
|
||||
// title.innerHTML =
|
||||
// "<div><p>second title</p><ul><li>second title</li><li><p>second title</p></li></ul></div>";
|
||||
// }, 1000);
|
||||
|
||||
setTimeout(function () {
|
||||
var el = document.querySelector('h2>p>span');
|
||||
el.innerText = 'hello world';
|
||||
}, 1000);
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root">
|
||||
<h2>React is a JavaScript library for building user interfaces.</h2>
|
||||
<p>You need to enable <code>JavaScript</code> to run <span>this app.</span></p>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
@@ -53,13 +97,40 @@
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<h2>React is a JavaScript library for building user interfaces.</h2>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<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.
|
||||
<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.
|
||||
<br />
|
||||
</div>
|
||||
<h2>
|
||||
<p>
|
||||
<span>React is a JavaScript library for building user interfaces.</span>
|
||||
</p>
|
||||
</h2>
|
||||
<hr />
|
||||
<input id="input1" style="width: 80%" />
|
||||
<hr />
|
||||
<textarea id="textarea1" style="width: 80%">test</textarea>
|
||||
<hr />
|
||||
<div id="addtitle"></div>
|
||||
<h2>Shadow 1</h2>
|
||||
<div id="shadow1"></div>
|
||||
<h2>Shadow 2</h2>
|
||||
<div id="shadow2"></div>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
@@ -86,13 +157,21 @@
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<h2>React is a JavaScript library for building user interfaces.</h2>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<h2>
|
||||
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>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
@@ -119,7 +198,50 @@
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<h2>React is a JavaScript library for building user interfaces.</h2>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<h2>We’ve first shared our research on RSC in an introductory talk and an RFC.</h2>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<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>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
@@ -153,175 +275,41 @@
|
||||
<br />
|
||||
<br />
|
||||
<div class="cont cont1">
|
||||
<h2>React is a JavaScript library for building user interfaces.</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Declarative: React makes it painless to create interactive UIs.
|
||||
Design simple views for each state in your application, and React
|
||||
will efficiently update and render just the right components when
|
||||
your data changes. Declarative views make your code more
|
||||
predictable, simpler to understand, and easier to debug.
|
||||
</li>
|
||||
<li>
|
||||
Component-Based: Build encapsulated components that manage their own
|
||||
state, then compose them to make complex UIs. Since component logic
|
||||
is written in JavaScript instead of templates, you can easily pass
|
||||
rich data through your app and keep the state out of the DOM.
|
||||
</li>
|
||||
<li>
|
||||
React 使创建交互式 UI
|
||||
变得轻而易举。为你应用的每一个状态设计简洁的视图,当数据变动时 React
|
||||
能高效更新并渲染合适的组件。
|
||||
</li>
|
||||
<li>以声明式编写 UI,可以让你的代码更加可靠,且方便调试。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<div class="cont cont2">
|
||||
<h2>React is a JavaScript library for building user interfaces.</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Declarative: React makes it painless to create interactive UIs.
|
||||
Design simple views for each state in your application, and React
|
||||
will efficiently update and render just the right components when
|
||||
your data changes. Declarative views make your code more
|
||||
predictable, simpler to understand, and easier to debug.
|
||||
</li>
|
||||
<li>
|
||||
Component-Based: Build encapsulated components that manage their own
|
||||
state, then compose them to make complex UIs. Since component logic
|
||||
is written in JavaScript instead of templates, you can easily pass
|
||||
rich data through your app and keep the state out of the DOM.
|
||||
</li>
|
||||
<li>
|
||||
React 使创建交互式 UI
|
||||
变得轻而易举。为你应用的每一个状态设计简洁的视图,当数据变动时 React
|
||||
能高效更新并渲染合适的组件。
|
||||
</li>
|
||||
<li>以声明式编写 UI,可以让你的代码更加可靠,且方便调试。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<div class="cont cont3">
|
||||
<h2>React is a JavaScript library for building user interfaces.</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Declarative: React makes it painless to create interactive UIs.
|
||||
Design simple views for each state in your application, and React
|
||||
will efficiently update and render just the right components when
|
||||
your data changes. Declarative views make your code more
|
||||
predictable, simpler to understand, and easier to debug.
|
||||
</li>
|
||||
<li>
|
||||
Component-Based: Build encapsulated components that manage their own
|
||||
state, then compose them to make complex UIs. Since component logic
|
||||
is written in JavaScript instead of templates, you can easily pass
|
||||
rich data through your app and keep the state out of the DOM.
|
||||
</li>
|
||||
<li>
|
||||
React 使创建交互式 UI
|
||||
变得轻而易举。为你应用的每一个状态设计简洁的视图,当数据变动时 React
|
||||
能高效更新并渲染合适的组件。
|
||||
</li>
|
||||
<li>以声明式编写 UI,可以让你的代码更加可靠,且方便调试。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<div class="cont cont4">
|
||||
<h2>
|
||||
React is a <code>JavaScript</code> <a href="#">library</a> for
|
||||
building user interfaces.
|
||||
Server Components can run during the build, letting you read from the filesystem
|
||||
or fetch static content.
|
||||
</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Declarative: React makes it painless to create interactive UIs.
|
||||
Design simple views for each state in your application, and React
|
||||
will efficiently update and render just the right components when
|
||||
your data changes. Declarative views make your code more
|
||||
predictable, simpler to understand, and easier to debug.
|
||||
</li>
|
||||
<li>
|
||||
Component-Based: Build encapsulated components that manage their own
|
||||
state, then compose them to make complex UIs. Since component logic
|
||||
is written in JavaScript instead of templates, you can easily pass
|
||||
rich data through your app and keep the state out of the DOM.
|
||||
</li>
|
||||
<li>
|
||||
React 使创建交互式 UI
|
||||
变得轻而易举。为你应用的每一个状态设计简洁的视图,当数据变动时 React
|
||||
能高效更新并渲染合适的组件。
|
||||
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>
|
||||
</div>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<div class="cont cont5">
|
||||
<h2>React is a JavaScript library for building user interfaces.</h2>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<div class="cont cont2">
|
||||
<h2>
|
||||
Since our last update, we have merged the React Server Components RFC to ratify
|
||||
the proposal.
|
||||
</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Declarative: React makes it painless to create interactive UIs.
|
||||
Design simple views for each state in your application, and React
|
||||
will efficiently update and render just the right components when
|
||||
your data changes. Declarative views make your code more
|
||||
predictable, simpler to understand, and easier to debug.
|
||||
</li>
|
||||
<li>
|
||||
Component-Based: Build encapsulated components that manage their own
|
||||
state, then compose them to make complex UIs. Since component logic
|
||||
is written in JavaScript instead of templates, you can easily pass
|
||||
rich data through your app and keep the state out of the DOM.
|
||||
RSC combines the simple “request/response” mental model of server-centric
|
||||
Multi-Page Apps with the seamless interactivity of client-centric Single-Page
|
||||
Apps, giving you the best of both worlds.
|
||||
</li>
|
||||
<li>
|
||||
React 使创建交互式 UI
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 2,
|
||||
"name": "__MSG_app_name__",
|
||||
"description": "__MSG_app_description__",
|
||||
"version": "1.5.4",
|
||||
"version": "2.0.5",
|
||||
"default_locale": "en",
|
||||
"author": "Gabe<yugang2002@gmail.com>",
|
||||
"homepage_url": "https://github.com/fishjar/kiss-translator",
|
||||
@@ -12,24 +12,49 @@
|
||||
"content_scripts": [
|
||||
{
|
||||
"js": ["content.js"],
|
||||
"matches": ["<all_urls>"]
|
||||
"matches": ["<all_urls>", "file://*/*"],
|
||||
"all_frames": true
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
"injector-subtitle.js",
|
||||
"injector-shadowroot.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"],
|
||||
"permissions": [
|
||||
"<all_urls>",
|
||||
"storage",
|
||||
"contextMenus",
|
||||
"scripting",
|
||||
"declarativeNetRequest"
|
||||
],
|
||||
"icons": {
|
||||
"16": "images/logo16.png",
|
||||
"32": "images/logo32.png",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"name": "__MSG_app_name__",
|
||||
"description": "__MSG_app_description__",
|
||||
"version": "1.5.4",
|
||||
"version": "2.0.5",
|
||||
"default_locale": "en",
|
||||
"author": "Gabe<yugang2002@gmail.com>",
|
||||
"homepage_url": "https://github.com/fishjar/kiss-translator",
|
||||
@@ -13,24 +13,49 @@
|
||||
"content_scripts": [
|
||||
{
|
||||
"js": ["content.js"],
|
||||
"matches": ["<all_urls>", "file://*/*"],
|
||||
"all_frames": true
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["injector-subtitle.js"],
|
||||
"matches": ["https://www.youtube.com/*"]
|
||||
},
|
||||
{
|
||||
"resources": ["injector-shadowroot.js"],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
"commands": {
|
||||
"_execute_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": ["storage"],
|
||||
"permissions": ["storage", "contextMenus", "scripting", "declarativeNetRequest", "declarativeNetRequestWithHostAccess"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"icons": {
|
||||
"16": "images/logo16.png",
|
||||
|
||||
82
public/manifest.thunderbird.json
Normal file
82
public/manifest.thunderbird.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "__MSG_app_name__",
|
||||
"description": "__MSG_app_description__",
|
||||
"version": "2.0.5",
|
||||
"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-subtitle.js",
|
||||
"injector-shadowroot.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
|
||||
}
|
||||
}
|
||||
19
src/apis/baidu.js
Normal file
19
src/apis/baidu.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { DEFAULT_USER_AGENT } from "../config";
|
||||
|
||||
export const genBaidu = ({ texts, from, to }) => {
|
||||
const body = {
|
||||
from,
|
||||
to,
|
||||
query: texts.join(" "),
|
||||
source: "txt",
|
||||
};
|
||||
|
||||
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, body, headers };
|
||||
};
|
||||
50
src/apis/deepl.js
Normal file
50
src/apis/deepl.js
Normal file
@@ -0,0 +1,50 @@
|
||||
let id = 1e4 * Math.round(1e4 * Math.random());
|
||||
|
||||
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++;
|
||||
|
||||
const url = "https://www2.deepl.com/jsonrpc";
|
||||
|
||||
const body = {
|
||||
jsonrpc: "2.0",
|
||||
method: "LMT_handle_texts",
|
||||
params: {
|
||||
splitting: "newlines",
|
||||
lang: {
|
||||
target_lang: to,
|
||||
source_lang_user_selected: from,
|
||||
},
|
||||
commonJobParams: {
|
||||
wasSpoken: false,
|
||||
transcribe_as: "",
|
||||
},
|
||||
id,
|
||||
timestamp,
|
||||
texts: [
|
||||
{
|
||||
text,
|
||||
requestAlternatives: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
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
39
src/apis/history.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { DEFAULT_CONTEXT_SIZE } from "../config";
|
||||
|
||||
const historyMap = new Map();
|
||||
|
||||
const MsgHistory = (maxSize = DEFAULT_CONTEXT_SIZE) => {
|
||||
const messages = [];
|
||||
|
||||
const add = (...msgs) => {
|
||||
messages.push(...msgs.filter(Boolean));
|
||||
const extra = messages.length - maxSize;
|
||||
if (extra > 0) {
|
||||
messages.splice(0, extra);
|
||||
}
|
||||
};
|
||||
|
||||
const getAll = () => {
|
||||
return [...messages];
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
messages.length = 0;
|
||||
};
|
||||
|
||||
return {
|
||||
add,
|
||||
getAll,
|
||||
clear,
|
||||
};
|
||||
};
|
||||
|
||||
export const getMsgHistory = (apiSlug, maxSize) => {
|
||||
if (historyMap.has(apiSlug)) {
|
||||
return historyMap.get(apiSlug);
|
||||
}
|
||||
|
||||
const msgHistory = MsgHistory(maxSize);
|
||||
historyMap.set(apiSlug, msgHistory);
|
||||
return msgHistory;
|
||||
};
|
||||
@@ -1,17 +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_OPENAI,
|
||||
URL_MICROSOFT_TRANS,
|
||||
OPT_LANGS_SPECIAL,
|
||||
PROMPT_PLACE_FROM,
|
||||
PROMPT_PLACE_TO,
|
||||
URL_CACHE_TRAN,
|
||||
URL_CACHE_DELANG,
|
||||
URL_CACHE_BINGDICT,
|
||||
KV_SALT_SYNC,
|
||||
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,
|
||||
OPT_LANGS_TO_CODE,
|
||||
} from "../config";
|
||||
import { getSetting, detectLang } from "../libs";
|
||||
import { sha256 } from "../libs/utils";
|
||||
import { sha256, withTimeout } from "../libs/utils";
|
||||
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";
|
||||
|
||||
/**
|
||||
* 同步数据
|
||||
@@ -20,110 +36,376 @@ import { sha256 } from "../libs/utils";
|
||||
* @param {*} data
|
||||
* @returns
|
||||
*/
|
||||
export const apiSyncData = async (url, key, data, isBg = false) =>
|
||||
fetchPolyfill(url, {
|
||||
export const apiSyncData = async (url, key, data) =>
|
||||
fetchData(url, {
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
Authorization: `Bearer ${await sha256(key, KV_SALT_SYNC)}`,
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
isBg,
|
||||
});
|
||||
|
||||
/**
|
||||
* 谷歌翻译
|
||||
* @param {*} text
|
||||
* @param {*} to
|
||||
* @param {*} from
|
||||
* 下载数据
|
||||
* @param {*} url
|
||||
* @returns
|
||||
*/
|
||||
const apiGoogleTranslate = async (translator, text, to, from) => {
|
||||
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: from,
|
||||
tl: to,
|
||||
sl: "auto",
|
||||
tl: "zh-CN",
|
||||
q: text,
|
||||
};
|
||||
const { googleUrl } = await getSetting();
|
||||
const input = `${googleUrl}?${queryString.stringify(params)}`;
|
||||
return fetchPolyfill(input, {
|
||||
const input = `https://translate.googleapis.com/translate_a/single?${queryString.stringify(params)}`;
|
||||
const init = {
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
},
|
||||
useCache: true,
|
||||
usePool: true,
|
||||
translator,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 微软翻译
|
||||
* @param {*} text
|
||||
* @param {*} to
|
||||
* @param {*} from
|
||||
* @returns
|
||||
*/
|
||||
const apiMicrosoftTranslate = (translator, text, to, from) => {
|
||||
const params = {
|
||||
from,
|
||||
to,
|
||||
"api-version": "3.0",
|
||||
};
|
||||
const input = `${URL_MICROSOFT_TRANS}?${queryString.stringify(params)}`;
|
||||
return fetchPolyfill(input, {
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify([{ Text: text }]),
|
||||
useCache: true,
|
||||
usePool: true,
|
||||
translator,
|
||||
});
|
||||
const res = await fetchData(input, init, { useCache: true });
|
||||
|
||||
if (res?.src) {
|
||||
await putHttpCachePolyfill(input, init, res);
|
||||
return res.src;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenAI 翻译
|
||||
* Microsoft语言识别
|
||||
* @param {*} text
|
||||
* @param {*} to
|
||||
* @param {*} from
|
||||
* @returns
|
||||
*/
|
||||
const apiOpenaiTranslate = async (translator, text, to, from) => {
|
||||
const { openaiUrl, openaiKey, openaiModel, openaiPrompt } =
|
||||
await getSetting();
|
||||
let prompt = openaiPrompt
|
||||
.replaceAll(PROMPT_PLACE_FROM, from)
|
||||
.replaceAll(PROMPT_PLACE_TO, to);
|
||||
return fetchPolyfill(openaiUrl, {
|
||||
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: 200,
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 百度语言识别
|
||||
* @param {*} text
|
||||
* @returns
|
||||
*/
|
||||
export const apiBaiduLangdetect = async (text) => {
|
||||
const input = "https://fanyi.baidu.com/langdetect";
|
||||
const init = {
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
model: openaiModel,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: prompt,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: text,
|
||||
},
|
||||
],
|
||||
temperature: 0,
|
||||
max_tokens: 256,
|
||||
query: text,
|
||||
}),
|
||||
useCache: true,
|
||||
usePool: true,
|
||||
translator,
|
||||
token: openaiKey,
|
||||
};
|
||||
const res = await fetchData(input, init, { useCache: true });
|
||||
|
||||
if (res?.error === 0) {
|
||||
await putHttpCachePolyfill(input, init, res);
|
||||
return res.lan;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* 百度翻译建议
|
||||
* @param {*} text
|
||||
* @returns
|
||||
*/
|
||||
export const apiBaiduSuggest = async (text) => {
|
||||
const input = "https://fanyi.baidu.com/sug";
|
||||
const init = {
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
kw: text,
|
||||
}),
|
||||
};
|
||||
const res = await fetchData(input, init, { useCache: true });
|
||||
|
||||
if (res?.errno === 0) {
|
||||
await putHttpCachePolyfill(input, init, res);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 有道翻译建议
|
||||
* @param {*} text
|
||||
* @returns
|
||||
*/
|
||||
export const apiYoudaoSuggest = async (text) => {
|
||||
const params = {
|
||||
num: 5,
|
||||
ver: 3.0,
|
||||
doctype: "json",
|
||||
cache: false,
|
||||
le: "en",
|
||||
q: text,
|
||||
};
|
||||
const input = `https://dict.youdao.com/suggest?${queryString.stringify(params)}`;
|
||||
const init = {
|
||||
headers: {
|
||||
accept: "application/json, text/plain, */*",
|
||||
"accept-language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,ja;q=0.6",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method: "GET",
|
||||
};
|
||||
const res = await fetchData(input, init, { useCache: true });
|
||||
|
||||
if (res?.result?.code === 200) {
|
||||
await putHttpCachePolyfill(input, init, res);
|
||||
return res.data.entries;
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 有道词典
|
||||
* @param {*} text
|
||||
* @returns
|
||||
*/
|
||||
export const apiYoudaoDict = async (text) => {
|
||||
const params = {
|
||||
doctype: "json",
|
||||
jsonversion: 4,
|
||||
};
|
||||
const input = `https://dict.youdao.com/jsonapi_s?${queryString.stringify(params)}`;
|
||||
const body = queryString.stringify({
|
||||
q: text,
|
||||
le: "en",
|
||||
t: 3,
|
||||
client: "web",
|
||||
// sign: "",
|
||||
keyfrom: "webdict",
|
||||
});
|
||||
const init = {
|
||||
headers: {
|
||||
accept: "application/json, text/plain, */*",
|
||||
"accept-language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,ja;q=0.6",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method: "POST",
|
||||
body,
|
||||
};
|
||||
const res = await fetchData(input, init, { useCache: true });
|
||||
|
||||
if (res) {
|
||||
await putHttpCachePolyfill(input, init, res);
|
||||
return res;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 百度语音
|
||||
* @param {*} text
|
||||
* @param {*} lan
|
||||
* @param {*} spd
|
||||
* @returns
|
||||
*/
|
||||
export const apiBaiduTTS = (text, lan = "uk", spd = 3) => {
|
||||
const input = `https://fanyi.baidu.com/gettts?${queryString.stringify({ lan, text, spd })}`;
|
||||
return fetchData(input);
|
||||
};
|
||||
|
||||
/**
|
||||
* 腾讯语言识别
|
||||
* @param {*} text
|
||||
* @returns
|
||||
*/
|
||||
export const apiTencentLangdetect = async (text) => {
|
||||
const input = "https://transmart.qq.com/api/imt";
|
||||
const body = JSON.stringify({
|
||||
header: {
|
||||
fn: "text_analysis",
|
||||
client_key:
|
||||
"browser-chrome-110.0.0-Mac OS-df4bd4c5-a65d-44b2-a40f-42f34f3535f2-1677486696487",
|
||||
},
|
||||
text,
|
||||
});
|
||||
const 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,
|
||||
};
|
||||
const res = await fetchData(input, init, { useCache: true });
|
||||
|
||||
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];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -131,26 +413,147 @@ const apiOpenaiTranslate = async (translator, text, to, from) => {
|
||||
* @param {*} param0
|
||||
* @returns
|
||||
*/
|
||||
export const apiTranslate = async ({ translator, q, fromLang, toLang }) => {
|
||||
let trText = "";
|
||||
let isSame = false;
|
||||
|
||||
let from = OPT_LANGS_SPECIAL?.[translator]?.get(fromLang) ?? fromLang;
|
||||
let to = OPT_LANGS_SPECIAL?.[translator]?.get(toLang) ?? toLang;
|
||||
|
||||
if (translator === OPT_TRANS_GOOGLE) {
|
||||
const res = await apiGoogleTranslate(translator, q, to, from);
|
||||
trText = res.sentences.map((item) => item.trans).join(" ");
|
||||
isSame = to === res.src;
|
||||
} else if (translator === OPT_TRANS_MICROSOFT) {
|
||||
const res = await apiMicrosoftTranslate(translator, q, to, from);
|
||||
trText = res[0].translations[0].text;
|
||||
isSame = to === res[0].detectedLanguage.language;
|
||||
} else if (translator === OPT_TRANS_OPENAI) {
|
||||
const res = await apiOpenaiTranslate(translator, q, to, from);
|
||||
trText = res?.choices?.[0].message.content;
|
||||
isSame = (await detectLang(q)) === (await detectLang(trText));
|
||||
export const apiTranslate = async ({
|
||||
text,
|
||||
fromLang = "auto",
|
||||
toLang,
|
||||
apiSetting = DEFAULT_API_SETTING,
|
||||
docInfo = {},
|
||||
glossary = {},
|
||||
useCache = true,
|
||||
usePool = true,
|
||||
}) => {
|
||||
if (!text) {
|
||||
throw new Error("The text cannot be empty.");
|
||||
}
|
||||
|
||||
return [trText, isSame];
|
||||
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) {
|
||||
throw new Error(`The target lang: ${toLang} not support`);
|
||||
}
|
||||
|
||||
// todo: 优化缓存失效因素
|
||||
const [v1, v2] = process.env.REACT_APP_VERSION.split(".");
|
||||
const cacheOpts = {
|
||||
apiSlug,
|
||||
text,
|
||||
fromLang,
|
||||
toLang,
|
||||
version: [v1, v2].join("."),
|
||||
};
|
||||
const cacheInput = `${URL_CACHE_TRAN}?${queryString.stringify(cacheOpts)}`;
|
||||
|
||||
// 查询缓存数据
|
||||
if (useCache) {
|
||||
const cache = await getHttpCachePolyfill(cacheInput);
|
||||
if (cache?.trText) {
|
||||
return cache;
|
||||
}
|
||||
}
|
||||
|
||||
// 请求接口数据
|
||||
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 = "";
|
||||
let srCode = "";
|
||||
if (Array.isArray(tranlation)) {
|
||||
[trText, srLang = ""] = tranlation;
|
||||
if (srLang) {
|
||||
srCode = OPT_LANGS_TO_CODE[apiType].get(srLang) || "";
|
||||
}
|
||||
} 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, srCode });
|
||||
}
|
||||
|
||||
return { trText, srLang, srCode, 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 [];
|
||||
};
|
||||
|
||||
1030
src/apis/trans.js
Normal file
1030
src/apis/trans.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,93 +1,296 @@
|
||||
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,
|
||||
MSG_TRANS_TOGGLE_STYLE,
|
||||
MSG_OPEN_TRANBOX,
|
||||
MSG_CONTEXT_MENUS,
|
||||
MSG_COMMAND_SHORTCUTS,
|
||||
MSG_INJECT_JS,
|
||||
MSG_INJECT_CSS,
|
||||
MSG_UPDATE_CSP,
|
||||
MSG_BUILTINAI_DETECT,
|
||||
MSG_BUILTINAI_TRANSLATE,
|
||||
DEFAULT_CSPLIST,
|
||||
DEFAULT_ORILIST,
|
||||
CMD_TOGGLE_TRANSLATE,
|
||||
CMD_TOGGLE_STYLE,
|
||||
DEFAULT_SETTING,
|
||||
DEFAULT_RULES,
|
||||
DEFAULT_SYNC,
|
||||
STOKEY_SETTING,
|
||||
STOKEY_RULES,
|
||||
STOKEY_SYNC,
|
||||
CACHE_NAME,
|
||||
STOKEY_RULESCACHE_PREFIX,
|
||||
BUILTIN_RULES,
|
||||
CMD_OPEN_OPTIONS,
|
||||
CMD_OPEN_TRANBOX,
|
||||
CLIENT_THUNDERBIRD,
|
||||
MSG_SET_LOGLEVEL,
|
||||
MSG_CLEAR_CACHES,
|
||||
} from "./config";
|
||||
import storage from "./libs/storage";
|
||||
import { getSetting } from "./libs";
|
||||
import { syncAll } from "./libs/sync";
|
||||
import { fetchData, fetchPool } from "./libs/fetch";
|
||||
import { getSettingWithDefault, tryInitDefaultData } from "./libs/storage";
|
||||
import { trySyncSettingAndRules } from "./libs/sync";
|
||||
import { fetchHandle } from "./libs/fetch";
|
||||
import { tryClearCaches, getHttpCache, putHttpCache } from "./libs/cache";
|
||||
import { sendTabMsg } from "./libs/msg";
|
||||
import { trySyncAllSubRules } from "./libs/rules";
|
||||
import { trySyncAllSubRules } from "./libs/subRules";
|
||||
import { saveRule } from "./libs/rules";
|
||||
import { getCurTabId } from "./libs/msg";
|
||||
import { injectInlineJsBg, injectInternalCss } from "./libs/injector";
|
||||
import { kissLog, logger } from "./libs/log";
|
||||
import { chromeDetect, chromeTranslate } from "./libs/builtinAI";
|
||||
|
||||
globalThis.ContextType = "BACKGROUND";
|
||||
|
||||
const CSP_RULE_START_ID = 1;
|
||||
const ORI_RULE_START_ID = 10000;
|
||||
const CSP_REMOVE_HEADERS = [
|
||||
`content-security-policy`,
|
||||
`content-security-policy-report-only`,
|
||||
`x-webkit-csp`,
|
||||
`x-content-security-policy`,
|
||||
];
|
||||
|
||||
/**
|
||||
* 添加右键菜单
|
||||
*/
|
||||
async function addContextMenus(contextMenuType = 1) {
|
||||
// 添加前先删除,避免重复ID的错误
|
||||
try {
|
||||
await browser.contextMenus.removeAll();
|
||||
} catch (err) {
|
||||
kissLog("remove contextMenus", err);
|
||||
}
|
||||
|
||||
switch (contextMenuType) {
|
||||
case 1:
|
||||
browser.contextMenus.create({
|
||||
id: CMD_TOGGLE_TRANSLATE,
|
||||
title: browser.i18n.getMessage("app_name"),
|
||||
contexts: ["page", "selection"],
|
||||
});
|
||||
break;
|
||||
case 2:
|
||||
browser.contextMenus.create({
|
||||
id: CMD_TOGGLE_TRANSLATE,
|
||||
title: browser.i18n.getMessage("toggle_translate"),
|
||||
contexts: ["page", "selection"],
|
||||
});
|
||||
browser.contextMenus.create({
|
||||
id: CMD_TOGGLE_STYLE,
|
||||
title: browser.i18n.getMessage("toggle_style"),
|
||||
contexts: ["page", "selection"],
|
||||
});
|
||||
browser.contextMenus.create({
|
||||
id: CMD_OPEN_TRANBOX,
|
||||
title: browser.i18n.getMessage("open_tranbox"),
|
||||
contexts: ["page", "selection"],
|
||||
});
|
||||
browser.contextMenus.create({
|
||||
id: "options_separator",
|
||||
type: "separator",
|
||||
contexts: ["page", "selection"],
|
||||
});
|
||||
browser.contextMenus.create({
|
||||
id: CMD_OPEN_OPTIONS,
|
||||
title: browser.i18n.getMessage("open_options"),
|
||||
contexts: ["page", "selection"],
|
||||
});
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新CSP策略
|
||||
* @param {*} csplist
|
||||
*/
|
||||
async function updateCspRules({ csplist, orilist }) {
|
||||
try {
|
||||
const oldRules = await browser.declarativeNetRequest.getDynamicRules();
|
||||
|
||||
const rulesToAdd = [];
|
||||
const idsToRemove = [];
|
||||
|
||||
if (csplist !== undefined) {
|
||||
let processedCspList = csplist;
|
||||
if (typeof processedCspList === "string") {
|
||||
processedCspList = processedCspList
|
||||
.split(/\n|,/)
|
||||
.map((url) => url.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const oldCspRuleIds = oldRules
|
||||
.filter(
|
||||
(rule) => rule.id >= CSP_RULE_START_ID && rule.id < ORI_RULE_START_ID
|
||||
)
|
||||
.map((rule) => rule.id);
|
||||
idsToRemove.push(...oldCspRuleIds);
|
||||
|
||||
const newCspRules = processedCspList.map((url, index) => ({
|
||||
id: CSP_RULE_START_ID + index,
|
||||
action: {
|
||||
type: "modifyHeaders",
|
||||
responseHeaders: CSP_REMOVE_HEADERS.map((header) => ({
|
||||
operation: "remove",
|
||||
header,
|
||||
})),
|
||||
},
|
||||
condition: {
|
||||
urlFilter: url,
|
||||
resourceTypes: ["main_frame", "sub_frame"],
|
||||
},
|
||||
}));
|
||||
rulesToAdd.push(...newCspRules);
|
||||
}
|
||||
|
||||
if (orilist !== undefined) {
|
||||
let processedOriList = orilist;
|
||||
if (typeof processedOriList === "string") {
|
||||
processedOriList = processedOriList
|
||||
.split(/\n|,/)
|
||||
.map((url) => url.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const oldOriRuleIds = oldRules
|
||||
.filter((rule) => rule.id >= ORI_RULE_START_ID)
|
||||
.map((rule) => rule.id);
|
||||
idsToRemove.push(...oldOriRuleIds);
|
||||
|
||||
const newOriRules = processedOriList.map((url, index) => ({
|
||||
id: ORI_RULE_START_ID + index,
|
||||
action: {
|
||||
type: "modifyHeaders",
|
||||
requestHeaders: [{ header: "Origin", operation: "set", value: url }],
|
||||
},
|
||||
condition: {
|
||||
urlFilter: url,
|
||||
resourceTypes: ["xmlhttprequest"],
|
||||
},
|
||||
}));
|
||||
rulesToAdd.push(...newOriRules);
|
||||
}
|
||||
|
||||
if (idsToRemove.length > 0 || rulesToAdd.length > 0) {
|
||||
await browser.declarativeNetRequest.updateDynamicRules({
|
||||
removeRuleIds: idsToRemove,
|
||||
addRules: rulesToAdd,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
kissLog("update csp rules", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册邮件显示脚本
|
||||
*/
|
||||
async function registerMsgDisplayScript() {
|
||||
await messenger.messageDisplayScripts.register({
|
||||
js: [{ file: "/content.js" }],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件安装
|
||||
*/
|
||||
browser.runtime.onInstalled.addListener(() => {
|
||||
console.log("KISS Translator onInstalled");
|
||||
storage.trySetObj(STOKEY_SETTING, DEFAULT_SETTING);
|
||||
storage.trySetObj(STOKEY_RULES, DEFAULT_RULES);
|
||||
storage.trySetObj(STOKEY_SYNC, DEFAULT_SYNC);
|
||||
storage.trySetObj(
|
||||
`${STOKEY_RULESCACHE_PREFIX}${process.env.REACT_APP_RULESURL}`,
|
||||
BUILTIN_RULES
|
||||
);
|
||||
tryInitDefaultData();
|
||||
|
||||
//在thunderbird中注册脚本
|
||||
if (process.env.REACT_APP_CLIENT === CLIENT_THUNDERBIRD) {
|
||||
registerMsgDisplayScript();
|
||||
}
|
||||
|
||||
// 右键菜单
|
||||
addContextMenus();
|
||||
|
||||
// 禁用CSP
|
||||
updateCspRules({ csplist: DEFAULT_CSPLIST, orilist: DEFAULT_ORILIST });
|
||||
});
|
||||
|
||||
/**
|
||||
* 浏览器启动
|
||||
*/
|
||||
browser.runtime.onStartup.addListener(async () => {
|
||||
console.log("browser onStartup");
|
||||
const {
|
||||
clearCache,
|
||||
contextMenuType,
|
||||
subrulesList,
|
||||
csplist,
|
||||
orilist,
|
||||
logLevel,
|
||||
} = await getSettingWithDefault();
|
||||
|
||||
// 同步数据
|
||||
await syncAll(true);
|
||||
// 设置日志
|
||||
logger.setLevel(logLevel);
|
||||
|
||||
// 清除缓存
|
||||
const setting = await getSetting();
|
||||
if (setting.clearCache) {
|
||||
caches.delete(CACHE_NAME);
|
||||
if (clearCache) {
|
||||
tryClearCaches();
|
||||
}
|
||||
|
||||
//在thunderbird中注册脚本
|
||||
if (process.env.REACT_APP_CLIENT === CLIENT_THUNDERBIRD) {
|
||||
registerMsgDisplayScript();
|
||||
}
|
||||
|
||||
// 右键菜单
|
||||
// firefox重启后菜单会消失,故重复添加
|
||||
addContextMenus(contextMenuType);
|
||||
|
||||
// 禁用CSP
|
||||
updateCspRules({ csplist, orilist });
|
||||
|
||||
// 同步数据
|
||||
trySyncSettingAndRules();
|
||||
|
||||
// 同步订阅规则
|
||||
trySyncAllSubRules(setting, true);
|
||||
trySyncAllSubRules({ subrulesList });
|
||||
});
|
||||
|
||||
/**
|
||||
* 监听消息
|
||||
* 向当前活动标签页注入脚本或CSS
|
||||
*/
|
||||
browser.runtime.onMessage.addListener(
|
||||
({ action, args }, sender, sendResponse) => {
|
||||
switch (action) {
|
||||
case MSG_FETCH:
|
||||
const { input, opts } = args;
|
||||
fetchData(input, opts)
|
||||
.then((data) => {
|
||||
sendResponse({ data });
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({ error: error.message });
|
||||
});
|
||||
break;
|
||||
case MSG_FETCH_LIMIT:
|
||||
const { interval, limit } = args;
|
||||
fetchPool.update(interval, limit);
|
||||
sendResponse({ data: "ok" });
|
||||
break;
|
||||
case MSG_FETCH_CLEAR:
|
||||
fetchPool.clear();
|
||||
sendResponse({ data: "ok" });
|
||||
break;
|
||||
default:
|
||||
sendResponse({ error: `message action is unavailable: ${action}` });
|
||||
}
|
||||
return true;
|
||||
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(injectInlineJsBg, args),
|
||||
[MSG_INJECT_CSS]: (args) => injectToCurrentTab(injectInternalCss, args),
|
||||
[MSG_UPDATE_CSP]: (args) => updateCspRules(args),
|
||||
[MSG_CONTEXT_MENUS]: (args) => addContextMenus(args),
|
||||
[MSG_COMMAND_SHORTCUTS]: () => browser.commands.getAll(),
|
||||
[MSG_BUILTINAI_DETECT]: (args) => chromeDetect(args),
|
||||
[MSG_BUILTINAI_TRANSLATE]: (args) => chromeTranslate(args),
|
||||
[MSG_SET_LOGLEVEL]: (args) => logger.setLevel(args),
|
||||
[MSG_CLEAR_CACHES]: () => tryClearCaches(),
|
||||
};
|
||||
|
||||
/**
|
||||
* 监听消息
|
||||
* todo: 返回含错误的结构化信息
|
||||
*/
|
||||
browser.runtime.onMessage.addListener(async ({ action, args }) => {
|
||||
const handler = messageHandlers[action];
|
||||
if (!handler) {
|
||||
throw new Error(`Message action is unavailable: ${action}`);
|
||||
}
|
||||
);
|
||||
|
||||
return handler(args);
|
||||
});
|
||||
|
||||
/**
|
||||
* 监听快捷键
|
||||
@@ -98,9 +301,36 @@ browser.commands.onCommand.addListener((command) => {
|
||||
case CMD_TOGGLE_TRANSLATE:
|
||||
sendTabMsg(MSG_TRANS_TOGGLE);
|
||||
break;
|
||||
case CMD_OPEN_TRANBOX:
|
||||
sendTabMsg(MSG_OPEN_TRANBOX);
|
||||
break;
|
||||
case CMD_TOGGLE_STYLE:
|
||||
sendTabMsg(MSG_TRANS_TOGGLE_STYLE);
|
||||
break;
|
||||
case CMD_OPEN_OPTIONS:
|
||||
browser.runtime.openOptionsPage();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 监听右键菜单
|
||||
*/
|
||||
browser.contextMenus.onClicked.addListener(({ menuItemId }) => {
|
||||
switch (menuItemId) {
|
||||
case CMD_TOGGLE_TRANSLATE:
|
||||
sendTabMsg(MSG_TRANS_TOGGLE);
|
||||
break;
|
||||
case CMD_TOGGLE_STYLE:
|
||||
sendTabMsg(MSG_TRANS_TOGGLE_STYLE);
|
||||
break;
|
||||
case CMD_OPEN_TRANBOX:
|
||||
sendTabMsg(MSG_OPEN_TRANBOX);
|
||||
break;
|
||||
case CMD_OPEN_OPTIONS:
|
||||
browser.runtime.openOptionsPage();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
});
|
||||
|
||||
172
src/common.js
Normal file
172
src/common.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import { OPT_HIGHLIGHT_WORDS_DISABLE } from "./config";
|
||||
import {
|
||||
getFabWithDefault,
|
||||
getSettingWithDefault,
|
||||
getWordsWithDefault,
|
||||
} from "./libs/storage";
|
||||
import { isIframe } from "./libs/iframe";
|
||||
import { genEventName } from "./libs/utils";
|
||||
import { handlePing, injectScript } from "./libs/gm";
|
||||
import { matchRule } from "./libs/rules";
|
||||
import { trySyncAllSubRules } from "./libs/subRules";
|
||||
import { isInBlacklist } from "./libs/blacklist";
|
||||
import { runSubtitle } from "./subtitle/subtitle";
|
||||
import { logger } from "./libs/log";
|
||||
import { injectInlineJs } from "./libs/injector";
|
||||
import TranslatorManager from "./libs/translatorManager";
|
||||
|
||||
/**
|
||||
* 油猴脚本设置页面
|
||||
*/
|
||||
function runSettingPage() {
|
||||
if (GM?.info?.script?.grant?.includes("unsafeWindow")) {
|
||||
unsafeWindow.GM = GM;
|
||||
unsafeWindow.APP_INFO = {
|
||||
name: process.env.REACT_APP_NAME,
|
||||
version: process.env.REACT_APP_VERSION,
|
||||
};
|
||||
} else {
|
||||
const ping = genEventName();
|
||||
window.addEventListener(ping, handlePing);
|
||||
// window.eval(`(${injectScript})("${ping}")`); // eslint-disable-line
|
||||
injectInlineJs(
|
||||
`(${injectScript})("${ping}")`,
|
||||
"kiss-translator-options-injector"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示错误信息到页面顶部
|
||||
* @param {*} message
|
||||
*/
|
||||
function showErr(message) {
|
||||
const bannerId = "KISS-Translator-Message";
|
||||
const existingBanner = document.getElementById(bannerId);
|
||||
if (existingBanner) {
|
||||
existingBanner.remove();
|
||||
}
|
||||
|
||||
const banner = document.createElement("div");
|
||||
banner.id = bannerId;
|
||||
|
||||
Object.assign(banner.style, {
|
||||
position: "fixed",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "100%",
|
||||
backgroundColor: "#f44336",
|
||||
color: "white",
|
||||
textAlign: "center",
|
||||
padding: "8px 16px",
|
||||
zIndex: "1001",
|
||||
boxSizing: "border-box",
|
||||
fontSize: "16px",
|
||||
boxShadow: "0 2px 5px rgba(0,0,0,0.2)",
|
||||
});
|
||||
|
||||
const closeButton = document.createElement("span");
|
||||
closeButton.textContent = "×";
|
||||
|
||||
Object.assign(closeButton.style, {
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
right: "20px",
|
||||
transform: "translateY(-50%)",
|
||||
cursor: "pointer",
|
||||
fontSize: "22px",
|
||||
fontWeight: "bold",
|
||||
});
|
||||
|
||||
const messageText = document.createTextNode(`KISS-Translator: ${message}`);
|
||||
banner.appendChild(messageText);
|
||||
banner.appendChild(closeButton);
|
||||
|
||||
document.body.appendChild(banner);
|
||||
|
||||
const removeBanner = () => {
|
||||
banner.style.transition = "opacity 0.5s ease";
|
||||
banner.style.opacity = "0";
|
||||
setTimeout(() => {
|
||||
if (banner && banner.parentNode) {
|
||||
banner.parentNode.removeChild(banner);
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
closeButton.onclick = removeBanner;
|
||||
setTimeout(removeBanner, 10000);
|
||||
}
|
||||
|
||||
async function getFavWords(rule) {
|
||||
if (
|
||||
rule.highlightWords &&
|
||||
rule.highlightWords !== OPT_HIGHLIGHT_WORDS_DISABLE
|
||||
) {
|
||||
try {
|
||||
return Object.keys(await getWordsWithDefault());
|
||||
} catch (err) {
|
||||
logger.info("get fav words", err);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 入口函数
|
||||
*/
|
||||
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))
|
||||
) {
|
||||
runSettingPage();
|
||||
return;
|
||||
}
|
||||
|
||||
// 黑名单
|
||||
if (isInBlacklist(href, setting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 翻译网页
|
||||
const rule = await matchRule(href, setting);
|
||||
const favWords = await getFavWords(rule);
|
||||
const fabConfig = await getFabWithDefault();
|
||||
const translatorManager = new TranslatorManager({
|
||||
setting,
|
||||
rule,
|
||||
fabConfig,
|
||||
favWords,
|
||||
isIframe,
|
||||
isUserscript,
|
||||
});
|
||||
translatorManager.start();
|
||||
|
||||
if (isIframe) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 字幕翻译
|
||||
runSubtitle({ href, setting, rule, isUserscript });
|
||||
|
||||
if (isUserscript) {
|
||||
trySyncAllSubRules(setting);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[KISS-Translator]", err);
|
||||
showErr(err.message);
|
||||
}
|
||||
}
|
||||
573
src/config/api.js
Normal file
573
src/config/api.js
Normal file
@@ -0,0 +1,573 @@
|
||||
export const DEFAULT_HTTP_TIMEOUT = 10000; // 调用超时时间
|
||||
export const DEFAULT_FETCH_LIMIT = 10; // 默认最大任务数量
|
||||
export const DEFAULT_FETCH_INTERVAL = 100; // 默认任务间隔时间
|
||||
export const DEFAULT_BATCH_INTERVAL = 400; // 批处理请求间隔时间
|
||||
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_TRANS_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_TRANS_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);
|
||||
});
|
||||
|
||||
export const defaultNobatchPrompt = `You are a professional, authentic machine translation engine.`;
|
||||
export const defaultNobatchUserPrompt = `Translate the following source text from ${INPUT_PLACE_FROM} to ${INPUT_PLACE_TO}. Output translation directly without any additional text.\n\nSource Text: ${INPUT_PLACE_TEXT}\n\nTranslated Text:`;
|
||||
|
||||
export 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]').
|
||||
// `;
|
||||
|
||||
export 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, url, body, headers, userMsg, method });
|
||||
// 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,
|
||||
nobatchPrompt: defaultNobatchPrompt,
|
||||
nobatchUserPrompt: defaultNobatchUserPrompt,
|
||||
userPrompt: "",
|
||||
tone: BUILTIN_STONES[0], // 翻译风格
|
||||
placeholder: BUILTIN_PLACEHOLDERS[0], // 占位符
|
||||
placetag: [BUILTIN_PLACETAGS[0]], // 占位标签
|
||||
// aiTerms: false, // AI智能专业术语 (todo: 备用)
|
||||
customHeader: "",
|
||||
customBody: "",
|
||||
reqHook: "", // request 钩子函数
|
||||
resHook: "", // response 钩子函数
|
||||
fetchLimit: DEFAULT_FETCH_LIMIT, // 最大请求数量
|
||||
fetchInterval: DEFAULT_FETCH_INTERVAL, // 请求间隔时间
|
||||
httpTimeout: DEFAULT_HTTP_TIMEOUT * 30, // 请求超时时间
|
||||
batchInterval: DEFAULT_BATCH_INTERVAL, // 批处理请求间隔时间
|
||||
batchSize: DEFAULT_BATCH_SIZE, // 每次最多发送段落数量
|
||||
batchLength: DEFAULT_BATCH_LENGTH, // 每次发送最大文字数量
|
||||
useBatchFetch: false, // 是否启用聚合发送请求
|
||||
useContext: false, // 是否启用智能上下文
|
||||
contextSize: DEFAULT_CONTEXT_SIZE, // 智能上下文保留会话数
|
||||
temperature: 0.0,
|
||||
maxTokens: 20480,
|
||||
// think: false, // (OpenAI 兼容接口未支持,暂时移除)
|
||||
// thinkIgnore: "qwen3,deepseek-r1", // (OpenAI 兼容接口未支持,暂时移除)
|
||||
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",
|
||||
},
|
||||
[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,
|
||||
},
|
||||
[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_TRANS_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.find(
|
||||
(a) => a.apiType === DEFAULT_API_TYPE
|
||||
);
|
||||
15
src/config/app.js
Normal file
15
src/config/app.js
Normal file
@@ -0,0 +1,15 @@
|
||||
export const APP_NAME = process.env.REACT_APP_NAME.trim()
|
||||
.split(/\s+/)
|
||||
.join("-");
|
||||
export const APP_LCNAME = APP_NAME.toLowerCase();
|
||||
export const APP_UPNAME = APP_NAME.toUpperCase();
|
||||
export const APP_CONSTS = {
|
||||
fabID: `${APP_LCNAME}-fab`,
|
||||
boxID: `${APP_LCNAME}-box`,
|
||||
popupID: `${APP_LCNAME}-popup`,
|
||||
};
|
||||
|
||||
export const APP_VERSION = process.env.REACT_APP_VERSION.split(".");
|
||||
|
||||
export const THEME_LIGHT = "light";
|
||||
export const THEME_DARK = "dark";
|
||||
15
src/config/client.js
Normal file
15
src/config/client.js
Normal file
@@ -0,0 +1,15 @@
|
||||
export const CLIENT_WEB = "web";
|
||||
export const CLIENT_CHROME = "chrome";
|
||||
export const CLIENT_EDGE = "edge";
|
||||
export const CLIENT_FIREFOX = "firefox";
|
||||
export const CLIENT_USERSCRIPT = "userscript";
|
||||
export const CLIENT_THUNDERBIRD = "thunderbird";
|
||||
export const CLIENT_EXTS = [
|
||||
CLIENT_CHROME,
|
||||
CLIENT_EDGE,
|
||||
CLIENT_FIREFOX,
|
||||
CLIENT_THUNDERBIRD,
|
||||
];
|
||||
|
||||
export const DEFAULT_USER_AGENT =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36";
|
||||
1577
src/config/i18n.js
1577
src/config/i18n.js
File diff suppressed because it is too large
Load Diff
@@ -1,196 +1,9 @@
|
||||
import {
|
||||
DEFAULT_SELECTOR,
|
||||
GLOBAL_KEY,
|
||||
DEFAULT_RULE,
|
||||
BUILTIN_RULES,
|
||||
} from "./rules";
|
||||
export { I18N, UI_LANGS } from "./i18n";
|
||||
export { GLOBAL_KEY, DEFAULT_RULE, BUILTIN_RULES };
|
||||
|
||||
const APP_NAME = process.env.REACT_APP_NAME.trim().split(/\s+/).join("-");
|
||||
|
||||
export const APP_LCNAME = APP_NAME.toLowerCase();
|
||||
|
||||
export const STOKEY_MSAUTH = `${APP_NAME}_msauth`;
|
||||
export const STOKEY_SETTING = `${APP_NAME}_setting`;
|
||||
export const STOKEY_RULES = `${APP_NAME}_rules`;
|
||||
export const STOKEY_SYNC = `${APP_NAME}_sync`;
|
||||
export const STOKEY_FAB = `${APP_NAME}_fab`;
|
||||
export const STOKEY_RULESCACHE_PREFIX = `${APP_NAME}_rulescache_`;
|
||||
|
||||
export const CMD_TOGGLE_TRANSLATE = "toggleTranslate";
|
||||
export const CMD_TOGGLE_STYLE = "toggleStyle";
|
||||
|
||||
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 = "KT_RULES";
|
||||
export const KV_RULES_SHARE_KEY = "KT_RULES_SHARE";
|
||||
export const KV_SETTING_KEY = "KT_SETTING";
|
||||
export const KV_SALT_SYNC = "KISS-Translator-SYNC";
|
||||
export const KV_SALT_SHARE = "KISS-Translator-SHARE";
|
||||
|
||||
export const CACHE_NAME = `${APP_NAME}_cache`;
|
||||
|
||||
export const MSG_FETCH = "fetch";
|
||||
export const MSG_FETCH_LIMIT = "fetch_limit";
|
||||
export const MSG_FETCH_CLEAR = "fetch_clear";
|
||||
export const MSG_TRANS_TOGGLE = "trans_toggle";
|
||||
export const MSG_TRANS_TOGGLE_STYLE = "trans_toggle_style";
|
||||
export const MSG_TRANS_GETRULE = "trans_getrule";
|
||||
export const MSG_TRANS_PUTRULE = "trans_putrule";
|
||||
export const MSG_TRANS_CURRULE = "trans_currule";
|
||||
|
||||
export const EVENT_KISS = "kissEvent";
|
||||
|
||||
export const THEME_LIGHT = "light";
|
||||
export const THEME_DARK = "dark";
|
||||
|
||||
export const URL_KISS_WORKER = "https://github.com/fishjar/kiss-worker";
|
||||
export const URL_RAW_PREFIX =
|
||||
"https://raw.githubusercontent.com/fishjar/kiss-translator/master";
|
||||
export const URL_MICROSOFT_AUTH = "https://edge.microsoft.com/translate/auth";
|
||||
export const URL_MICROSOFT_TRANS =
|
||||
"https://api-edge.cognitive.microsofttranslator.com/translate";
|
||||
|
||||
export const OPT_TRANS_GOOGLE = "Google";
|
||||
export const OPT_TRANS_MICROSOFT = "Microsoft";
|
||||
export const OPT_TRANS_OPENAI = "OpenAI";
|
||||
export const OPT_TRANS_ALL = [
|
||||
OPT_TRANS_GOOGLE,
|
||||
OPT_TRANS_MICROSOFT,
|
||||
OPT_TRANS_OPENAI,
|
||||
];
|
||||
|
||||
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_MICROSOFT]: new Map([
|
||||
["auto", ""],
|
||||
["zh-CN", "zh-Hans"],
|
||||
["zh-TW", "zh-Hant"],
|
||||
]),
|
||||
[OPT_TRANS_OPENAI]: new Map(
|
||||
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
|
||||
),
|
||||
};
|
||||
|
||||
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_HIGHTLIGHT = "highlight"; // 高亮
|
||||
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_HIGHTLIGHT,
|
||||
];
|
||||
|
||||
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 DEFAULT_COLOR = "#209CEE"; // 默认高亮背景色/线条颜色
|
||||
|
||||
// 全局规则
|
||||
export const GLOBLA_RULE = {
|
||||
pattern: "*",
|
||||
selector: DEFAULT_SELECTOR,
|
||||
translator: OPT_TRANS_MICROSOFT,
|
||||
fromLang: "auto",
|
||||
toLang: "zh-CN",
|
||||
textStyle: OPT_STYLE_DASHLINE,
|
||||
transOpen: "false",
|
||||
bgColor: "",
|
||||
};
|
||||
|
||||
// 订阅列表
|
||||
export const DEFAULT_SUBRULES_LIST = [
|
||||
{
|
||||
url: process.env.REACT_APP_RULESURL,
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
url: "https://fishjar.github.io/kiss-translator/kiss-translator-rules.json",
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_SETTING = {
|
||||
darkMode: false, // 深色模式
|
||||
uiLang: "en", // 界面语言
|
||||
fetchLimit: DEFAULT_FETCH_LIMIT, // 最大任务数量
|
||||
fetchInterval: DEFAULT_FETCH_INTERVAL, // 任务间隔时间
|
||||
clearCache: false, // 是否在浏览器下次启动时清除缓存
|
||||
injectRules: true, // 是否注入订阅规则
|
||||
subrulesList: DEFAULT_SUBRULES_LIST, // 订阅列表
|
||||
googleUrl: "https://translate.googleapis.com/translate_a/single", // 谷歌翻译接口
|
||||
openaiUrl: "https://api.openai.com/v1/chat/completions",
|
||||
openaiKey: "",
|
||||
openaiModel: "gpt-4",
|
||||
openaiPrompt: `You will be provided with a sentence in ${PROMPT_PLACE_FROM}, and your task is to translate it into ${PROMPT_PLACE_TO}.`,
|
||||
};
|
||||
|
||||
export const DEFAULT_RULES = [GLOBLA_RULE];
|
||||
|
||||
export const TRANS_MIN_LENGTH = 5; // 最短翻译长度
|
||||
export const TRANS_MAX_LENGTH = 5000; // 最长翻译长度
|
||||
|
||||
export const DEFAULT_SYNC = {
|
||||
syncUrl: "", // 数据同步接口
|
||||
syncKey: "", // 数据同步密钥
|
||||
settingUpdateAt: 0,
|
||||
settingSyncAt: 0,
|
||||
rulesUpdateAt: 0,
|
||||
rulesSyncAt: 0,
|
||||
subRulesSyncAt: 0, // 订阅规则同步时间
|
||||
};
|
||||
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";
|
||||
|
||||
35
src/config/msg.js
Normal file
35
src/config/msg.js
Normal file
@@ -0,0 +1,35 @@
|
||||
export const CMD_TOGGLE_TRANSLATE = "toggleTranslate";
|
||||
export const CMD_TOGGLE_STYLE = "toggleStyle";
|
||||
export const CMD_OPEN_OPTIONS = "openOptions";
|
||||
export const CMD_OPEN_TRANBOX = "openTranbox";
|
||||
|
||||
export const MSG_FETCH = "kiss_fetch";
|
||||
export const MSG_GET_HTTPCACHE = "get_httpcache";
|
||||
export const MSG_PUT_HTTPCACHE = "put_httpcache";
|
||||
export const MSG_OPEN_OPTIONS = "open_options";
|
||||
export const MSG_SAVE_RULE = "save_rule";
|
||||
export const MSG_TRANS_TOGGLE = "trans_toggle";
|
||||
export const MSG_TRANS_TOGGLE_STYLE = "trans_toggle_style";
|
||||
export const MSG_OPEN_TRANBOX = "open_tranbox";
|
||||
export const MSG_TRANS_GETRULE = "trans_getrule";
|
||||
export const MSG_TRANS_PUTRULE = "trans_putrule";
|
||||
export const MSG_TRANS_CURRULE = "trans_currule";
|
||||
export const MSG_TRANSBOX_TOGGLE = "transbox_toggle";
|
||||
export const MSG_POPUP_TOGGLE = "popup_toggle";
|
||||
export const MSG_MOUSEHOVER_TOGGLE = "mousehover_toggle";
|
||||
export const MSG_TRANSINPUT_TOGGLE = "transinput_toggle";
|
||||
export const MSG_CONTEXT_MENUS = "context_menus";
|
||||
export const MSG_COMMAND_SHORTCUTS = "command_shortcuts";
|
||||
export const MSG_INJECT_JS = "inject_js";
|
||||
export const MSG_INJECT_CSS = "inject_css";
|
||||
export const MSG_UPDATE_CSP = "update_csp";
|
||||
export const MSG_BUILTINAI_DETECT = "builtinai_detect";
|
||||
export const MSG_BUILTINAI_TRANSLATE = "builtinai_translte";
|
||||
export const MSG_SET_LOGLEVEL = "set_loglevel";
|
||||
export const MSG_CLEAR_CACHES = "clear_caches";
|
||||
|
||||
export const EVENT_KISS = "event_kiss_translate";
|
||||
|
||||
export const MSG_XHR_DATA_YOUTUBE = "KISS_XHR_DATA_YOUTUBE";
|
||||
// export const MSG_GLOBAL_VAR_FETCH = "KISS_GLOBAL_VAR_FETCH";
|
||||
// export const MSG_GLOBAL_VAR_BACK = "KISS_GLOBAL_VAR_BACK";
|
||||
@@ -1,153 +1,240 @@
|
||||
const els = `li, p, h1, h2, h3, h4, h5, h6, dd`;
|
||||
|
||||
export const DEFAULT_SELECTOR = `:is(${els})`;
|
||||
import { OPT_TRANS_MICROSOFT } from "./api";
|
||||
|
||||
export const GLOBAL_KEY = "*";
|
||||
export const REMAIN_KEY = "-";
|
||||
export const SHADOW_KEY = ">>>";
|
||||
|
||||
export const DEFAULT_RULE = {
|
||||
pattern: "",
|
||||
selector: "",
|
||||
translator: GLOBAL_KEY,
|
||||
fromLang: GLOBAL_KEY,
|
||||
toLang: GLOBAL_KEY,
|
||||
textStyle: GLOBAL_KEY,
|
||||
transOpen: GLOBAL_KEY,
|
||||
bgColor: "",
|
||||
};
|
||||
export const DEFAULT_COLOR = "#209CEE"; // 默认高亮背景色/线条颜色
|
||||
|
||||
const RULES = [
|
||||
{
|
||||
pattern: `www.google.com/search`,
|
||||
selector: `h3, .IsZvec, .VwiC3b`,
|
||||
},
|
||||
{
|
||||
pattern: `https://news.google.com/`,
|
||||
selector: `h4`,
|
||||
},
|
||||
{
|
||||
pattern: `bearblog.dev, www.theverge.com, www.tampermonkey.net/documentation.php`,
|
||||
selector: DEFAULT_SELECTOR,
|
||||
},
|
||||
{
|
||||
pattern: `themessenger.com`,
|
||||
selector: `.leading-tight, .leading-tighter, .my-2 p, .font-body p, article ${DEFAULT_SELECTOR}`,
|
||||
},
|
||||
{
|
||||
pattern: `www.telegraph.co.uk`,
|
||||
selector: `article ${DEFAULT_SELECTOR}`,
|
||||
},
|
||||
{
|
||||
pattern: `www.theguardian.com`,
|
||||
selector: `.show-underline, .dcr-hup5wm div, .dcr-7vl6y8 div, .dcr-12evv1c, figcaption, article ${DEFAULT_SELECTOR}, [data-cy="mostviewed-footer"] h4`,
|
||||
},
|
||||
{
|
||||
pattern: `www.semafor.com`,
|
||||
selector: `${DEFAULT_SELECTOR}, .styles_intro__IYj__, [class*="styles_description"]`,
|
||||
},
|
||||
{
|
||||
pattern: `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`,
|
||||
},
|
||||
{
|
||||
pattern: `restofworld.org`,
|
||||
selector: `${DEFAULT_SELECTOR}, .recirc-story__headline, .recirc-story__dek`,
|
||||
},
|
||||
{
|
||||
pattern: `www.axios.com`,
|
||||
selector: `.h7, ${DEFAULT_SELECTOR}`,
|
||||
},
|
||||
{
|
||||
pattern: `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`,
|
||||
},
|
||||
{
|
||||
pattern: `https://time.com/`,
|
||||
selector: `h1, h3, .summary, .video-title, #article-body ${DEFAULT_SELECTOR}, .image-wrap-container .credit.body-caption, .media-heading`,
|
||||
},
|
||||
{
|
||||
pattern: `www.dw.com`,
|
||||
selector: `.ts-teaser-title a, .news-title a, .title a, .teaser-description a, .hbudab h3, .hbudab p, figcaption ,article ${DEFAULT_SELECTOR}`,
|
||||
},
|
||||
{
|
||||
pattern: `www.bbc.com`,
|
||||
selector: `h1, h2, .media__link, .media__summary, article ${DEFAULT_SELECTOR}, .ssrcss-y7krbn-Stack, .ssrcss-1mrs5ns-PromoLink, .ssrcss-18cjaf3-Headline, .gs-c-promo-heading__title, .gs-c-promo-summary, .media__content h3, .article__intro`,
|
||||
},
|
||||
{
|
||||
pattern: `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}`,
|
||||
},
|
||||
{
|
||||
pattern: `www.facebook.com`,
|
||||
selector: `[role="main"] [dir="auto"]`,
|
||||
},
|
||||
{
|
||||
pattern: `www.reddit.com`,
|
||||
selector: `[slot="title"], [slot="text-body"] ${DEFAULT_SELECTOR}, #-post-rtjson-content p`,
|
||||
},
|
||||
{
|
||||
pattern: `www.quora.com`,
|
||||
selector: `.qu-wordBreak--break-word`,
|
||||
},
|
||||
{
|
||||
pattern: `edition.cnn.com`,
|
||||
selector: `.container__title, .container__headline, .headline__text, .image__caption, [data-type="Title"], .article__content ${DEFAULT_SELECTOR}`,
|
||||
},
|
||||
{
|
||||
pattern: `www.reuters.com`,
|
||||
selector: `#main-content [data-testid="Heading"], #main-content [data-testid="Body"], .article-body__content__17Yit ${DEFAULT_SELECTOR}`,
|
||||
},
|
||||
{
|
||||
pattern: `www.bloomberg.com`,
|
||||
selector: `[data-component="headline"], [data-component="related-item-headline"], [data-component="title"], article ${DEFAULT_SELECTOR}`,
|
||||
},
|
||||
{
|
||||
pattern: `deno.land, docs.github.com`,
|
||||
selector: `main ${DEFAULT_SELECTOR}`,
|
||||
},
|
||||
{
|
||||
pattern: `doc.rust-lang.org`,
|
||||
selector: `#content ${DEFAULT_SELECTOR}`,
|
||||
},
|
||||
{
|
||||
pattern: `www.indiehackers.com`,
|
||||
selector: `h1, h3, .content ${DEFAULT_SELECTOR}, .feed-item__title-link`,
|
||||
},
|
||||
{
|
||||
pattern: `platform.openai.com/docs`,
|
||||
selector: `.docs-body ${DEFAULT_SELECTOR}`,
|
||||
},
|
||||
{
|
||||
pattern: `en.wikipedia.org`,
|
||||
selector: `h1, .mw-parser-output ${DEFAULT_SELECTOR}`,
|
||||
},
|
||||
{
|
||||
pattern: `stackoverflow.com`,
|
||||
selector: `h1, .s-prose p, .comment-body .comment-copy`,
|
||||
},
|
||||
{
|
||||
pattern: `www.npmjs.com/package/, developer.chrome.com/docs, medium.com, developers.cloudflare.com, react.dev, create-react-app.dev, pytorch.org/`,
|
||||
selector: `article ${DEFAULT_SELECTOR}`,
|
||||
},
|
||||
{
|
||||
pattern: `news.ycombinator.com`,
|
||||
selector: `.title, .commtext`,
|
||||
},
|
||||
{
|
||||
pattern: `https://github.com/`,
|
||||
selector: `.markdown-body ${DEFAULT_SELECTOR}, .repo-description p, .Layout-sidebar .f4, .container-lg .py-4 .f5, .container-lg .my-4 .f5, .Box-row .pr-4, .Box-row article .mt-1, [itemprop='description'], .markdown-title, bdi`,
|
||||
},
|
||||
{
|
||||
pattern: `twitter.com`,
|
||||
selector: `[data-testid='tweetText']`,
|
||||
},
|
||||
{
|
||||
pattern: `youtube.com`,
|
||||
selector: `h1, #video-title, #content-text, #title, yt-attributed-string>span>span`,
|
||||
},
|
||||
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 BUILTIN_RULES = RULES.map((item) => ({
|
||||
...DEFAULT_RULE,
|
||||
...item,
|
||||
transOpen: "true",
|
||||
}));
|
||||
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 OPT_SPLIT_PARAGRAPH_DISABLE = "split_disable";
|
||||
export const OPT_SPLIT_PARAGRAPH_TEXTLENGTH = "split_textlength";
|
||||
export const OPT_SPLIT_PARAGRAPH_PUNCTUATION = "split_punctuation";
|
||||
export const OPT_SPLIT_PARAGRAPH_ALL = [
|
||||
OPT_SPLIT_PARAGRAPH_DISABLE,
|
||||
OPT_SPLIT_PARAGRAPH_PUNCTUATION,
|
||||
OPT_SPLIT_PARAGRAPH_TEXTLENGTH,
|
||||
];
|
||||
|
||||
export const OPT_HIGHLIGHT_WORDS_DISABLE = "highlight_disable";
|
||||
export const OPT_HIGHLIGHT_WORDS_BEFORETRANS = "highlight_beforetrans";
|
||||
export const OPT_HIGHLIGHT_WORDS_AFTERTRANS = "highlight_aftertrans";
|
||||
export const OPT_HIGHLIGHT_WORDS_ALL = [
|
||||
OPT_HIGHLIGHT_WORDS_DISABLE,
|
||||
OPT_HIGHLIGHT_WORDS_BEFORETRANS,
|
||||
OPT_HIGHLIGHT_WORDS_AFTERTRANS,
|
||||
];
|
||||
|
||||
export const DEFAULT_DIY_STYLE = `color: #333;
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
LightGreen 20%,
|
||||
LightPink 20% 40%,
|
||||
LightSalmon 40% 60%,
|
||||
LightSeaGreen 60% 80%,
|
||||
LightSkyBlue 80%
|
||||
);
|
||||
&:hover {
|
||||
color: #111;
|
||||
};`;
|
||||
|
||||
export const DEFAULT_SELECTOR =
|
||||
"h1, h2, h3, h4, h5, h6, li, p, dd, blockquote, figcaption, label, legend";
|
||||
export const DEFAULT_IGNORE_SELECTOR = "button, footer, pre, mark, nav";
|
||||
export const DEFAULT_KEEP_SELECTOR = `code, cite, math, .math, 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: "", // 自定义译文样式
|
||||
termsStyle: "", // 专业术语样式
|
||||
highlightStyle: "", // 高亮词汇样式
|
||||
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: "", // 不翻译的选择器
|
||||
splitParagraph: GLOBAL_KEY, // 切分段落
|
||||
splitLength: 0, // 切分段落长度
|
||||
highlightWords: GLOBAL_KEY, // 高亮词汇
|
||||
};
|
||||
|
||||
// 全局规则
|
||||
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, // 自定义译文样式
|
||||
termsStyle: "font-weight: bold;", // 专业术语样式
|
||||
highlightStyle: "color: red;", // 高亮词汇样式
|
||||
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, // 不翻译的选择器
|
||||
splitParagraph: OPT_SPLIT_PARAGRAPH_DISABLE, // 切分段落
|
||||
splitLength: 100, // 切分段落长度
|
||||
highlightWords: OPT_HIGHLIGHT_WORDS_DISABLE, // 高亮词汇
|
||||
};
|
||||
|
||||
export const DEFAULT_RULES = [GLOBLA_RULE];
|
||||
|
||||
export const DEFAULT_OW_RULE = {
|
||||
apiSlug: REMAIN_KEY,
|
||||
fromLang: REMAIN_KEY,
|
||||
toLang: REMAIN_KEY,
|
||||
textStyle: REMAIN_KEY,
|
||||
transOpen: REMAIN_KEY,
|
||||
bgColor: "",
|
||||
textDiyStyle: DEFAULT_DIY_STYLE,
|
||||
};
|
||||
|
||||
// todo: 校验几个内置规则
|
||||
const RULES_MAP = {
|
||||
// "www.google.com/search": {
|
||||
// rootsSelector: `#rcnt`,
|
||||
// },
|
||||
"en.wikipedia.org": {
|
||||
ignoreSelector: `.button, code, footer, form, mark, pre, .mwe-math-element, .mw-editsection`,
|
||||
},
|
||||
"news.ycombinator.com": {
|
||||
selector: `p, .titleline, .commtext, .hn-item-title, .hn-comment-text, .hn-story-title`,
|
||||
keepSelector: `code, img, svg, pre, .sitebit`,
|
||||
ignoreSelector: `button, code, footer, form, header, mark, nav, pre, .reply`,
|
||||
autoScan: `false`,
|
||||
},
|
||||
"twitter.com, https://x.com": {
|
||||
selector: `[data-testid='tweetText']`,
|
||||
keepSelector: `img, svg, a, span:has(a), div:has(a)`,
|
||||
ignoreSelector: `button, [data-testid='videoPlayer'], [role='group']`,
|
||||
autoScan: `false`,
|
||||
},
|
||||
"www.youtube.com/live_chat": {
|
||||
rootsSelector: `div#items`,
|
||||
selector: `span.yt-live-chat-text-message-renderer`,
|
||||
autoScan: `false`,
|
||||
},
|
||||
"www.youtube.com": {
|
||||
rootsSelector: `ytd-page-manager`,
|
||||
ignoreSelector: `aside, button, footer, form, header, pre, mark, nav, #player, #container, .caption-window, .ytp-settings-menu`,
|
||||
},
|
||||
"web.telegram.org": {
|
||||
autoScan: `false`,
|
||||
selector: ".text-content, .embedded-text-wrapper",
|
||||
rootsSelector: ".Transition",
|
||||
},
|
||||
};
|
||||
|
||||
export const BUILTIN_RULES = Object.entries(RULES_MAP).map(
|
||||
([pattern, rule]) => ({
|
||||
// ...DEFAULT_RULE,
|
||||
...rule,
|
||||
pattern,
|
||||
})
|
||||
);
|
||||
|
||||
186
src/config/setting.js
Normal file
186
src/config/setting.js
Normal file
@@ -0,0 +1,186 @@
|
||||
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, // 翻译框是否跟随选中文本
|
||||
autoHeight: 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, // 是否双语显示
|
||||
skipAd: false, // 是否快进广告
|
||||
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 = ["ControlLeft"];
|
||||
export const DEFAULT_MOUSE_HOVER_SETTING = {
|
||||
useMouseHover: false, // 是否启用鼠标悬停翻译
|
||||
mouseHoverKey: DEFAULT_MOUSEHOVER_KEY, // 鼠标悬停翻译组合键
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTING = {
|
||||
darkMode: "auto", // 深色模式
|
||||
uiLang: "en", // 界面语言
|
||||
// fetchLimit: DEFAULT_FETCH_LIMIT, // 最大任务数量(移至rule,作废)
|
||||
// fetchInterval: DEFAULT_FETCH_INTERVAL, // 任务间隔时间(移至rule,作废)
|
||||
minLength: TRANS_MIN_LENGTH,
|
||||
maxLength: TRANS_MAX_LENGTH,
|
||||
newlineLength: TRANS_NEWLINE_LENGTH,
|
||||
httpTimeout: DEFAULT_HTTP_TIMEOUT,
|
||||
clearCache: false, // 是否在浏览器下次启动时清除缓存
|
||||
injectRules: true, // 是否注入订阅规则
|
||||
fabClickAction: 0, // 悬浮按钮点击行为
|
||||
// injectWebfix: true, // 是否注入修复补丁(作废)
|
||||
// detectRemote: false, // 是否使用远程语言检测 (从rule移回)
|
||||
// contextMenus: true, // 是否添加右键菜单(作废)
|
||||
contextMenuType: 1, // 右键菜单类型(0不显示,1简单菜单,2多级菜单)
|
||||
// transTag: DEFAULT_TRANS_TAG, // 译文元素标签(移至rule,作废)
|
||||
// transOnly: false, // 是否仅显示译文(移至rule,作废)
|
||||
// transTitle: false, // 是否同时翻译页面标题(移至rule,作废)
|
||||
subrulesList: DEFAULT_SUBRULES_LIST, // 订阅列表
|
||||
// owSubrule: DEFAULT_OW_RULE, // 覆写订阅规则 (作废)
|
||||
transApis: DEFAULT_API_LIST, // 翻译接口 (v2.0 对象改为数组)
|
||||
// mouseKey: OPT_TIMING_PAGESCROLL, // 翻译时机/鼠标悬停翻译(移至rule,作废)
|
||||
shortcuts: DEFAULT_SHORTCUTS, // 快捷键
|
||||
inputRule: DEFAULT_INPUT_RULE, // 输入框设置
|
||||
tranboxSetting: DEFAULT_TRANBOX_SETTING, // 划词翻译设置
|
||||
// touchTranslate: 2, // 触屏翻译 {5:单指双击,6:单指三击,7:双指双击} (作废)
|
||||
touchModes: [2], // 触屏翻译 {5:单指双击,6:单指三击,7:双指双击} (多选)
|
||||
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, // 日志级别
|
||||
rootMargin: 500, // 提前触发翻译
|
||||
};
|
||||
23
src/config/storage.js
Normal file
23
src/config/storage.js
Normal file
@@ -0,0 +1,23 @@
|
||||
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_TRANBOX = `${APP_NAME}_tranbox`;
|
||||
export const STOKEY_RULESCACHE_PREFIX = `${APP_NAME}_rulescache_`;
|
||||
|
||||
export const CACHE_NAME = `${APP_NAME}_cache`;
|
||||
export const DEFAULT_CACHE_TIMEOUT = 3600 * 24 * 7; // 缓存超时时间(7天)
|
||||
14
src/config/url.js
Normal file
14
src/config/url.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { APP_LCNAME } from "./app";
|
||||
|
||||
export const URL_CACHE_TRAN = `https://${APP_LCNAME}/translate`;
|
||||
export const URL_CACHE_SUBTITLE = `https://${APP_LCNAME}/subtitle`;
|
||||
export const URL_CACHE_DELANG = `https://${APP_LCNAME}/detectlang`;
|
||||
export const URL_CACHE_BINGDICT = `https://${APP_LCNAME}/bingdict`;
|
||||
|
||||
export const URL_KISS_WORKER = "https://github.com/fishjar/kiss-worker";
|
||||
export const URL_KISS_PROXY = "https://github.com/fishjar/kiss-proxy";
|
||||
export const URL_KISS_RULES = "https://github.com/fishjar/kiss-rules";
|
||||
export const URL_KISS_RULES_NEW_ISSUE =
|
||||
"https://github.com/fishjar/kiss-rules/issues/new";
|
||||
export const URL_RAW_PREFIX =
|
||||
"https://raw.githubusercontent.com/fishjar/kiss-translator/master";
|
||||
@@ -1,39 +1,5 @@
|
||||
import { browser } from "./libs/browser";
|
||||
import {
|
||||
MSG_TRANS_TOGGLE,
|
||||
MSG_TRANS_TOGGLE_STYLE,
|
||||
MSG_TRANS_GETRULE,
|
||||
MSG_TRANS_PUTRULE,
|
||||
} from "./config";
|
||||
import { getSetting, getRules, matchRule } from "./libs";
|
||||
import { Translator } from "./libs/translator";
|
||||
import { run } from "./common";
|
||||
|
||||
/**
|
||||
* 入口函数
|
||||
*/
|
||||
(async () => {
|
||||
const setting = await getSetting();
|
||||
const rules = await getRules();
|
||||
const rule = await matchRule(rules, document.location.href, setting);
|
||||
const translator = new Translator(rule, setting);
|
||||
|
||||
// 监听消息
|
||||
browser?.runtime.onMessage.addListener(async ({ action, args }) => {
|
||||
switch (action) {
|
||||
case MSG_TRANS_TOGGLE:
|
||||
translator.toggle();
|
||||
break;
|
||||
case MSG_TRANS_TOGGLE_STYLE:
|
||||
translator.toggleStyle();
|
||||
break;
|
||||
case MSG_TRANS_GETRULE:
|
||||
break;
|
||||
case MSG_TRANS_PUTRULE:
|
||||
translator.updateRule(args);
|
||||
break;
|
||||
default:
|
||||
return { error: `message action is unavailable: ${action}` };
|
||||
}
|
||||
return { data: translator.rule };
|
||||
});
|
||||
})();
|
||||
if (document.documentElement && document.documentElement.tagName === "HTML") {
|
||||
run();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { createContext, useContext, useState, forwardRef } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import Snackbar from "@mui/material/Snackbar";
|
||||
import MuiAlert from "@mui/material/Alert";
|
||||
|
||||
@@ -18,36 +25,45 @@ 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 error = (msg) => showAlert(msg, "error");
|
||||
const warning = (msg) => showAlert(msg, "warning");
|
||||
const info = (msg) => showAlert(msg, "info");
|
||||
const success = (msg) => showAlert(msg, "success");
|
||||
|
||||
const showAlert = (msg, type) => {
|
||||
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 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 }}
|
||||
>
|
||||
<Alert onClose={handleClose} severity={severity} sx={{ width: "100%" }}>
|
||||
<Alert
|
||||
onClose={handleClose}
|
||||
severity={severity}
|
||||
sx={{ minWidth: "300px", maxWidth: "80%" }}
|
||||
>
|
||||
{message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
136
src/hooks/Api.js
Normal file
136
src/hooks/Api.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { DEFAULT_API_LIST, API_SPE_TYPES } from "../config";
|
||||
import { useSetting } from "./Setting";
|
||||
|
||||
function useApiState() {
|
||||
const { setting, updateSetting } = useSetting();
|
||||
const transApis = setting?.transApis || [];
|
||||
|
||||
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 builtinApis = useMemo(
|
||||
() => transApis.filter((api) => API_SPE_TYPES.builtin.has(api.apiSlug)),
|
||||
[transApis]
|
||||
);
|
||||
|
||||
const enabledApis = useMemo(
|
||||
() => transApis.filter((api) => !api.isDisabled),
|
||||
[transApis]
|
||||
);
|
||||
|
||||
const aiEnabledApis = useMemo(
|
||||
() => enabledApis.filter((api) => API_SPE_TYPES.ai.has(api.apiType)),
|
||||
[enabledApis]
|
||||
);
|
||||
|
||||
const addApi = useCallback(
|
||||
(apiType) => {
|
||||
const defaultApiOpt =
|
||||
DEFAULT_API_LIST.find((da) => da.apiType === apiType) || {};
|
||||
const uuid = crypto.randomUUID();
|
||||
const apiSlug = `${apiType}_${crypto.randomUUID()}`;
|
||||
const apiName = `${apiType}_${uuid.slice(0, 8)}`;
|
||||
const newApi = {
|
||||
...defaultApiOpt,
|
||||
apiSlug,
|
||||
apiName,
|
||||
apiType,
|
||||
};
|
||||
updateSetting((prev) => ({
|
||||
...prev,
|
||||
transApis: [...(prev?.transApis || []), newApi],
|
||||
}));
|
||||
},
|
||||
[updateSetting]
|
||||
);
|
||||
|
||||
const deleteApi = useCallback(
|
||||
(apiSlug) => {
|
||||
updateSetting((prev) => ({
|
||||
...prev,
|
||||
transApis: (prev?.transApis || []).filter(
|
||||
(api) => api.apiSlug !== apiSlug
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updateSetting]
|
||||
);
|
||||
|
||||
return {
|
||||
transApis,
|
||||
userApis,
|
||||
builtinApis,
|
||||
enabledApis,
|
||||
aiEnabledApis,
|
||||
addApi,
|
||||
deleteApi,
|
||||
};
|
||||
}
|
||||
|
||||
export function useApiItem(apiSlug) {
|
||||
const { transApis, updateSetting } = useApiState();
|
||||
|
||||
const api = useMemo(
|
||||
() => transApis.find((a) => a.apiSlug === apiSlug),
|
||||
[transApis, apiSlug]
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
(updateData) => {
|
||||
updateSetting((prev) => ({
|
||||
...prev,
|
||||
transApis: (prev?.transApis || []).map((item) =>
|
||||
item.apiSlug === apiSlug ? { ...item, ...updateData, apiSlug } : item
|
||||
),
|
||||
}));
|
||||
},
|
||||
[apiSlug, updateSetting]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
updateSetting((prev) => ({
|
||||
...prev,
|
||||
transApis: (prev?.transApis || []).map((item) => {
|
||||
if (item.apiSlug === apiSlug) {
|
||||
const defaultApiOpt =
|
||||
DEFAULT_API_LIST.find((da) => da.apiType === item.apiType) || {};
|
||||
return {
|
||||
...defaultApiOpt,
|
||||
apiSlug: item.apiSlug,
|
||||
apiName: item.apiName,
|
||||
apiType: item.apiType,
|
||||
key: item.key,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
}));
|
||||
}, [apiSlug, updateSetting]);
|
||||
|
||||
return { api, update, reset };
|
||||
}
|
||||
61
src/hooks/Audio.js
Normal file
61
src/hooks/Audio.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { apiBaiduTTS } from "../apis";
|
||||
import { kissLog } from "../libs/log";
|
||||
|
||||
/**
|
||||
* 声音播放hook
|
||||
* @param {*} src
|
||||
* @returns
|
||||
*/
|
||||
export function useAudio(src) {
|
||||
const audioRef = useRef(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
const onPlay = useCallback(() => {
|
||||
audioRef.current?.play();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
const audio = new Audio(src);
|
||||
audio.addEventListener("error", (err) => setError(err));
|
||||
audio.addEventListener("canplaythrough", () => setReady(true));
|
||||
audio.addEventListener("play", () => setPlaying(true));
|
||||
audio.addEventListener("ended", () => setPlaying(false));
|
||||
audioRef.current = audio;
|
||||
}, [src]);
|
||||
|
||||
return {
|
||||
error,
|
||||
ready,
|
||||
playing,
|
||||
onPlay,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语音hook
|
||||
* @param {*} text
|
||||
* @param {*} lan
|
||||
* @param {*} spd
|
||||
* @returns
|
||||
*/
|
||||
export function useTextAudio(text, lan = "uk", spd = 3) {
|
||||
const [src, setSrc] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
setSrc(await apiBaiduTTS(text, lan, spd));
|
||||
} catch (err) {
|
||||
kissLog("baidu tts", err);
|
||||
}
|
||||
})();
|
||||
}, [text, lan, spd]);
|
||||
|
||||
return useAudio(src);
|
||||
}
|
||||
@@ -1,22 +1,24 @@
|
||||
import { useSetting, useSettingUpdate } from "./Setting";
|
||||
import { useCallback } from "react";
|
||||
import { useSetting } from "./Setting";
|
||||
|
||||
/**
|
||||
* 深色模式hook
|
||||
* @returns
|
||||
*/
|
||||
export function useDarkMode() {
|
||||
const setting = useSetting();
|
||||
return !!setting?.darkMode;
|
||||
}
|
||||
const {
|
||||
setting: { darkMode },
|
||||
updateSetting,
|
||||
} = useSetting();
|
||||
|
||||
/**
|
||||
* 切换深色模式
|
||||
* @returns
|
||||
*/
|
||||
export function useDarkModeSwitch() {
|
||||
const darkMode = useDarkMode();
|
||||
const updateSetting = useSettingUpdate();
|
||||
return async () => {
|
||||
await updateSetting({ darkMode: !darkMode });
|
||||
};
|
||||
const toggleDarkMode = useCallback(() => {
|
||||
const nextMode = {
|
||||
light: "dark",
|
||||
dark: "auto",
|
||||
auto: "light",
|
||||
};
|
||||
updateSetting({ darkMode: nextMode[darkMode] || "light" });
|
||||
}, [darkMode, updateSetting]);
|
||||
|
||||
return { darkMode, toggleDarkMode };
|
||||
}
|
||||
|
||||
97
src/hooks/Confirm.js
Normal file
97
src/hooks/Confirm.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
useState,
|
||||
useContext,
|
||||
createContext,
|
||||
useCallback,
|
||||
useRef,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogContentText from "@mui/material/DialogContentText";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import Button from "@mui/material/Button";
|
||||
import { useI18n } from "./I18n";
|
||||
|
||||
const ConfirmContext = createContext(null);
|
||||
|
||||
export function ConfirmProvider({ children }) {
|
||||
const [dialogConfig, setDialogConfig] = useState(null);
|
||||
const resolveRef = useRef(null);
|
||||
const i18n = useI18n();
|
||||
|
||||
const translatedDefaults = useMemo(
|
||||
() => ({
|
||||
title: i18n("confirm_title", "Confirm"),
|
||||
message: i18n("confirm_message", "Are you sure you want to proceed?"),
|
||||
confirmText: i18n("confirm_action", "Confirm"),
|
||||
cancelText: i18n("cancel_action", "Cancel"),
|
||||
}),
|
||||
[i18n]
|
||||
);
|
||||
|
||||
const confirm = useCallback(
|
||||
(config) => {
|
||||
return new Promise((resolve) => {
|
||||
setDialogConfig({ ...translatedDefaults, ...config });
|
||||
resolveRef.current = resolve;
|
||||
});
|
||||
},
|
||||
[translatedDefaults]
|
||||
);
|
||||
|
||||
const handleClose = () => {
|
||||
if (resolveRef.current) {
|
||||
resolveRef.current(false);
|
||||
}
|
||||
setDialogConfig(null);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (resolveRef.current) {
|
||||
resolveRef.current(true);
|
||||
}
|
||||
setDialogConfig(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={confirm}>
|
||||
{children}
|
||||
|
||||
<Dialog
|
||||
open={!!dialogConfig}
|
||||
onClose={handleClose}
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
aria-describedby="confirm-dialog-description"
|
||||
>
|
||||
{dialogConfig && (
|
||||
<>
|
||||
<DialogTitle id="confirm-dialog-title">
|
||||
{dialogConfig.title}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="confirm-dialog-description">
|
||||
{dialogConfig.message}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>{dialogConfig.cancelText}</Button>
|
||||
<Button onClick={handleConfirm} color="primary" autoFocus>
|
||||
{dialogConfig.confirmText}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConfirm() {
|
||||
const context = useContext(ConfirmContext);
|
||||
if (!context) {
|
||||
throw new Error("useConfirm must be used within a ConfirmProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
17
src/hooks/DebouncedCallback.js
Normal file
17
src/hooks/DebouncedCallback.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useMemo, useEffect, useRef } from "react";
|
||||
import { debounce } from "../libs/utils";
|
||||
|
||||
export function useDebouncedCallback(callback, delay) {
|
||||
const callbackRef = useRef(callback);
|
||||
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
const debouncedCallback = useMemo(
|
||||
() => debounce((...args) => callbackRef.current(...args), delay),
|
||||
[delay]
|
||||
);
|
||||
|
||||
return debouncedCallback;
|
||||
}
|
||||
13
src/hooks/Fab.js
Normal file
13
src/hooks/Fab.js
Normal file
@@ -0,0 +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, DEFAULT_FAB);
|
||||
return { fab: data, updateFab: update };
|
||||
}
|
||||
64
src/hooks/FavWords.js
Normal file
64
src/hooks/FavWords.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { STOKEY_WORDS, KV_WORDS_KEY } from "../config";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useStorage } from "./Storage";
|
||||
import { debounceSyncMeta } from "../libs/storage";
|
||||
|
||||
const DEFAULT_FAVWORDS = {};
|
||||
|
||||
export function useFavWords() {
|
||||
const { data: favWords, save: saveWords } = useStorage(
|
||||
STOKEY_WORDS,
|
||||
DEFAULT_FAVWORDS,
|
||||
KV_WORDS_KEY
|
||||
);
|
||||
|
||||
const save = useCallback(
|
||||
(objOrFn) => {
|
||||
saveWords(objOrFn);
|
||||
debounceSyncMeta(KV_WORDS_KEY);
|
||||
},
|
||||
[saveWords]
|
||||
);
|
||||
|
||||
const toggleFav = useCallback(
|
||||
(word) => {
|
||||
save((prev) => {
|
||||
if (!prev[word]) {
|
||||
return { ...prev, [word]: { createdAt: Date.now() } };
|
||||
}
|
||||
|
||||
const favs = { ...prev };
|
||||
delete favs[word];
|
||||
return favs;
|
||||
});
|
||||
},
|
||||
[save]
|
||||
);
|
||||
|
||||
const mergeWords = useCallback(
|
||||
(words) => {
|
||||
save((prev) => ({
|
||||
...words.reduce((acc, key) => {
|
||||
acc[key] = { createdAt: Date.now() };
|
||||
return acc;
|
||||
}, {}),
|
||||
...prev,
|
||||
}));
|
||||
},
|
||||
[save]
|
||||
);
|
||||
|
||||
const clearWords = useCallback(() => {
|
||||
save({});
|
||||
}, [save]);
|
||||
|
||||
const favList = useMemo(
|
||||
() =>
|
||||
Object.entries(favWords || {}).sort((a, b) => a[0].localeCompare(b[0])),
|
||||
[favWords]
|
||||
);
|
||||
|
||||
const wordList = useMemo(() => favList.map(([word]) => word), [favList]);
|
||||
|
||||
return { favWords, favList, wordList, toggleFav, mergeWords, clearWords };
|
||||
}
|
||||
@@ -1,40 +1,152 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
|
||||
/**
|
||||
* fetch data hook
|
||||
* @returns
|
||||
*/
|
||||
export const useFetch = (url) => {
|
||||
export const useAsync = () => {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
const execute = useCallback(async (fn, ...args) => {
|
||||
if (!fn) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`[${res.status}] ${res.statusText}`);
|
||||
}
|
||||
let data;
|
||||
if (res.headers.get("Content-Type")?.includes("json")) {
|
||||
data = await res.json();
|
||||
} else {
|
||||
data = await res.text();
|
||||
}
|
||||
setData(data);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [url]);
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
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
|
||||
* @returns
|
||||
*/
|
||||
export const useI18n = () => {
|
||||
const { uiLang } = useSetting() ?? {};
|
||||
return (key, defaultText = "") => I18N?.[key]?.[uiLang] ?? defaultText;
|
||||
const {
|
||||
setting: { uiLang },
|
||||
} = useSetting();
|
||||
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);
|
||||
};
|
||||
|
||||
10
src/hooks/InputRule.js
Normal file
10
src/hooks/InputRule.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { DEFAULT_INPUT_RULE } from "../config";
|
||||
import { useSetting } from "./Setting";
|
||||
|
||||
export function useInputRule() {
|
||||
const { setting, updateChild } = useSetting();
|
||||
const inputRule = setting?.inputRule || DEFAULT_INPUT_RULE;
|
||||
const updateInputRule = updateChild("inputRule");
|
||||
|
||||
return { inputRule, updateInputRule };
|
||||
}
|
||||
16
src/hooks/Loading.js
Normal file
16
src/hooks/Loading.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Link from "@mui/material/Link";
|
||||
import Divider from "@mui/material/Divider";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<center>
|
||||
<Divider>
|
||||
<Link
|
||||
href={process.env.REACT_APP_HOMEPAGE}
|
||||
>{`KISS Translator v${process.env.REACT_APP_VERSION}`}</Link>
|
||||
</Divider>
|
||||
<CircularProgress />
|
||||
</center>
|
||||
);
|
||||
}
|
||||
11
src/hooks/MouseHover.js
Normal file
11
src/hooks/MouseHover.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { DEFAULT_MOUSE_HOVER_SETTING } from "../config";
|
||||
import { useSetting } from "./Setting";
|
||||
|
||||
export function useMouseHoverSetting() {
|
||||
const { setting, updateChild } = useSetting();
|
||||
const mouseHoverSetting =
|
||||
setting?.mouseHoverSetting || DEFAULT_MOUSE_HOVER_SETTING;
|
||||
const updateMouseHoverSetting = updateChild("mouseHoverSetting");
|
||||
|
||||
return { mouseHoverSetting, updateMouseHoverSetting };
|
||||
}
|
||||
@@ -1,107 +1,105 @@
|
||||
import { STOKEY_RULES, DEFAULT_SUBRULES_LIST } from "../config";
|
||||
import storage from "../libs/storage";
|
||||
import { useStorages } from "./Storage";
|
||||
import { syncRules } from "../libs/sync";
|
||||
import { useSync } from "./Sync";
|
||||
import { useSetting, useSettingUpdate } from "./Setting";
|
||||
import { STOKEY_RULES, DEFAULT_RULES, KV_RULES_KEY } from "../config";
|
||||
import { useStorage } from "./Storage";
|
||||
import { checkRules } from "../libs/rules";
|
||||
import { useCallback } from "react";
|
||||
import { debounceSyncMeta } from "../libs/storage";
|
||||
|
||||
/**
|
||||
* 匹配规则增删改查 hook
|
||||
* 规则 hook
|
||||
* @returns
|
||||
*/
|
||||
export function useRules() {
|
||||
const storages = useStorages();
|
||||
const list = storages?.[STOKEY_RULES] || [];
|
||||
const sync = useSync();
|
||||
const { data: list = [], save: saveRules } = useStorage(
|
||||
STOKEY_RULES,
|
||||
DEFAULT_RULES,
|
||||
KV_RULES_KEY
|
||||
);
|
||||
|
||||
const update = async (rules) => {
|
||||
const updateAt = sync.opt?.rulesUpdateAt ? Date.now() : 0;
|
||||
await storage.setObj(STOKEY_RULES, rules);
|
||||
await sync.update({ rulesUpdateAt: updateAt });
|
||||
syncRules();
|
||||
};
|
||||
const save = useCallback(
|
||||
(objOrFn) => {
|
||||
saveRules(objOrFn);
|
||||
debounceSyncMeta(KV_RULES_KEY);
|
||||
},
|
||||
[saveRules]
|
||||
);
|
||||
|
||||
const add = async (rule) => {
|
||||
const rules = [...list];
|
||||
if (rule.pattern === "*") {
|
||||
return;
|
||||
}
|
||||
if (rules.map((item) => item.pattern).includes(rule.pattern)) {
|
||||
return;
|
||||
}
|
||||
rules.unshift(rule);
|
||||
await update(rules);
|
||||
};
|
||||
const add = useCallback(
|
||||
(rule) => {
|
||||
save((prev) => {
|
||||
if (
|
||||
rule.pattern === "*" ||
|
||||
prev.some((item) => item.pattern === rule.pattern)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return [rule, ...prev];
|
||||
});
|
||||
},
|
||||
[save]
|
||||
);
|
||||
|
||||
const del = async (pattern) => {
|
||||
let rules = [...list];
|
||||
if (pattern === "*") {
|
||||
return;
|
||||
}
|
||||
rules = rules.filter((item) => item.pattern !== pattern);
|
||||
await update(rules);
|
||||
};
|
||||
const del = useCallback(
|
||||
(pattern) => {
|
||||
save((prev) => {
|
||||
if (pattern === "*") {
|
||||
return prev;
|
||||
}
|
||||
return prev.filter((item) => item.pattern !== pattern);
|
||||
});
|
||||
},
|
||||
[save]
|
||||
);
|
||||
|
||||
const put = async (pattern, obj) => {
|
||||
const rules = [...list];
|
||||
if (pattern === "*") {
|
||||
obj.pattern = "*";
|
||||
}
|
||||
const rule = rules.find((r) => r.pattern === pattern);
|
||||
rule && Object.assign(rule, obj);
|
||||
await update(rules);
|
||||
};
|
||||
const clear = useCallback(() => {
|
||||
save((prev) => prev.filter((item) => item.pattern === "*"));
|
||||
}, [save]);
|
||||
|
||||
const merge = 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);
|
||||
}
|
||||
});
|
||||
await update(rules);
|
||||
};
|
||||
const put = useCallback(
|
||||
(pattern, obj) => {
|
||||
save((prev) => {
|
||||
// if (pattern !== obj.pattern) {
|
||||
// return prev;
|
||||
// }
|
||||
return prev.map((item) =>
|
||||
item.pattern === pattern ? { ...item, ...obj } : item
|
||||
);
|
||||
});
|
||||
},
|
||||
[save]
|
||||
);
|
||||
|
||||
return { list, add, del, put, merge };
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅规则
|
||||
* @returns
|
||||
*/
|
||||
export function useSubrules() {
|
||||
const setting = useSetting();
|
||||
const updateSetting = useSettingUpdate();
|
||||
const list = setting?.subrulesList || DEFAULT_SUBRULES_LIST;
|
||||
|
||||
const select = async (url) => {
|
||||
const subrulesList = [...list];
|
||||
subrulesList.forEach((item) => {
|
||||
if (item.url === url) {
|
||||
item.selected = true;
|
||||
} else {
|
||||
item.selected = false;
|
||||
}
|
||||
});
|
||||
await updateSetting({ subrulesList });
|
||||
};
|
||||
|
||||
const add = async (url) => {
|
||||
const subrulesList = [...list];
|
||||
subrulesList.push({ url });
|
||||
await updateSetting({ subrulesList });
|
||||
};
|
||||
|
||||
const del = async (url) => {
|
||||
let subrulesList = [...list];
|
||||
subrulesList = subrulesList.filter((item) => item.url !== url);
|
||||
await updateSetting({ subrulesList });
|
||||
};
|
||||
|
||||
return { list, select, add, del };
|
||||
const merge = useCallback(
|
||||
(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()];
|
||||
|
||||
const addsMap = new Map(adds.map((item) => [item.pattern, item]));
|
||||
const prevPatterns = new Set(prev.map((item) => item.pattern));
|
||||
const updatedPrev = prev.map(
|
||||
(prevItem) => addsMap.get(prevItem.pattern) || prevItem
|
||||
);
|
||||
const newItems = adds.filter(
|
||||
(addItem) => !prevPatterns.has(addItem.pattern)
|
||||
);
|
||||
|
||||
return [...newItems, ...updatedPrev];
|
||||
});
|
||||
},
|
||||
[save]
|
||||
);
|
||||
|
||||
return { list, add, del, clear, put, merge };
|
||||
}
|
||||
|
||||
@@ -1,28 +1,113 @@
|
||||
import { STOKEY_SETTING } from "../config";
|
||||
import storage from "../libs/storage";
|
||||
import { useStorages } from "./Storage";
|
||||
import { useSync } from "./Sync";
|
||||
import { syncSetting } from "../libs/sync";
|
||||
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 { debounceSyncMeta } from "../libs/storage";
|
||||
import Loading from "./Loading";
|
||||
import { logger } from "../libs/log";
|
||||
import { sendBgMsg } from "../libs/msg";
|
||||
import { isExt } from "../libs/client";
|
||||
|
||||
const SettingContext = createContext({
|
||||
setting: DEFAULT_SETTING,
|
||||
updateSetting: () => {},
|
||||
reloadSetting: () => {},
|
||||
});
|
||||
|
||||
export function SettingProvider({ children }) {
|
||||
const {
|
||||
data: setting,
|
||||
isLoading,
|
||||
update,
|
||||
reload,
|
||||
} = useStorage(STOKEY_SETTING, DEFAULT_SETTING, KV_SETTING_KEY);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof setting?.darkMode === "boolean") {
|
||||
update((currentSetting) => ({
|
||||
...currentSetting,
|
||||
darkMode: currentSetting.darkMode ? "dark" : "light",
|
||||
}));
|
||||
}
|
||||
}, [setting?.darkMode, update]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
logger.setLevel(setting?.logLevel);
|
||||
if (isExt) {
|
||||
await sendBgMsg(MSG_SET_LOGLEVEL, setting?.logLevel);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch log level, using default.", error);
|
||||
}
|
||||
})();
|
||||
}, [setting]);
|
||||
|
||||
const updateSetting = useCallback(
|
||||
(objOrFn) => {
|
||||
update(objOrFn);
|
||||
debounceSyncMeta(KV_SETTING_KEY);
|
||||
},
|
||||
[update]
|
||||
);
|
||||
|
||||
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={value}>{children}</SettingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置hook
|
||||
* 设置 hook
|
||||
* @returns
|
||||
*/
|
||||
export function useSetting() {
|
||||
const storages = useStorages();
|
||||
return storages?.[STOKEY_SETTING];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新设置
|
||||
* @returns
|
||||
*/
|
||||
export function useSettingUpdate() {
|
||||
const sync = useSync();
|
||||
return async (obj) => {
|
||||
const updateAt = sync.opt?.settingUpdateAt ? Date.now() : 0;
|
||||
await storage.putObj(STOKEY_SETTING, obj);
|
||||
await sync.update({ settingUpdateAt: updateAt });
|
||||
syncSetting();
|
||||
};
|
||||
return useContext(SettingContext);
|
||||
}
|
||||
|
||||
20
src/hooks/Shortcut.js
Normal file
20
src/hooks/Shortcut.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useCallback } from "react";
|
||||
import { DEFAULT_SHORTCUTS } from "../config";
|
||||
import { useSetting } from "./Setting";
|
||||
|
||||
export function useShortcut(action) {
|
||||
const { setting, updateSetting } = useSetting();
|
||||
const shortcuts = setting?.shortcuts || DEFAULT_SHORTCUTS;
|
||||
const shortcut = shortcuts[action] || [];
|
||||
const setShortcut = useCallback(
|
||||
(val) => {
|
||||
updateSetting((prev) => ({
|
||||
...prev,
|
||||
shortcuts: { ...(prev?.shortcuts || {}), [action]: val },
|
||||
}));
|
||||
},
|
||||
[action, updateSetting]
|
||||
);
|
||||
|
||||
return { shortcut, setShortcut };
|
||||
}
|
||||
@@ -1,91 +1,144 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { browser, isExt, isGm, isWeb } from "../libs/browser";
|
||||
import {
|
||||
STOKEY_SETTING,
|
||||
STOKEY_RULES,
|
||||
STOKEY_SYNC,
|
||||
DEFAULT_SETTING,
|
||||
DEFAULT_RULES,
|
||||
DEFAULT_SYNC,
|
||||
} from "../config";
|
||||
import storage from "../libs/storage";
|
||||
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 {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 const defaultStorage = {
|
||||
[STOKEY_SETTING]: DEFAULT_SETTING,
|
||||
[STOKEY_RULES]: DEFAULT_RULES,
|
||||
[STOKEY_SYNC]: DEFAULT_SYNC,
|
||||
};
|
||||
|
||||
const activeKeys = Object.keys(defaultStorage);
|
||||
|
||||
const StoragesContext = createContext(null);
|
||||
|
||||
export function StoragesProvider({ children }) {
|
||||
const [storages, setStorages] = useState(null);
|
||||
|
||||
const handleChanged = (changes) => {
|
||||
if (isWeb || isGm) {
|
||||
const { key, oldValue, newValue } = changes;
|
||||
changes = {
|
||||
[key]: {
|
||||
oldValue,
|
||||
newValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
const newStorages = {};
|
||||
Object.entries(changes)
|
||||
.filter(
|
||||
([key, { oldValue, newValue }]) =>
|
||||
activeKeys.includes(key) && oldValue !== newValue
|
||||
)
|
||||
.forEach(([key, { newValue }]) => {
|
||||
newStorages[key] = JSON.parse(newValue);
|
||||
});
|
||||
if (Object.keys(newStorages).length !== 0) {
|
||||
setStorages((pre) => ({ ...pre, ...newStorages }));
|
||||
}
|
||||
};
|
||||
export function useStorage(key, defaultVal = null, syncKey = "") {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [data, setData] = useState(defaultVal);
|
||||
|
||||
// 首次加载数据
|
||||
useEffect(() => {
|
||||
// 首次从storage同步配置到内存
|
||||
(async () => {
|
||||
const curStorages = {};
|
||||
for (const key of activeKeys) {
|
||||
const val = await storage.get(key);
|
||||
if (val) {
|
||||
curStorages[key] = JSON.parse(val);
|
||||
} else {
|
||||
await storage.setObj(key, defaultStorage[key]);
|
||||
curStorages[key] = defaultStorage[key];
|
||||
let isMounted = true;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
setStorages(curStorages);
|
||||
})();
|
||||
|
||||
// 监听storage,并同步到内存中
|
||||
storage.onChanged(handleChanged);
|
||||
|
||||
// 解除监听
|
||||
return () => {
|
||||
if (isExt) {
|
||||
browser.storage.onChanged.removeListener(handleChanged);
|
||||
} else {
|
||||
window.removeEventListener("storage", handleChanged);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StoragesContext.Provider value={storages}>
|
||||
{children}
|
||||
</StoragesContext.Provider>
|
||||
);
|
||||
}
|
||||
const debouncedSync = useDebouncedCallback(runSync, 3000);
|
||||
|
||||
export function useStorages() {
|
||||
return useContext(StoragesContext);
|
||||
// 持久化
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
storage.setObj(key, data).catch((err) => {
|
||||
kissLog(`storage save error for key: ${key}`, err);
|
||||
});
|
||||
|
||||
// 触发远端同步
|
||||
if (syncKey) {
|
||||
debouncedSync(syncKey, data);
|
||||
}
|
||||
}, [key, syncKey, isLoading, data, debouncedSync]);
|
||||
|
||||
/**
|
||||
* 全量替换状态值
|
||||
* @param {any | ((prevData: any) => any)} valueOrFn 新的值或一个返回新值的函数。
|
||||
*/
|
||||
const save = useCallback((valueOrFn) => {
|
||||
// kissLog("save storage:", valueOrFn);
|
||||
setData((prevData) =>
|
||||
typeof valueOrFn === "function" ? valueOrFn(prevData) : valueOrFn
|
||||
);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 合并对象到当前状态(假设状态是一个对象)。
|
||||
* @param {object | ((prevData: object) => object)} partialDataOrFn 要合并的对象或一个返回该对象的函数。
|
||||
*/
|
||||
const update = useCallback((partialDataOrFn) => {
|
||||
// kissLog("update storage:", partialDataOrFn);
|
||||
setData((prevData) => {
|
||||
const partialData =
|
||||
typeof partialDataOrFn === "function"
|
||||
? partialDataOrFn(prevData)
|
||||
: partialDataOrFn;
|
||||
// 确保 preData 是一个对象,避免展开 null 或 undefined
|
||||
const baseObj =
|
||||
typeof prevData === "object" && prevData !== null ? prevData : {};
|
||||
return { ...baseObj, ...partialData };
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 从 Storage 中删除该值,并将状态重置为 null。
|
||||
*/
|
||||
const remove = useCallback(async () => {
|
||||
// kissLog("remove storage:");
|
||||
try {
|
||||
await storage.del(key);
|
||||
setData(null);
|
||||
} catch (err) {
|
||||
kissLog(`storage remove error for key: ${key}`, err);
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
/**
|
||||
* 从 Storage 重新加载数据以覆盖当前状态。
|
||||
*/
|
||||
const reload = useCallback(async () => {
|
||||
// kissLog("reload storage:");
|
||||
try {
|
||||
const storedVal = await storage.getObj(key);
|
||||
setData(storedVal ?? defaultVal);
|
||||
} catch (err) {
|
||||
kissLog(`storage reload error for key: ${key}`, err);
|
||||
// setData(defaultVal);
|
||||
}
|
||||
}, [key, defaultVal]);
|
||||
|
||||
return { data, save, update, remove, reload, isLoading };
|
||||
}
|
||||
|
||||
92
src/hooks/SubRules.js
Normal file
92
src/hooks/SubRules.js
Normal file
@@ -0,0 +1,92 @@
|
||||
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 { kissLog } from "../libs/log";
|
||||
|
||||
/**
|
||||
* 订阅规则
|
||||
* @returns
|
||||
*/
|
||||
export function useSubRules() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedRules, setSelectedRules] = useState([]);
|
||||
const { setting, updateSetting } = useSetting();
|
||||
const list = setting?.subrulesList || DEFAULT_SUBRULES_LIST;
|
||||
|
||||
const selectedSub = useMemo(() => list.find((item) => item.selected), [list]);
|
||||
const selectedUrl = selectedSub.url;
|
||||
|
||||
const selectSub = useCallback(
|
||||
(url) => {
|
||||
updateSetting((prev) => ({
|
||||
...prev,
|
||||
subrulesList: prev.subrulesList.map((item) => ({
|
||||
...item,
|
||||
selected: item.url === url,
|
||||
})),
|
||||
}));
|
||||
},
|
||||
[updateSetting]
|
||||
);
|
||||
|
||||
const addSub = useCallback(
|
||||
(url) => {
|
||||
updateSetting((prev) => ({
|
||||
...prev,
|
||||
subrulesList: [...prev.subrulesList, { url, selected: false }],
|
||||
}));
|
||||
},
|
||||
[updateSetting]
|
||||
);
|
||||
|
||||
const delSub = useCallback(
|
||||
(url) => {
|
||||
updateSetting((prev) => ({
|
||||
...prev,
|
||||
subrulesList: prev.subrulesList.filter((item) => item.url !== url),
|
||||
}));
|
||||
},
|
||||
[updateSetting]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (selectedUrl) {
|
||||
try {
|
||||
setLoading(true);
|
||||
const rules = await loadOrFetchSubRules(selectedUrl);
|
||||
setSelectedRules(rules);
|
||||
} catch (err) {
|
||||
kissLog("loadOrFetchSubRules", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [selectedUrl]);
|
||||
|
||||
return {
|
||||
subList: list,
|
||||
selectSub,
|
||||
addSub,
|
||||
delSub,
|
||||
selectedSub,
|
||||
selectedUrl,
|
||||
selectedRules,
|
||||
setSelectedRules,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 覆写订阅规则
|
||||
* @returns
|
||||
*/
|
||||
export function useOwSubRule() {
|
||||
const { setting, updateChild } = useSetting();
|
||||
const owSubrule = setting?.owSubrule || DEFAULT_OW_RULE;
|
||||
const updateOwSubrule = updateChild("owSubrule");
|
||||
|
||||
return { owSubrule, updateOwSubrule };
|
||||
}
|
||||
10
src/hooks/Subtitle.js
Normal file
10
src/hooks/Subtitle.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { DEFAULT_SUBTITLE_SETTING } from "../config";
|
||||
import { useSetting } from "./Setting";
|
||||
|
||||
export function useSubtitle() {
|
||||
const { setting, updateChild } = useSetting();
|
||||
const subtitleSetting = setting?.subtitleSetting || DEFAULT_SUBTITLE_SETTING;
|
||||
const updateSubtitle = updateChild("subtitleSetting");
|
||||
|
||||
return { subtitleSetting, updateSubtitle };
|
||||
}
|
||||
@@ -1,20 +1,79 @@
|
||||
import { useCallback } from "react";
|
||||
import { STOKEY_SYNC } from "../config";
|
||||
import storage from "../libs/storage";
|
||||
import { useStorages } from "./Storage";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { STOKEY_SYNC, DEFAULT_SYNC } from "../config";
|
||||
import { useStorage } from "./Storage";
|
||||
|
||||
/**
|
||||
* sync hook
|
||||
* @returns
|
||||
*/
|
||||
export function useSync() {
|
||||
const storages = useStorages();
|
||||
const opt = storages?.[STOKEY_SYNC];
|
||||
const update = useCallback(async (obj) => {
|
||||
await storage.putObj(STOKEY_SYNC, obj);
|
||||
}, []);
|
||||
const { data, update, reload } = useStorage(STOKEY_SYNC, DEFAULT_SYNC);
|
||||
return { sync: data, updateSync: update, reloadSync: reload };
|
||||
}
|
||||
|
||||
/**
|
||||
* update syncmeta hook
|
||||
* @returns
|
||||
*/
|
||||
export function useSyncMeta() {
|
||||
const { updateSync } = useSync();
|
||||
|
||||
const updateSyncMeta = useCallback(
|
||||
(key) => {
|
||||
updateSync((prevSync) => {
|
||||
const newSyncMeta = {
|
||||
...(prevSync?.syncMeta || {}),
|
||||
[key]: {
|
||||
...(prevSync?.syncMeta?.[key] || {}),
|
||||
updateAt: Date.now(),
|
||||
},
|
||||
};
|
||||
return { syncMeta: newSyncMeta };
|
||||
});
|
||||
},
|
||||
[updateSync]
|
||||
);
|
||||
|
||||
return { updateSyncMeta };
|
||||
}
|
||||
|
||||
/**
|
||||
* caches sync hook
|
||||
* @param {*} url
|
||||
* @returns
|
||||
*/
|
||||
export function useSyncCaches() {
|
||||
const { sync, updateSync, reloadSync } = useSync();
|
||||
|
||||
const updateDataCache = useCallback(
|
||||
(url) => {
|
||||
updateSync((prevSync) => ({
|
||||
dataCaches: {
|
||||
...(prevSync?.dataCaches || {}),
|
||||
[url]: Date.now(),
|
||||
},
|
||||
}));
|
||||
},
|
||||
[updateSync]
|
||||
);
|
||||
|
||||
const deleteDataCache = useCallback(
|
||||
(url) => {
|
||||
updateSync((prevSync) => {
|
||||
const newDataCaches = { ...(prevSync?.dataCaches || {}) };
|
||||
delete newDataCaches[url];
|
||||
return { dataCaches: newDataCaches };
|
||||
});
|
||||
},
|
||||
[updateSync]
|
||||
);
|
||||
|
||||
const dataCaches = useMemo(() => sync?.dataCaches || {}, [sync?.dataCaches]);
|
||||
|
||||
return {
|
||||
opt,
|
||||
update,
|
||||
dataCaches,
|
||||
updateDataCache,
|
||||
deleteDataCache,
|
||||
reloadSync,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import { CssBaseline, GlobalStyles } from "@mui/material";
|
||||
import { useDarkMode } from "./ColorMode";
|
||||
import { THEME_DARK, THEME_LIGHT } from "../config";
|
||||
|
||||
@@ -9,21 +9,54 @@ import { THEME_DARK, THEME_LIGHT } from "../config";
|
||||
* @param {*} param0
|
||||
* @returns
|
||||
*/
|
||||
export default function MuiThemeProvider({ children, options }) {
|
||||
const darkMode = useDarkMode();
|
||||
export default function Theme({ children, options, styles }) {
|
||||
const { darkMode } = useDarkMode();
|
||||
const [systemMode, setSystemMode] = useState(THEME_LIGHT);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function") {
|
||||
return;
|
||||
}
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = () => {
|
||||
setSystemMode(mediaQuery.matches ? THEME_DARK : THEME_LIGHT);
|
||||
};
|
||||
handleChange(); // Set initial value
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}, []);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
let htmlFontSize = 16;
|
||||
try {
|
||||
const s = window.getComputedStyle(document.body.parentNode).fontSize;
|
||||
const fontSize = parseInt(s.replace("px", ""));
|
||||
if (fontSize > 0 && fontSize < 1000) {
|
||||
htmlFontSize = fontSize;
|
||||
}
|
||||
} catch (err) {
|
||||
//
|
||||
}
|
||||
|
||||
const isDarkMode =
|
||||
darkMode === "dark" || (darkMode === "auto" && systemMode === THEME_DARK);
|
||||
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode: darkMode ? THEME_DARK : THEME_LIGHT,
|
||||
mode: isDarkMode ? THEME_DARK : THEME_LIGHT,
|
||||
},
|
||||
typography: {
|
||||
htmlFontSize,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}, [darkMode, options]);
|
||||
}, [darkMode, options, systemMode]);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
|
||||
<CssBaseline />
|
||||
<GlobalStyles styles={styles} />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
10
src/hooks/Tranbox.js
Normal file
10
src/hooks/Tranbox.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { DEFAULT_TRANBOX_SETTING } from "../config";
|
||||
import { useSetting } from "./Setting";
|
||||
|
||||
export function useTranbox() {
|
||||
const { setting, updateChild } = useSetting();
|
||||
const tranboxSetting = setting?.tranboxSetting || DEFAULT_TRANBOX_SETTING;
|
||||
const updateTranbox = updateChild("tranboxSetting");
|
||||
|
||||
return { tranboxSetting, updateTranbox };
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { detectLang } from "../libs";
|
||||
import { apiTranslate } from "../apis";
|
||||
|
||||
/**
|
||||
* 翻译hook
|
||||
* @param {*} q
|
||||
* @param {*} rule
|
||||
* @returns
|
||||
*/
|
||||
export function useTranslate(q, rule) {
|
||||
const [text, setText] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sameLang, setSamelang] = useState(false);
|
||||
|
||||
const { translator, fromLang, toLang } = rule;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const deLang = await detectLang(q);
|
||||
if (toLang.includes(deLang)) {
|
||||
setSamelang(true);
|
||||
} else {
|
||||
const [trText, isSame] = await apiTranslate({
|
||||
translator,
|
||||
q,
|
||||
fromLang,
|
||||
toLang,
|
||||
});
|
||||
setText(trText);
|
||||
setSamelang(isSame);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[translate]", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [q, translator, fromLang, toLang]);
|
||||
|
||||
return { text, sameLang, loading };
|
||||
}
|
||||
61
src/hooks/ValidationInput.js
Normal file
61
src/hooks/ValidationInput.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { limitNumber, limitFloat } from "../libs/utils";
|
||||
|
||||
function ValidationInput({
|
||||
value,
|
||||
onChange,
|
||||
name,
|
||||
min,
|
||||
max,
|
||||
isFloat = false,
|
||||
...props
|
||||
}) {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleLocalChange = (e) => {
|
||||
setLocalValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
const numValue = Number(localValue);
|
||||
|
||||
if (isNaN(numValue)) {
|
||||
setLocalValue(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedValue = isFloat
|
||||
? limitFloat(numValue, min, max)
|
||||
: limitNumber(numValue, min, max);
|
||||
|
||||
if (validatedValue !== numValue) {
|
||||
setLocalValue(validatedValue);
|
||||
}
|
||||
|
||||
onChange({
|
||||
target: {
|
||||
name: name,
|
||||
value: validatedValue,
|
||||
},
|
||||
preventDefault: () => {},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<TextField
|
||||
{...props}
|
||||
type="number"
|
||||
name={name}
|
||||
value={localValue}
|
||||
onChange={handleLocalChange}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ValidationInput;
|
||||
29
src/hooks/WindowSize.js
Normal file
29
src/hooks/WindowSize.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useDebouncedCallback } from "./DebouncedCallback";
|
||||
|
||||
function useWindowSize() {
|
||||
const [windowSize, setWindowSize] = useState({
|
||||
w: window.innerWidth,
|
||||
h: window.innerHeight,
|
||||
});
|
||||
|
||||
const debounceWindowResize = useDebouncedCallback(() => {
|
||||
setWindowSize({
|
||||
w: window.innerWidth,
|
||||
h: window.innerHeight,
|
||||
});
|
||||
}, 200);
|
||||
|
||||
useEffect(() => {
|
||||
debounceWindowResize();
|
||||
|
||||
window.addEventListener("resize", debounceWindowResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", debounceWindowResize);
|
||||
};
|
||||
}, [debounceWindowResize]);
|
||||
|
||||
return windowSize;
|
||||
}
|
||||
|
||||
export default useWindowSize;
|
||||
41
src/index.js
41
src/index.js
@@ -1,25 +1,54 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import { useFetch } from "./hooks/Fetch";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Button from "@mui/material/Button";
|
||||
import Link from "@mui/material/Link";
|
||||
import { useGet } from "./hooks/Fetch";
|
||||
import { I18N, URL_RAW_PREFIX } from "./config";
|
||||
|
||||
function App() {
|
||||
const [data, loading, error] = useFetch(
|
||||
`${URL_RAW_PREFIX}/${I18N?.["about_md"]?.["zh"]}`
|
||||
const [lang, setLang] = useState("zh");
|
||||
const { data, loading, error } = useGet(
|
||||
`${URL_RAW_PREFIX}/${I18N?.["about_md"]?.[lang]}`
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper sx={{ padding: 2, margin: 2 }}>
|
||||
<Divider>{`KISS Translator v${process.env.REACT_APP_VERSION}`}</Divider>
|
||||
<Stack spacing={2} direction="row" justifyContent="flex-end">
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
setLang((pre) => (pre === "zh" ? "en" : "zh"));
|
||||
}}
|
||||
>
|
||||
{lang === "zh" ? "ENGLISH" : "中文"}
|
||||
</Button>
|
||||
</Stack>
|
||||
<Divider>
|
||||
<Link
|
||||
href={process.env.REACT_APP_HOMEPAGE}
|
||||
>{`KISS Translator v${process.env.REACT_APP_VERSION}`}</Link>
|
||||
</Divider>
|
||||
<Stack spacing={2}>
|
||||
<Link href={process.env.REACT_APP_USERSCRIPT_DOWNLOADURL}>
|
||||
Install/Update Userscript for Tampermonkey/Violentmonkey
|
||||
</Link>
|
||||
<Link href={process.env.REACT_APP_USERSCRIPT_IOS_DOWNLOADURL}>
|
||||
Install/Update Userscript for iOS Safari
|
||||
</Link>
|
||||
<Link href={process.env.REACT_APP_OPTIONSPAGE}>Open Options Page</Link>
|
||||
</Stack>
|
||||
|
||||
{loading ? (
|
||||
<center>
|
||||
<CircularProgress />
|
||||
</center>
|
||||
) : (
|
||||
<ReactMarkdown children={error ? error.message : data} />
|
||||
<ReactMarkdown children={error || data} />
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
|
||||
3
src/injector-shadowroot.js
Normal file
3
src/injector-shadowroot.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { shadowRootInjector } from "./injectors/shadowroot";
|
||||
|
||||
shadowRootInjector();
|
||||
3
src/injector-subtitle.js
Normal file
3
src/injector-subtitle.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { XMLHttpRequestInjector } from "./injectors/xmlhttp";
|
||||
|
||||
XMLHttpRequestInjector();
|
||||
27
src/injectors/index.js
Normal file
27
src/injectors/index.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { browser } from "../libs/browser";
|
||||
import { isExt } from "../libs/client";
|
||||
import { injectExternalJs, injectInlineJs } from "../libs/injector";
|
||||
import { shadowRootInjector } from "./shadowroot";
|
||||
import { XMLHttpRequestInjector } from "./xmlhttp";
|
||||
|
||||
export const INJECTOR = {
|
||||
subtitle: "injector-subtitle.js",
|
||||
shadowroot: "injector-shadowroot.js",
|
||||
};
|
||||
|
||||
const injectorMap = {
|
||||
[INJECTOR.subtitle]: XMLHttpRequestInjector,
|
||||
[INJECTOR.shadowroot]: shadowRootInjector,
|
||||
};
|
||||
|
||||
export function injectJs(name, id = "kiss-translator-inject-js") {
|
||||
const injector = injectorMap[name];
|
||||
if (!injector) return;
|
||||
|
||||
if (isExt) {
|
||||
const src = browser.runtime.getURL(name);
|
||||
injectExternalJs(src, id);
|
||||
} else {
|
||||
injectInlineJs(`(${injector})()`, id);
|
||||
}
|
||||
}
|
||||
12
src/injectors/shadowroot.js
Normal file
12
src/injectors/shadowroot.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export const shadowRootInjector = () => {
|
||||
try {
|
||||
const orig = Element.prototype.attachShadow;
|
||||
Element.prototype.attachShadow = function (...args) {
|
||||
const root = orig.apply(this, args);
|
||||
window.postMessage({ type: "KISS_SHADOW_ROOT_CREATED" }, "*");
|
||||
return root;
|
||||
};
|
||||
} catch (err) {
|
||||
console.log("shadowRootInjector", err);
|
||||
}
|
||||
};
|
||||
23
src/injectors/xmlhttp.js
Normal file
23
src/injectors/xmlhttp.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export const XMLHttpRequestInjector = () => {
|
||||
try {
|
||||
const originalOpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function (...args) {
|
||||
const url = args[1];
|
||||
if (typeof url === "string" && url.includes("timedtext")) {
|
||||
this.addEventListener("load", function () {
|
||||
window.postMessage(
|
||||
{
|
||||
type: "KISS_XHR_DATA_YOUTUBE",
|
||||
url: this.responseURL,
|
||||
response: this.responseText,
|
||||
},
|
||||
window.location.origin
|
||||
);
|
||||
});
|
||||
}
|
||||
return originalOpen.apply(this, args);
|
||||
};
|
||||
} catch (err) {
|
||||
console.log("XMLHttpRequestInjector", err);
|
||||
}
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
import storage from "./storage";
|
||||
import { STOKEY_MSAUTH, URL_MICROSOFT_AUTH } from "../config";
|
||||
import { fetchData } from "./fetch";
|
||||
import { getMsauth, setMsauth } from "./storage";
|
||||
import { kissLog } from "./log";
|
||||
import { apiMsAuth } from "../apis";
|
||||
|
||||
const parseMSToken = (token) => {
|
||||
try {
|
||||
return JSON.parse(atob(token.split(".")[1])).exp;
|
||||
} catch (err) {
|
||||
console.log("[parseMSToken]", err);
|
||||
kissLog("parseMSToken", err);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
@@ -16,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 storage.getObj(STOKEY_MSAUTH)) || {};
|
||||
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 storage.setObj(STOKEY_MSAUTH, { token, exp });
|
||||
return [token, exp];
|
||||
tokenPromise = fetchNewToken();
|
||||
const result = await tokenPromise;
|
||||
return result.token;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
152
src/libs/batchQueue.js
Normal file
152
src/libs/batchQueue.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
DEFAULT_BATCH_INTERVAL,
|
||||
DEFAULT_BATCH_SIZE,
|
||||
DEFAULT_BATCH_LENGTH,
|
||||
} from "../config";
|
||||
|
||||
/**
|
||||
* 批处理队列
|
||||
* @param {*} args
|
||||
* @param {*} param1
|
||||
* @returns
|
||||
*/
|
||||
const BatchQueue = (
|
||||
taskFn,
|
||||
{
|
||||
batchInterval = DEFAULT_BATCH_INTERVAL,
|
||||
batchSize = DEFAULT_BATCH_SIZE,
|
||||
batchLength = DEFAULT_BATCH_LENGTH,
|
||||
} = {}
|
||||
) => {
|
||||
const queue = [];
|
||||
let isProcessing = false;
|
||||
let timer = null;
|
||||
|
||||
const sendBatchRequest = async (payloads, batchArgs) => {
|
||||
return taskFn(payloads, batchArgs);
|
||||
};
|
||||
|
||||
const processQueue = async () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
if (queue.length === 0 || isProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing = true;
|
||||
|
||||
let tasksToProcess = [];
|
||||
let currentBatchLength = 0;
|
||||
let endIndex = 0;
|
||||
|
||||
for (const task of queue) {
|
||||
const textLength = task.payload?.length || 0;
|
||||
if (
|
||||
endIndex >= batchSize ||
|
||||
(currentBatchLength + textLength > batchLength && endIndex > 0)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
currentBatchLength += textLength;
|
||||
endIndex++;
|
||||
}
|
||||
|
||||
if (endIndex > 0) {
|
||||
tasksToProcess = queue.splice(0, endIndex);
|
||||
}
|
||||
|
||||
if (tasksToProcess.length === 0) {
|
||||
isProcessing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payloads = tasksToProcess.map((item) => item.payload);
|
||||
const batchArgs = tasksToProcess[0].args;
|
||||
const responses = await sendBatchRequest(payloads, batchArgs);
|
||||
if (!Array.isArray(responses)) {
|
||||
throw new Error("responses format error");
|
||||
}
|
||||
|
||||
tasksToProcess.forEach((taskItem, index) => {
|
||||
const response = responses[index];
|
||||
if (response) {
|
||||
taskItem.resolve(response);
|
||||
} else {
|
||||
taskItem.reject(new Error(`No response for item at index ${index}`));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
tasksToProcess.forEach((taskItem) => taskItem.reject(error));
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
if (queue.length > 0) {
|
||||
if (queue.length >= batchSize) {
|
||||
setTimeout(processQueue, 0);
|
||||
} else {
|
||||
scheduleProcessing();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleProcessing = () => {
|
||||
if (!isProcessing && !timer && queue.length > 0) {
|
||||
timer = setTimeout(processQueue, batchInterval);
|
||||
}
|
||||
};
|
||||
|
||||
const addTask = (data, args) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = data;
|
||||
queue.push({ payload, resolve, reject, args });
|
||||
|
||||
if (queue.length >= batchSize) {
|
||||
processQueue();
|
||||
} else {
|
||||
scheduleProcessing();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
queue.forEach((task) =>
|
||||
task.reject(new Error("Queue instance was destroyed."))
|
||||
);
|
||||
queue.length = 0;
|
||||
};
|
||||
|
||||
return { addTask, destroy };
|
||||
};
|
||||
|
||||
// 实例字典
|
||||
const queueMap = new Map();
|
||||
|
||||
/**
|
||||
* 获取批处理实例
|
||||
*/
|
||||
export const getBatchQueue = (key, taskFn, options) => {
|
||||
if (queueMap.has(key)) {
|
||||
return queueMap.get(key);
|
||||
}
|
||||
|
||||
const queue = BatchQueue(taskFn, options);
|
||||
queueMap.set(key, queue);
|
||||
return queue;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除所有任务
|
||||
*/
|
||||
export const clearAllBatchQueue = () => {
|
||||
for (const queue of queueMap.values()) {
|
||||
queue.destroy();
|
||||
}
|
||||
};
|
||||
10
src/libs/blacklist.js
Normal file
10
src/libs/blacklist.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { isMatch } from "./utils";
|
||||
|
||||
/**
|
||||
* 检查是否在黑名单中
|
||||
* @param {*} href
|
||||
* @param {*} param1
|
||||
* @returns
|
||||
*/
|
||||
export const isInBlacklist = (href, { blacklist }) =>
|
||||
blacklist.split(/\n|,/).some((url) => isMatch(href, url.trim()));
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CLIENT_EXTS, CLIENT_USERSCRIPT, CLIENT_WEB } from "../config";
|
||||
// import { CLIENT_EXTS, CLIENT_USERSCRIPT, CLIENT_WEB } from "../config";
|
||||
|
||||
/**
|
||||
* 浏览器兼容插件,另可用于判断是插件模式还是网页模式,方便开发
|
||||
@@ -8,12 +8,13 @@ function _browser() {
|
||||
try {
|
||||
return require("webextension-polyfill");
|
||||
} catch (err) {
|
||||
// console.log("[browser]", err.message);
|
||||
// kissLog("browser", err);
|
||||
}
|
||||
}
|
||||
|
||||
export const browser = _browser();
|
||||
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 isBg = () => globalThis?.ContextType === "BACKGROUND";
|
||||
|
||||
export const isBuiltinAIAvailable =
|
||||
"LanguageDetector" in globalThis && "Translator" in globalThis;
|
||||
|
||||
168
src/libs/builtinAI.js
Normal file
168
src/libs/builtinAI.js
Normal file
@@ -0,0 +1,168 @@
|
||||
import { kissLog, logger } from "./log";
|
||||
|
||||
/**
|
||||
* Chrome 浏览器内置翻译
|
||||
*/
|
||||
class ChromeTranslator {
|
||||
#translatorMap = new Map();
|
||||
#detectorPromise = null;
|
||||
|
||||
constructor(options = {}) {
|
||||
this.onProgress = options.onProgress || this.#defaultProgressHandler;
|
||||
}
|
||||
|
||||
#defaultProgressHandler(type, progress) {
|
||||
kissLog(`Downloading ${type} model: ${progress}%`);
|
||||
}
|
||||
|
||||
#getDetectorPromise() {
|
||||
if (!this.#detectorPromise) {
|
||||
this.#detectorPromise = (async () => {
|
||||
try {
|
||||
const availability = await LanguageDetector.availability();
|
||||
if (availability === "unavailable") {
|
||||
throw new Error("LanguageDetector unavailable");
|
||||
}
|
||||
|
||||
return await LanguageDetector.create({
|
||||
monitor: (m) => this._monitorProgress(m, "detector"),
|
||||
});
|
||||
} catch (error) {
|
||||
this.#detectorPromise = null;
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return this.#detectorPromise;
|
||||
}
|
||||
|
||||
#createTranslator(sourceLanguage, targetLanguage) {
|
||||
const key = `${sourceLanguage}_${targetLanguage}`;
|
||||
if (this.#translatorMap.has(key)) {
|
||||
return this.#translatorMap.get(key);
|
||||
}
|
||||
|
||||
const translatorPromise = (async () => {
|
||||
try {
|
||||
const avail = await Translator.availability({
|
||||
sourceLanguage,
|
||||
targetLanguage,
|
||||
});
|
||||
if (avail === "unavailable") {
|
||||
throw new Error(
|
||||
`Translator ${sourceLanguage}_${targetLanguage} unavailable`
|
||||
);
|
||||
}
|
||||
|
||||
const translator = await Translator.create({
|
||||
sourceLanguage,
|
||||
targetLanguage,
|
||||
monitor: (m) => this._monitorProgress(m, `translator (${key})`),
|
||||
});
|
||||
this.#translatorMap.set(key, translator);
|
||||
|
||||
return translator;
|
||||
} catch (error) {
|
||||
this.#translatorMap.delete(key);
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
this.#translatorMap.set(key, translatorPromise);
|
||||
return translatorPromise;
|
||||
}
|
||||
|
||||
_monitorProgress(monitorable, type) {
|
||||
monitorable.addEventListener("downloadprogress", (e) => {
|
||||
const progress = e.total > 0 ? Math.round((e.loaded / e.total) * 100) : 0;
|
||||
this.onProgress(type, progress);
|
||||
});
|
||||
}
|
||||
|
||||
async detectLanguage(text, confidenceThreshold = 0.4) {
|
||||
if (!text) {
|
||||
return ["", "Input text cannot be empty."];
|
||||
}
|
||||
|
||||
try {
|
||||
const detector = await this.#getDetectorPromise();
|
||||
const results = await detector.detect(text);
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
return ["", "No language could be detected."];
|
||||
}
|
||||
|
||||
const { detectedLanguage, confidence } = results[0];
|
||||
if (confidence < confidenceThreshold) {
|
||||
return [
|
||||
"",
|
||||
`Confidence of test results (${detectedLanguage} ${confidence.toFixed(
|
||||
2
|
||||
)}) below the set threshold ${confidenceThreshold}。`,
|
||||
];
|
||||
}
|
||||
|
||||
return [detectedLanguage, ""];
|
||||
} catch (error) {
|
||||
kissLog("detectLanguage", error, `(${text})`);
|
||||
return ["", error.message];
|
||||
}
|
||||
}
|
||||
|
||||
async translateText(text, targetLanguage, sourceLanguage = "auto") {
|
||||
if (!text || !targetLanguage || typeof text !== "string") {
|
||||
return ["", sourceLanguage, "Input text cannot be empty."];
|
||||
}
|
||||
|
||||
try {
|
||||
let finalSourceLanguage = sourceLanguage;
|
||||
if (sourceLanguage === "auto") {
|
||||
const [detectedLanguage, detectionError] =
|
||||
await this.detectLanguage(text);
|
||||
if (detectionError || !detectedLanguage) {
|
||||
const reason =
|
||||
detectionError || "Unable to determine source language.";
|
||||
return [
|
||||
"",
|
||||
finalSourceLanguage,
|
||||
`Automatic detection of source language failed: ${reason}`,
|
||||
];
|
||||
}
|
||||
finalSourceLanguage = detectedLanguage;
|
||||
}
|
||||
|
||||
if (finalSourceLanguage === targetLanguage) {
|
||||
return ["", finalSourceLanguage, "Same lang"];
|
||||
}
|
||||
|
||||
const translator = await this.#createTranslator(
|
||||
finalSourceLanguage,
|
||||
targetLanguage
|
||||
);
|
||||
const translatedText = await translator.translate(text);
|
||||
|
||||
return [translatedText, finalSourceLanguage, ""];
|
||||
} catch (error) {
|
||||
kissLog("translateText", error, `(${text})`);
|
||||
|
||||
if (
|
||||
error &&
|
||||
error.message &&
|
||||
error.message.includes("Other generic failures occurred")
|
||||
) {
|
||||
logger.info("Generic failure detected, resetting translator cache.");
|
||||
this.#translatorMap.clear();
|
||||
}
|
||||
|
||||
return ["", sourceLanguage, error.message];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chromeTranslator = new ChromeTranslator();
|
||||
|
||||
export const chromeDetect = (args) =>
|
||||
chromeTranslator.detectLanguage(args.text);
|
||||
export const chromeTranslate = (args) =>
|
||||
chromeTranslator.translateText(args.text, args.to, args.from);
|
||||
159
src/libs/cache.js
Normal file
159
src/libs/cache.js
Normal file
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
CACHE_NAME,
|
||||
DEFAULT_CACHE_TIMEOUT,
|
||||
MSG_CLEAR_CACHES,
|
||||
MSG_GET_HTTPCACHE,
|
||||
MSG_PUT_HTTPCACHE,
|
||||
} from "../config";
|
||||
import { kissLog } from "./log";
|
||||
import { isExt } from "./client";
|
||||
import { isBg } from "./browser";
|
||||
import { sendBgMsg } from "./msg";
|
||||
import { blobToBase64 } from "./utils";
|
||||
|
||||
/**
|
||||
* 清除缓存数据
|
||||
*/
|
||||
export const tryClearCaches = async () => {
|
||||
try {
|
||||
if (isExt && !isBg) {
|
||||
await sendBgMsg(MSG_CLEAR_CACHES);
|
||||
} else {
|
||||
await caches.delete(CACHE_NAME);
|
||||
}
|
||||
} catch (err) {
|
||||
kissLog("clean caches", err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 构造缓存 request
|
||||
* @param {*} input
|
||||
* @param {*} init
|
||||
* @returns
|
||||
*/
|
||||
const newCacheReq = async (input, init) => {
|
||||
let request = new Request(input, init);
|
||||
if (request.method !== "GET") {
|
||||
const body = await request.text();
|
||||
const cacheUrl = new URL(request.url);
|
||||
cacheUrl.pathname += body;
|
||||
request = new Request(cacheUrl.toString(), { method: "GET" });
|
||||
}
|
||||
|
||||
return request;
|
||||
};
|
||||
|
||||
/**
|
||||
* 查询 caches
|
||||
* @param {*} input
|
||||
* @param {*} init
|
||||
* @returns
|
||||
*/
|
||||
export const getHttpCache = async ({ input, init }) => {
|
||||
try {
|
||||
const request = await newCacheReq(input, init);
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const response = await cache.match(request);
|
||||
if (response) {
|
||||
const res = await parseResponse(response);
|
||||
return res;
|
||||
}
|
||||
} catch (err) {
|
||||
kissLog("get cache", err);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 插入 caches
|
||||
* @param {*} input
|
||||
* @param {*} init
|
||||
* @param {*} data
|
||||
*/
|
||||
export const putHttpCache = async ({
|
||||
input,
|
||||
init,
|
||||
data,
|
||||
maxAge = DEFAULT_CACHE_TIMEOUT, // todo: 从设置里面读取最大缓存时间
|
||||
}) => {
|
||||
try {
|
||||
const req = await newCacheReq(input, init);
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const res = new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": `max-age=${maxAge}`,
|
||||
},
|
||||
});
|
||||
// res.headers.set("Cache-Control", `max-age=${maxAge}`);
|
||||
await cache.put(req, res);
|
||||
} catch (err) {
|
||||
kissLog("put cache", err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析 response
|
||||
* @param {*} res
|
||||
* @returns
|
||||
*/
|
||||
export const parseResponse = async (res) => {
|
||||
if (!res) {
|
||||
throw new Error("Response object does not exist");
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = {
|
||||
url: res.url,
|
||||
status: res.status,
|
||||
};
|
||||
if (res.headers.get("Content-Type")?.includes("json")) {
|
||||
msg.response = await res.json();
|
||||
}
|
||||
throw new Error(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
const contentType = res.headers.get("Content-Type");
|
||||
if (contentType?.includes("json")) {
|
||||
return res.json();
|
||||
} else if (contentType?.includes("audio")) {
|
||||
const blob = await res.blob();
|
||||
return blobToBase64(blob);
|
||||
}
|
||||
return res.text();
|
||||
};
|
||||
|
||||
/**
|
||||
* getHttpCache 兼容性封装
|
||||
* @param {*} input
|
||||
* @param {*} init
|
||||
* @returns
|
||||
*/
|
||||
export const getHttpCachePolyfill = (input, init) => {
|
||||
// 插件
|
||||
if (isExt && !isBg()) {
|
||||
return sendBgMsg(MSG_GET_HTTPCACHE, { input, init });
|
||||
}
|
||||
|
||||
// 油猴/网页/BackgroundPage
|
||||
return getHttpCache({ input, init });
|
||||
};
|
||||
|
||||
/**
|
||||
* putHttpCache 兼容性封装
|
||||
* @param {*} input
|
||||
* @param {*} init
|
||||
* @param {*} data
|
||||
* @returns
|
||||
*/
|
||||
export const putHttpCachePolyfill = (input, init, data) => {
|
||||
// 插件
|
||||
if (isExt && !isBg()) {
|
||||
return sendBgMsg(MSG_PUT_HTTPCACHE, { input, init, data });
|
||||
}
|
||||
|
||||
// 油猴/网页/BackgroundPage
|
||||
return putHttpCache({ input, init, data });
|
||||
};
|
||||
12
src/libs/client.js
Normal file
12
src/libs/client.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import {
|
||||
CLIENT_EXTS,
|
||||
CLIENT_USERSCRIPT,
|
||||
CLIENT_WEB,
|
||||
CLIENT_FIREFOX,
|
||||
} from "../config";
|
||||
|
||||
export const client = process.env.REACT_APP_CLIENT;
|
||||
export const isExt = CLIENT_EXTS.includes(client);
|
||||
export const isGm = client === CLIENT_USERSCRIPT;
|
||||
export const isWeb = client === CLIENT_WEB;
|
||||
export const isFirefox = client === CLIENT_FIREFOX;
|
||||
65
src/libs/detect.js
Normal file
65
src/libs/detect.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
OPT_TRANS_GOOGLE,
|
||||
OPT_TRANS_MICROSOFT,
|
||||
OPT_TRANS_BAIDU,
|
||||
OPT_TRANS_TENCENT,
|
||||
OPT_LANGS_TO_CODE,
|
||||
OPT_LANGS_MAP,
|
||||
OPT_TRANS_BUILTINAI,
|
||||
OPT_LANGDETECTOR_MAP,
|
||||
} from "../config";
|
||||
import { browser } from "./browser";
|
||||
import {
|
||||
apiGoogleLangdetect,
|
||||
apiMicrosoftLangdetect,
|
||||
apiBaiduLangdetect,
|
||||
apiTencentLangdetect,
|
||||
apiBuiltinAIDetect,
|
||||
} from "../apis";
|
||||
import { kissLog } from "./log";
|
||||
|
||||
const langdetectFns = {
|
||||
[OPT_TRANS_GOOGLE]: apiGoogleLangdetect,
|
||||
[OPT_TRANS_MICROSOFT]: apiMicrosoftLangdetect,
|
||||
[OPT_TRANS_BAIDU]: apiBaiduLangdetect,
|
||||
[OPT_TRANS_TENCENT]: apiTencentLangdetect,
|
||||
[OPT_TRANS_BUILTINAI]: apiBuiltinAIDetect,
|
||||
};
|
||||
|
||||
/**
|
||||
* 语言识别
|
||||
* @param {*} text
|
||||
* @returns
|
||||
*/
|
||||
export const tryDetectLang = async (text, langDetector = "-") => {
|
||||
let deLang = "";
|
||||
|
||||
// 内置AI/远程识别
|
||||
if (OPT_LANGDETECTOR_MAP.has(langDetector)) {
|
||||
try {
|
||||
const lang = await langdetectFns[langDetector](text);
|
||||
if (lang) {
|
||||
deLang = OPT_LANGS_TO_CODE[langDetector].get(lang) || "";
|
||||
}
|
||||
} catch (err) {
|
||||
kissLog("detect lang remote", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 本地识别
|
||||
if (!deLang) {
|
||||
try {
|
||||
const res = await browser?.i18n?.detectLanguage(text);
|
||||
const lang = res?.languages?.[0]?.language;
|
||||
if (lang && OPT_LANGS_MAP.has(lang)) {
|
||||
deLang = lang;
|
||||
} else if (lang?.startsWith("zh")) {
|
||||
deLang = "zh-CN";
|
||||
}
|
||||
} catch (err) {
|
||||
kissLog("detect lang local", err);
|
||||
}
|
||||
}
|
||||
|
||||
return deLang;
|
||||
};
|
||||
18
src/libs/fabManager.js
Normal file
18
src/libs/fabManager.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import ShadowDomManager from "./shadowDomManager";
|
||||
import { APP_CONSTS } from "../config";
|
||||
import ContentFab from "../views/Action/ContentFab";
|
||||
|
||||
export class FabManager extends ShadowDomManager {
|
||||
constructor({ processActions, fabConfig }) {
|
||||
super({
|
||||
id: APP_CONSTS.fabID,
|
||||
className: "notranslate",
|
||||
reactComponent: ContentFab,
|
||||
props: { processActions, fabConfig },
|
||||
});
|
||||
|
||||
if (!fabConfig?.isHide) {
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,11 @@
|
||||
import { isExt, isGm } from "./browser";
|
||||
import { sendMsg } from "./msg";
|
||||
import { taskPool } from "./pool";
|
||||
import {
|
||||
MSG_FETCH,
|
||||
MSG_FETCH_LIMIT,
|
||||
MSG_FETCH_CLEAR,
|
||||
CACHE_NAME,
|
||||
OPT_TRANS_MICROSOFT,
|
||||
OPT_TRANS_OPENAI,
|
||||
DEFAULT_FETCH_INTERVAL,
|
||||
DEFAULT_FETCH_LIMIT,
|
||||
} from "../config";
|
||||
import { msAuth } from "./auth";
|
||||
import { isExt, isGm } from "./client";
|
||||
import { sendBgMsg } from "./msg";
|
||||
import { getSettingWithDefault } from "./storage";
|
||||
import { MSG_FETCH, DEFAULT_HTTP_TIMEOUT } from "../config";
|
||||
import { isBg } from "./browser";
|
||||
import { kissLog } from "./log";
|
||||
import { getFetchPool } from "./pool";
|
||||
import { getHttpCachePolyfill, parseResponse } from "./cache";
|
||||
|
||||
/**
|
||||
* 油猴脚本的请求封装
|
||||
@@ -19,185 +13,140 @@ import { msAuth } from "./auth";
|
||||
* @param {*} init
|
||||
* @returns
|
||||
*/
|
||||
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,
|
||||
url: input,
|
||||
headers,
|
||||
data: body,
|
||||
onload: (response) => {
|
||||
if (response.status === 200) {
|
||||
const headers = new Headers();
|
||||
response.responseHeaders.split("\n").forEach((line) => {
|
||||
const [name, value] = line.split(":").map((item) => item.trim());
|
||||
if (name && value) {
|
||||
headers.append(name, value);
|
||||
}
|
||||
});
|
||||
resolve(new Response(response.response, { headers }));
|
||||
} else {
|
||||
reject(new Error(`[${response.status}] ${response.responseText}`));
|
||||
}
|
||||
// withCredentials: true,
|
||||
timeout,
|
||||
onload: ({ response, responseHeaders, status, statusText }) => {
|
||||
const headers = {};
|
||||
responseHeaders.split("\n").forEach((line) => {
|
||||
const [name, value] = line.split(":").map((item) => item.trim());
|
||||
if (name && value) {
|
||||
headers[name] = value;
|
||||
}
|
||||
});
|
||||
resolve({
|
||||
body: response,
|
||||
headers,
|
||||
status,
|
||||
statusText,
|
||||
});
|
||||
},
|
||||
onerror: reject,
|
||||
onabort: () => {
|
||||
reject(new Error("GM request onabort."));
|
||||
},
|
||||
ontimeout: () => {
|
||||
reject(new Error("GM request timeout."));
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 构造缓存 request
|
||||
* @param {*} request
|
||||
* @returns
|
||||
*/
|
||||
const newCacheReq = async (request) => {
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 发起请求
|
||||
* @param {*} param0
|
||||
* @param {*} input
|
||||
* @param {*} init
|
||||
* @param {*} opts
|
||||
* @returns
|
||||
*/
|
||||
const fetchApi = async ({ input, init = {}, translator, token }) => {
|
||||
if (translator === OPT_TRANS_MICROSOFT) {
|
||||
init.headers["Authorization"] = `Bearer ${token}`;
|
||||
} else if (translator === OPT_TRANS_OPENAI) {
|
||||
init.headers["Authorization"] = `Bearer ${token}`; // // OpenAI
|
||||
init.headers["api-key"] = token; // Azure OpenAI
|
||||
export const fetchPatcher = async (input, init = {}, opts) => {
|
||||
let timeout = opts?.httpTimeout;
|
||||
if (!timeout) {
|
||||
try {
|
||||
timeout = (await getSettingWithDefault()).httpTimeout;
|
||||
} catch (err) {
|
||||
kissLog("getSettingWithDefault", err);
|
||||
}
|
||||
}
|
||||
if (!timeout) {
|
||||
timeout = DEFAULT_HTTP_TIMEOUT;
|
||||
}
|
||||
|
||||
if (isGm) {
|
||||
const connects = GM?.info?.script?.connects || [];
|
||||
const url = new URL(input);
|
||||
const isSafe = connects.find((item) => url.hostname.endsWith(item));
|
||||
if (isSafe) {
|
||||
return fetchGM(input, init);
|
||||
}
|
||||
// todo: 自定义接口 init 可能包含了 signal
|
||||
Object.assign(init, { timeout });
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
if (AbortSignal?.timeout && !init.signal) {
|
||||
Object.assign(init, { signal: AbortSignal.timeout(timeout) });
|
||||
}
|
||||
|
||||
return fetch(input, init);
|
||||
};
|
||||
|
||||
/**
|
||||
* 请求池实例
|
||||
*/
|
||||
export const fetchPool = taskPool(
|
||||
fetchApi,
|
||||
async ({ translator }) => {
|
||||
if (translator === OPT_TRANS_MICROSOFT) {
|
||||
const [token] = await msAuth();
|
||||
return { token };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
DEFAULT_FETCH_INTERVAL,
|
||||
DEFAULT_FETCH_LIMIT
|
||||
);
|
||||
|
||||
/**
|
||||
* 请求数据统一接口
|
||||
* @param {*} input
|
||||
* @param {*} opts
|
||||
* 处理请求
|
||||
* @param {*} param0
|
||||
* @returns
|
||||
*/
|
||||
export const fetchData = async (
|
||||
input,
|
||||
{ useCache, usePool, translator, token, ...init } = {}
|
||||
) => {
|
||||
const cacheReq = await newCacheReq(new Request(input, init));
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
let res;
|
||||
|
||||
// 查询缓存
|
||||
if (useCache) {
|
||||
try {
|
||||
res = await cache.match(cacheReq);
|
||||
} catch (err) {
|
||||
console.log("[cache match]", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!res) {
|
||||
// 发送请求
|
||||
if (usePool) {
|
||||
res = await fetchPool.push({ input, init, translator, token });
|
||||
} else {
|
||||
res = await fetchApi({ input, init, translator, token });
|
||||
}
|
||||
|
||||
if (!res?.ok) {
|
||||
throw new Error(`response: ${res.statusText}`);
|
||||
}
|
||||
|
||||
// 插入缓存
|
||||
if (useCache) {
|
||||
try {
|
||||
await cache.put(cacheReq, res.clone());
|
||||
} catch (err) {
|
||||
console.log("[cache put]", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = res.headers.get("Content-Type");
|
||||
if (contentType?.includes("json")) {
|
||||
return await res.json();
|
||||
}
|
||||
return await res.text();
|
||||
export const fetchHandle = async ({ input, init, opts }) => {
|
||||
const res = await fetchPatcher(input, init, opts);
|
||||
return parseResponse(res);
|
||||
};
|
||||
|
||||
/**
|
||||
* fetch 兼容性封装
|
||||
* @param {*} input
|
||||
* @param {*} opts
|
||||
* @param {*} args
|
||||
* @returns
|
||||
*/
|
||||
export const fetchPolyfill = async (input, { isBg = false, ...opts } = {}) => {
|
||||
export const fnPolyfill = ({ fn, msg = MSG_FETCH, ...args }) => {
|
||||
// 插件
|
||||
if (isExt && !isBg) {
|
||||
const res = await sendMsg(MSG_FETCH, { input, opts });
|
||||
if (res.error) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
return res.data;
|
||||
if (isExt && !isBg()) {
|
||||
return sendBgMsg(msg, { ...args });
|
||||
}
|
||||
|
||||
// 油猴/网页/BackgroundPage
|
||||
return await fetchData(input, opts);
|
||||
return fn({ ...args });
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新 fetch pool 参数
|
||||
* @param {*} interval
|
||||
* @param {*} limit
|
||||
* 数据请求
|
||||
* @param {*} input
|
||||
* @param {*} init
|
||||
* @param {*} param1
|
||||
* @returns
|
||||
*/
|
||||
export const fetchUpdate = async (interval, limit) => {
|
||||
if (isExt) {
|
||||
const res = await sendMsg(MSG_FETCH_LIMIT, { interval, limit });
|
||||
if (res.error) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
} else {
|
||||
fetchPool.update(interval, limit);
|
||||
export const fetchData = async (
|
||||
input,
|
||||
init,
|
||||
{ useCache, usePool, fetchInterval, fetchLimit, ...opts } = {}
|
||||
) => {
|
||||
if (!input?.trim()) {
|
||||
throw new Error("URL is empty");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空任务池
|
||||
*/
|
||||
export const fetchClear = async () => {
|
||||
if (isExt) {
|
||||
const res = await sendMsg(MSG_FETCH_CLEAR);
|
||||
if (res.error) {
|
||||
throw new Error(res.error);
|
||||
// 使用缓存数据
|
||||
if (useCache) {
|
||||
const resCache = await getHttpCachePolyfill(input, init);
|
||||
if (resCache) {
|
||||
return resCache;
|
||||
}
|
||||
} else {
|
||||
fetchPool.clear();
|
||||
}
|
||||
|
||||
// 通过任务池发送请求
|
||||
if (usePool) {
|
||||
const fetchPool = getFetchPool(fetchInterval, fetchLimit);
|
||||
return fetchPool.push(fnPolyfill, { fn: fetchHandle, input, init, opts });
|
||||
}
|
||||
|
||||
// 直接请求
|
||||
return fnPolyfill({ fn: fetchHandle, input, init, opts });
|
||||
};
|
||||
|
||||
102
src/libs/gm.js
Normal file
102
src/libs/gm.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { fetchGM } from "./fetch";
|
||||
import { genEventName } from "./utils";
|
||||
|
||||
const MSG_GM_xmlHttpRequest = "xmlHttpRequest";
|
||||
const MSG_GM_setValue = "setValue";
|
||||
const MSG_GM_getValue = "getValue";
|
||||
const MSG_GM_deleteValue = "deleteValue";
|
||||
const MSG_GM_info = "info";
|
||||
|
||||
/**
|
||||
* 注入页面的脚本,请求并接受GM接口信息
|
||||
* @param {*} param0
|
||||
*/
|
||||
export const injectScript = (ping) => {
|
||||
window.APP_INFO = {
|
||||
name: process.env.REACT_APP_NAME,
|
||||
version: process.env.REACT_APP_VERSION,
|
||||
eventName: ping,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 适配GM脚本
|
||||
*/
|
||||
export const adaptScript = (ping) => {
|
||||
const promiseGM = (action, args, timeout = 5000) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const pong = genEventName();
|
||||
const handleEvent = (e) => {
|
||||
window.removeEventListener(pong, handleEvent);
|
||||
const { data, error } = e.detail;
|
||||
if (error) {
|
||||
reject(new Error(error));
|
||||
} else {
|
||||
resolve(data);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(pong, handleEvent);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(ping, { detail: { action, args, pong } })
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
window.removeEventListener(pong, handleEvent);
|
||||
reject(new Error("timeout"));
|
||||
}, timeout);
|
||||
});
|
||||
|
||||
window.KISS_GM = {
|
||||
fetch: (input, init) => promiseGM(MSG_GM_xmlHttpRequest, { input, init }),
|
||||
setValue: (key, val) => promiseGM(MSG_GM_setValue, { key, val }),
|
||||
getValue: (key) => promiseGM(MSG_GM_getValue, { key }),
|
||||
deleteValue: (key) => promiseGM(MSG_GM_deleteValue, { key }),
|
||||
getInfo: async () => {
|
||||
if (!window.GM_info) {
|
||||
window.GM_info = await promiseGM(MSG_GM_info);
|
||||
}
|
||||
return window.GM_info;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 监听并回应页面对GM接口的请求
|
||||
* @param {*} param0
|
||||
*/
|
||||
export const handlePing = async (e) => {
|
||||
const { action, args, pong } = e.detail;
|
||||
let res;
|
||||
try {
|
||||
switch (action) {
|
||||
case MSG_GM_xmlHttpRequest:
|
||||
const { input, init } = args;
|
||||
res = await fetchGM(input, init);
|
||||
break;
|
||||
case MSG_GM_setValue:
|
||||
const { key, val } = args;
|
||||
await GM.setValue(key, val);
|
||||
res = val;
|
||||
break;
|
||||
case MSG_GM_getValue:
|
||||
res = await GM.getValue(args.key);
|
||||
break;
|
||||
case MSG_GM_deleteValue:
|
||||
await GM.deleteValue(args.key);
|
||||
res = "ok";
|
||||
break;
|
||||
case MSG_GM_info:
|
||||
res = GM.info;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`message action is unavailable: ${action}`);
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent(pong, { detail: { data: res } }));
|
||||
} catch (err) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(pong, { detail: { error: err.message } })
|
||||
);
|
||||
}
|
||||
};
|
||||
11
src/libs/iframe.js
Normal file
11
src/libs/iframe.js
Normal file
@@ -0,0 +1,11 @@
|
||||
export const isIframe = window.self !== window.top;
|
||||
|
||||
export const sendIframeMsg = (action, args) => {
|
||||
document.querySelectorAll("iframe").forEach((iframe) => {
|
||||
iframe.contentWindow.postMessage({ action, args }, "*");
|
||||
});
|
||||
};
|
||||
|
||||
export const sendParentMsg = (action, args) => {
|
||||
window.parent.postMessage({ action, args }, "*");
|
||||
};
|
||||
@@ -1,113 +0,0 @@
|
||||
import storage from "./storage";
|
||||
import {
|
||||
DEFAULT_SETTING,
|
||||
STOKEY_SETTING,
|
||||
STOKEY_RULES,
|
||||
STOKEY_FAB,
|
||||
GLOBLA_RULE,
|
||||
GLOBAL_KEY,
|
||||
DEFAULT_SUBRULES_LIST,
|
||||
} from "../config";
|
||||
import { browser } from "./browser";
|
||||
import { isMatch } from "./utils";
|
||||
import { loadSubRules } from "./rules";
|
||||
|
||||
/**
|
||||
* 获取节点列表并转为数组
|
||||
* @param {*} selector
|
||||
* @param {*} el
|
||||
* @returns
|
||||
*/
|
||||
export const queryEls = (selector, el = document) =>
|
||||
Array.from(el.querySelectorAll(selector));
|
||||
|
||||
/**
|
||||
* 查询storage中的设置
|
||||
* @returns
|
||||
*/
|
||||
export const getSetting = async () => ({
|
||||
...DEFAULT_SETTING,
|
||||
...((await storage.getObj(STOKEY_SETTING)) || {}),
|
||||
});
|
||||
|
||||
/**
|
||||
* 查询规则列表
|
||||
* @returns
|
||||
*/
|
||||
export const getRules = async () => (await storage.getObj(STOKEY_RULES)) || [];
|
||||
|
||||
/**
|
||||
* 查询fab位置信息
|
||||
* @returns
|
||||
*/
|
||||
export const getFab = async () => (await storage.getObj(STOKEY_FAB)) || {};
|
||||
|
||||
/**
|
||||
* 设置fab位置信息
|
||||
* @returns
|
||||
*/
|
||||
export const setFab = async (obj) => await storage.setObj(STOKEY_FAB, obj);
|
||||
|
||||
/**
|
||||
* 根据href匹配规则
|
||||
* @param {*} rules
|
||||
* @param {string} href
|
||||
* @returns
|
||||
*/
|
||||
export const matchRule = async (
|
||||
rules,
|
||||
href,
|
||||
{ injectRules = true, subrulesList = DEFAULT_SUBRULES_LIST }
|
||||
) => {
|
||||
rules = [...rules];
|
||||
if (injectRules) {
|
||||
try {
|
||||
const selectedSub = subrulesList.find((item) => item.selected);
|
||||
if (selectedSub?.url) {
|
||||
const subRules = await loadSubRules(selectedSub.url);
|
||||
rules.splice(-1, 0, ...subRules);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[load injectRules]", err);
|
||||
}
|
||||
}
|
||||
|
||||
const rule = rules.find((r) =>
|
||||
r.pattern.split(",").some((p) => isMatch(href, p.trim()))
|
||||
);
|
||||
|
||||
const globalRule =
|
||||
rules.find((r) => r.pattern.split(",").some((p) => p.trim() === "*")) ||
|
||||
GLOBLA_RULE;
|
||||
|
||||
if (!rule) {
|
||||
return globalRule;
|
||||
}
|
||||
|
||||
rule.selector =
|
||||
rule?.selector?.trim() ||
|
||||
globalRule?.selector?.trim() ||
|
||||
GLOBLA_RULE.selector;
|
||||
|
||||
rule.bgColor = rule?.bgColor?.trim() || globalRule?.bgColor?.trim();
|
||||
|
||||
["translator", "fromLang", "toLang", "textStyle", "transOpen"].forEach(
|
||||
(key) => {
|
||||
if (rule[key] === GLOBAL_KEY) {
|
||||
rule[key] = globalRule[key];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return rule;
|
||||
};
|
||||
|
||||
/**
|
||||
* 本地语言识别
|
||||
* @param {*} q
|
||||
* @returns
|
||||
*/
|
||||
export const detectLang = async (q) => {
|
||||
const res = await browser?.i18n.detectLanguage(q);
|
||||
return res?.languages?.[0]?.language;
|
||||
};
|
||||
57
src/libs/injector.js
Normal file
57
src/libs/injector.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { trustedTypesHelper } from "./trustedTypes";
|
||||
|
||||
// Function to inject inline JavaScript code
|
||||
export const injectInlineJs = (code, id = "kiss-translator-inline-js") => {
|
||||
if (document.getElementById(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = document.createElement("script");
|
||||
el.type = "text/javascript";
|
||||
el.id = id;
|
||||
el.textContent = trustedTypesHelper.createScript(code);
|
||||
(document.head || document.documentElement).appendChild(el);
|
||||
};
|
||||
|
||||
export const injectInlineJsBg = (code, id = "kiss-translator-inline-js") => {
|
||||
if (document.getElementById(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = document.createElement("script");
|
||||
el.type = "text/javascript";
|
||||
el.id = id;
|
||||
el.textContent = code;
|
||||
(document.head || document.documentElement).appendChild(el);
|
||||
};
|
||||
|
||||
// Function to inject external JavaScript file
|
||||
export const injectExternalJs = (src, id = "kiss-translator-external-js") => {
|
||||
if (document.getElementById(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = document.createElement("script");
|
||||
el.type = "text/javascript";
|
||||
el.id = id;
|
||||
el.src = trustedTypesHelper.createScriptURL(src);
|
||||
(document.head || document.documentElement).appendChild(el);
|
||||
};
|
||||
|
||||
// Function to inject internal CSS code
|
||||
export const injectInternalCss = (styles) => {
|
||||
const el = document.createElement("style");
|
||||
el.setAttribute("data-source", "kiss-inject injectInternalCss");
|
||||
el.textContent = styles;
|
||||
document.head?.appendChild(el);
|
||||
};
|
||||
|
||||
// Function to inject external CSS file
|
||||
export const injectExternalCss = (href) => {
|
||||
const el = document.createElement("link");
|
||||
el.setAttribute("data-source", "kiss-inject injectExternalCss");
|
||||
el.setAttribute("rel", "stylesheet");
|
||||
el.setAttribute("type", "text/css");
|
||||
el.setAttribute("href", href);
|
||||
document.head?.appendChild(el);
|
||||
};
|
||||
324
src/libs/inputTranslate.js
Normal file
324
src/libs/inputTranslate.js
Normal file
@@ -0,0 +1,324 @@
|
||||
import {
|
||||
DEFAULT_INPUT_RULE,
|
||||
DEFAULT_INPUT_SHORTCUT,
|
||||
OPT_LANGS_LIST,
|
||||
DEFAULT_API_SETTING,
|
||||
} from "../config";
|
||||
import { genEventName, removeEndchar, matchInputStr, sleep } from "./utils";
|
||||
import { stepShortcutRegister } from "./shortcut";
|
||||
import { apiTranslate } from "../apis";
|
||||
import { createLoadingSVG } from "./svg";
|
||||
import { logger } from "./log";
|
||||
|
||||
function isInputNode(node) {
|
||||
return node.nodeName === "INPUT" || node.nodeName === "TEXTAREA";
|
||||
}
|
||||
|
||||
function isEditAbleNode(node) {
|
||||
return node.hasAttribute("contenteditable");
|
||||
}
|
||||
|
||||
async function replaceContentEditableText(node, newText) {
|
||||
try {
|
||||
logger.debug("try replace editable 1: pasteEvent");
|
||||
|
||||
node.focus();
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection) throw new Error("window.getSelection() is not available.");
|
||||
|
||||
const targetNode = node.querySelector("p") || node;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(targetNode);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.setData("text/plain", newText);
|
||||
|
||||
const pasteEvent = new ClipboardEvent("paste", {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
node.dispatchEvent(pasteEvent);
|
||||
|
||||
await sleep(50);
|
||||
if (node.innerText.trim() === newText) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error("Strategy 1 failed to replace text correctly.");
|
||||
} catch (error) {
|
||||
logger.debug("Strategy 1 Failed:", error.message);
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug("try replace editable 2: execCommand");
|
||||
|
||||
node.focus();
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection) throw new Error("window.getSelection() is not available.");
|
||||
|
||||
const targetNode = node.querySelector("p") || node;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(targetNode);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
document.execCommand("insertText", false, newText);
|
||||
|
||||
await sleep(50);
|
||||
if (node.innerText.trim() === newText) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error("Strategy 2 failed to replace text correctly.");
|
||||
} catch (error) {
|
||||
logger.debug("Strategy 2 Failed:", error.message);
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug("try replace editable 3: textContent");
|
||||
|
||||
node.focus();
|
||||
|
||||
const targetNode = node.querySelector("p") || node;
|
||||
const textSpan = targetNode.querySelector('span[data-lexical-text="true"]');
|
||||
|
||||
if (textSpan) {
|
||||
textSpan.textContent = newText;
|
||||
} else {
|
||||
targetNode.textContent = newText;
|
||||
}
|
||||
|
||||
node.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
|
||||
|
||||
await sleep(50);
|
||||
if (node.innerText.trim() === newText) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error("Strategy 3 failed to replace text correctly.");
|
||||
} catch (error) {
|
||||
logger.debug("Strategy 3 Failed:", error.message);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getNodeText(node) {
|
||||
if (isInputNode(node)) {
|
||||
return node.value;
|
||||
}
|
||||
return node.innerText || node.textContent || "";
|
||||
}
|
||||
|
||||
function addLoading(node, loadingId) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
const div = document.createElement("div");
|
||||
div.id = loadingId;
|
||||
div.appendChild(createLoadingSVG());
|
||||
div.style.cssText = `
|
||||
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; /* 允许点击穿透 */
|
||||
`;
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
|
||||
function removeLoading(loadingId) {
|
||||
const div = document.getElementById(loadingId);
|
||||
if (div) div.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入框翻译
|
||||
*/
|
||||
export class InputTranslator {
|
||||
#config;
|
||||
#unregisterShortcut = null;
|
||||
#isEnabled = false;
|
||||
#triggerShortcut; // 用于缓存快捷键
|
||||
|
||||
constructor({ inputRule = DEFAULT_INPUT_RULE, transApis = [] } = {}) {
|
||||
this.#config = { inputRule, transApis };
|
||||
|
||||
const { triggerShortcut: initialTriggerShortcut } = this.#config.inputRule;
|
||||
if (initialTriggerShortcut && initialTriggerShortcut.length > 0) {
|
||||
this.#triggerShortcut = initialTriggerShortcut;
|
||||
} else {
|
||||
this.#triggerShortcut = DEFAULT_INPUT_SHORTCUT;
|
||||
}
|
||||
|
||||
if (this.#config.inputRule.transOpen) {
|
||||
this.enable();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用输入翻译功能
|
||||
*/
|
||||
enable() {
|
||||
if (this.#isEnabled || !this.#config.inputRule.transOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { triggerCount, triggerTime } = this.#config.inputRule;
|
||||
this.#unregisterShortcut = stepShortcutRegister(
|
||||
this.#triggerShortcut,
|
||||
this.#handleTranslate.bind(this),
|
||||
triggerCount,
|
||||
triggerTime
|
||||
);
|
||||
|
||||
this.#isEnabled = true;
|
||||
logger.info("Input Translator enabled.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用输入翻译功能
|
||||
*/
|
||||
disable() {
|
||||
if (!this.#isEnabled) {
|
||||
return;
|
||||
}
|
||||
if (this.#unregisterShortcut) {
|
||||
this.#unregisterShortcut();
|
||||
this.#unregisterShortcut = null;
|
||||
}
|
||||
this.#isEnabled = false;
|
||||
logger.info("Input Translator disabled.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换启用/禁用状态
|
||||
*/
|
||||
toggle() {
|
||||
if (this.#isEnabled) {
|
||||
this.disable();
|
||||
} else {
|
||||
this.enable();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译核心逻辑
|
||||
* @private
|
||||
*/
|
||||
async #handleTranslate() {
|
||||
let node = document.activeElement;
|
||||
if (!node) return;
|
||||
|
||||
while (node.shadowRoot && node.shadowRoot.activeElement) {
|
||||
node = node.shadowRoot.activeElement;
|
||||
}
|
||||
|
||||
if (!isInputNode(node) && !isEditAbleNode(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
const apiSetting =
|
||||
this.#config.transApis.find((api) => api.apiSlug === apiSlug) ||
|
||||
DEFAULT_API_SETTING;
|
||||
const loadingId = "kiss-loading-" + genEventName();
|
||||
|
||||
try {
|
||||
addLoading(node, loadingId);
|
||||
|
||||
const { trText, isSame } = await apiTranslate({
|
||||
text,
|
||||
fromLang,
|
||||
toLang,
|
||||
apiSetting,
|
||||
});
|
||||
|
||||
const newText = trText?.trim() || "";
|
||||
if (!newText || isSame) return;
|
||||
|
||||
if (isInputNode(node)) {
|
||||
node.value = newText;
|
||||
node.dispatchEvent(
|
||||
new Event("input", { bubbles: true, cancelable: true })
|
||||
);
|
||||
} else {
|
||||
const success = await replaceContentEditableText(node, newText);
|
||||
if (!success) {
|
||||
// todo: 提示可以黏贴
|
||||
logger.info("Replace editable text failed");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.info("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();
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/libs/interpreter.js
Normal file
14
src/libs/interpreter.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import Sval from "sval";
|
||||
|
||||
export 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,
|
||||
});
|
||||
161
src/libs/log.js
Normal file
161
src/libs/log.js
Normal file
@@ -0,0 +1,161 @@
|
||||
// 定义日志级别
|
||||
export const LogLevel = {
|
||||
DEBUG: { value: 0, name: "DEBUG", color: "#6495ED" }, // 宝蓝色
|
||||
INFO: { value: 1, name: "INFO", color: "#4CAF50" }, // 绿色
|
||||
WARN: { value: 2, name: "WARN", color: "#FFC107" }, // 琥珀色
|
||||
ERROR: { value: 3, name: "ERROR", color: "#F44336" }, // 红色
|
||||
SILENT: { value: 4, name: "SILENT" }, // 特殊级别,用于关闭所有日志
|
||||
};
|
||||
|
||||
function findLogLevelByValue(value) {
|
||||
return Object.values(LogLevel).find((level) => level.value === value);
|
||||
}
|
||||
|
||||
function findLogLevelByName(name) {
|
||||
if (typeof name !== "string" || name.length === 0) return undefined;
|
||||
const upperCaseName = name.toUpperCase();
|
||||
return Object.values(LogLevel).find((level) => level.name === upperCaseName);
|
||||
}
|
||||
|
||||
class Logger {
|
||||
/**
|
||||
* @param {object} [options={}] 配置选项
|
||||
* @param {LogLevel} [options.level=LogLevel.INFO] 要显示的最低日志级别
|
||||
* @param {string} [options.prefix='App'] 日志前缀,用于区分模块
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.config = {
|
||||
level: options.level || LogLevel.INFO,
|
||||
prefix: options.prefix || "KISS-Translator",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态设置日志级别
|
||||
* @param {LogLevel} level - 新的日志级别
|
||||
*/
|
||||
setLevel(level) {
|
||||
let newLevelObject;
|
||||
|
||||
if (typeof level === "string") {
|
||||
newLevelObject = findLogLevelByName(level);
|
||||
if (!newLevelObject) {
|
||||
this.warn(
|
||||
`Invalid log level name provided: "${level}". Keeping current level.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (typeof level === "number") {
|
||||
newLevelObject = findLogLevelByValue(level);
|
||||
if (!newLevelObject) {
|
||||
this.warn(
|
||||
`Invalid log level value provided: ${level}. Keeping current level.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (level && typeof level.value === "number") {
|
||||
newLevelObject = level;
|
||||
} else {
|
||||
this.warn(
|
||||
"Invalid argument passed to setLevel. Must be a LogLevel object, number, or string."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.level = newLevelObject;
|
||||
console.log(
|
||||
`[${this.config.prefix}] Log level dynamically set to ${this.config.level.name}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心日志记录方法
|
||||
* @private
|
||||
* @param {LogLevel} level - 当前消息的日志级别
|
||||
* @param {...any} args - 要记录的多个参数,可以是任何类型
|
||||
*/
|
||||
_log(level, ...args) {
|
||||
// 如果当前级别低于配置的最低级别,则不打印
|
||||
if (level.value < this.config.level.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const prefixStr = `[${this.config.prefix}]`;
|
||||
const levelStr = `[${level.name}]`;
|
||||
|
||||
// 判断是否在浏览器环境并且浏览器支持 console 样式
|
||||
const isBrowser =
|
||||
typeof window !== "undefined" && typeof window.document !== "undefined";
|
||||
|
||||
if (isBrowser) {
|
||||
// 在浏览器中使用颜色高亮
|
||||
const consoleMethod = this._getConsoleMethod(level);
|
||||
consoleMethod(
|
||||
`%c${timestamp} %c${prefixStr} %c${levelStr}`,
|
||||
"color: gray; font-weight: lighter;", // 时间戳样式
|
||||
"color: #7c57e0; font-weight: bold;", // 前缀样式 (紫色)
|
||||
`color: ${level.color}; font-weight: bold;`, // 日志级别样式
|
||||
...args
|
||||
);
|
||||
} else {
|
||||
// 在 Node.js 或不支持样式的环境中,输出纯文本
|
||||
const consoleMethod = this._getConsoleMethod(level);
|
||||
consoleMethod(timestamp, prefixStr, levelStr, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据日志级别获取对应的 console 方法
|
||||
* @private
|
||||
*/
|
||||
_getConsoleMethod(level) {
|
||||
switch (level) {
|
||||
case LogLevel.ERROR:
|
||||
return console.error;
|
||||
case LogLevel.WARN:
|
||||
return console.warn;
|
||||
case LogLevel.INFO:
|
||||
return console.info;
|
||||
default:
|
||||
return console.log;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 DEBUG 级别的日志
|
||||
* @param {...any} args
|
||||
*/
|
||||
debug(...args) {
|
||||
this._log(LogLevel.DEBUG, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 INFO 级别的日志
|
||||
* @param {...any} args
|
||||
*/
|
||||
info(...args) {
|
||||
this._log(LogLevel.INFO, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 WARN 级别的日志
|
||||
* @param {...any} args
|
||||
*/
|
||||
warn(...args) {
|
||||
this._log(LogLevel.WARN, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 ERROR 级别的日志
|
||||
* @param {...any} args
|
||||
*/
|
||||
error(...args) {
|
||||
this._log(LogLevel.ERROR, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
export const kissLog = logger.info.bind(logger);
|
||||
|
||||
// todo:debug日志埋点
|
||||
@@ -1,13 +1,30 @@
|
||||
import { browser } from "./browser";
|
||||
|
||||
/**
|
||||
* 获取当前tab信息
|
||||
* @returns
|
||||
*/
|
||||
export const getCurTab = async () => {
|
||||
const [tab] = await browser.tabs.query({
|
||||
active: true,
|
||||
lastFocusedWindow: true,
|
||||
});
|
||||
return tab;
|
||||
};
|
||||
|
||||
export const getCurTabId = async () => {
|
||||
const tab = await getCurTab();
|
||||
return tab.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* 发送消息给background
|
||||
* @param {*} action
|
||||
* @param {*} args
|
||||
* @returns
|
||||
*/
|
||||
export const sendMsg = (action, args) =>
|
||||
browser?.runtime?.sendMessage({ action, args });
|
||||
export const sendBgMsg = (action, args) =>
|
||||
browser.runtime.sendMessage({ action, args });
|
||||
|
||||
/**
|
||||
* 发送消息给当前页面
|
||||
@@ -16,6 +33,6 @@ export const sendMsg = (action, args) =>
|
||||
* @returns
|
||||
*/
|
||||
export const sendTabMsg = async (action, args) => {
|
||||
const tabs = await browser?.tabs.query({ active: true, currentWindow: true });
|
||||
return await browser?.tabs.sendMessage(tabs[0].id, { action, args });
|
||||
const tabId = await getCurTabId();
|
||||
return browser.tabs.sendMessage(tabId, { action, args });
|
||||
};
|
||||
|
||||
213
src/libs/pool.js
213
src/libs/pool.js
@@ -1,77 +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) => {
|
||||
const pool = [];
|
||||
const maxRetry = 2; // 最大重试次数
|
||||
let maxCount = _limit; // 最大数量
|
||||
let curCount = 0; // 当前数量
|
||||
let interval = _interval; // 间隔时间
|
||||
let timer = null;
|
||||
class TaskPool {
|
||||
#pool = [];
|
||||
|
||||
#maxRetry = 2; // 最大重试次数
|
||||
#retryInterval = 1000; // 重试间隔时间
|
||||
#limit; // 最大并发数
|
||||
#interval; // 任务最小启动间隔
|
||||
|
||||
#currentConcurrent = 0; // 当前正在执行的任务数
|
||||
#lastExecutionTime = 0; // 上一个任务的启动时间
|
||||
#schedulerTimer = null; // 用于调度下一个任务的定时器
|
||||
|
||||
constructor(
|
||||
interval = DEFAULT_FETCH_INTERVAL,
|
||||
limit = DEFAULT_FETCH_LIMIT,
|
||||
retryInterval = 1000
|
||||
) {
|
||||
this.#interval = interval;
|
||||
this.#limit = limit;
|
||||
this.#retryInterval = retryInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度器
|
||||
*/
|
||||
#scheduleNext() {
|
||||
if (this.#schedulerTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#currentConcurrent >= this.#limit || this.#pool.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceLast = now - this.#lastExecutionTime;
|
||||
const delay = Math.max(0, this.#interval - timeSinceLast);
|
||||
|
||||
this.#schedulerTimer = setTimeout(() => {
|
||||
this.#schedulerTimer = null;
|
||||
if (this.#currentConcurrent < this.#limit && this.#pool.length > 0) {
|
||||
const task = this.#pool.shift();
|
||||
if (task) {
|
||||
this.#lastExecutionTime = Date.now();
|
||||
this.#execute(task);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#pool.length > 0) {
|
||||
this.#scheduleNext();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单个任务
|
||||
* @param {object} task - 任务对象
|
||||
*/
|
||||
async #execute(task) {
|
||||
this.#currentConcurrent++;
|
||||
const { fn, args, resolve, reject, retry } = task;
|
||||
|
||||
const handleTask = async (item, preArgs) => {
|
||||
curCount++;
|
||||
const { args, resolve, reject, retry } = item;
|
||||
try {
|
||||
const res = await fn({ ...args, ...preArgs });
|
||||
const res = await fn(args);
|
||||
resolve(res);
|
||||
} catch (err) {
|
||||
if (retry < maxRetry) {
|
||||
pool.push({ args, resolve, reject, retry: retry + 1 });
|
||||
kissLog("task pool", err);
|
||||
if (retry < this.#maxRetry) {
|
||||
setTimeout(() => {
|
||||
this.#pool.unshift({ ...task, retry: retry + 1 }); // unshift 保证重试任务优先
|
||||
this.#scheduleNext();
|
||||
}, this.#retryInterval);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
} finally {
|
||||
curCount--;
|
||||
this.#currentConcurrent--;
|
||||
this.#scheduleNext();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
// console.log("timer", timer);
|
||||
timer && clearTimeout(timer);
|
||||
timer = setTimeout(run, interval);
|
||||
/**
|
||||
* 向任务池中添加一个新任务
|
||||
* @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();
|
||||
});
|
||||
}
|
||||
|
||||
if (curCount < maxCount) {
|
||||
const item = pool.shift();
|
||||
if (item) {
|
||||
try {
|
||||
const preArgs = await preFn(item.args);
|
||||
handleTask(item, preArgs);
|
||||
} catch (err) {
|
||||
console.log("[preFn]", err);
|
||||
pool.push(item);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 更新任务池的配置
|
||||
* @param {number} interval - 新的最小任务间隔
|
||||
* @param {number} limit - 新的最大并发数
|
||||
*/
|
||||
update(interval, limit) {
|
||||
if (interval >= 0) {
|
||||
this.#interval = interval;
|
||||
}
|
||||
if (limit >= 1) {
|
||||
this.#limit = limit;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
push: async (args) => {
|
||||
if (!timer) {
|
||||
run();
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (_limit >= 1 && _limit <= 100 && _limit !== maxCount) {
|
||||
maxCount = _limit;
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
pool.length = 0;
|
||||
curCount = 0;
|
||||
timer && clearTimeout(timer);
|
||||
timer = null;
|
||||
},
|
||||
};
|
||||
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();
|
||||
};
|
||||
|
||||
26
src/libs/popupManager.js
Normal file
26
src/libs/popupManager.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import ShadowDomManager from "./shadowDomManager";
|
||||
import { APP_CONSTS, EVENT_KISS, MSG_POPUP_TOGGLE } from "../config";
|
||||
import Action from "../views/Action";
|
||||
|
||||
export class PopupManager extends ShadowDomManager {
|
||||
constructor({ translator, processActions }) {
|
||||
super({
|
||||
id: APP_CONSTS.popupID,
|
||||
className: "notranslate",
|
||||
reactComponent: Action,
|
||||
props: { translator, processActions },
|
||||
});
|
||||
}
|
||||
|
||||
toggle(props) {
|
||||
if (this.isVisible) {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent(EVENT_KISS, {
|
||||
detail: { action: MSG_POPUP_TOGGLE },
|
||||
})
|
||||
);
|
||||
} else {
|
||||
this.show(props || this._props);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,118 @@
|
||||
import storage from "./storage";
|
||||
import { fetchPolyfill } from "./fetch";
|
||||
import { matchValue, type } from "./utils";
|
||||
import { matchValue, type, isMatch } from "./utils";
|
||||
import {
|
||||
STOKEY_RULESCACHE_PREFIX,
|
||||
GLOBAL_KEY,
|
||||
OPT_TRANS_ALL,
|
||||
OPT_STYLE_ALL,
|
||||
OPT_LANGS_FROM,
|
||||
OPT_LANGS_TO,
|
||||
// OPT_TIMING_ALL,
|
||||
DEFAULT_RULE,
|
||||
GLOBLA_RULE,
|
||||
OPT_SPLIT_PARAGRAPH_ALL,
|
||||
OPT_HIGHLIGHT_WORDS_ALL,
|
||||
} from "../config";
|
||||
import { syncOpt } from "./sync";
|
||||
import { loadOrFetchSubRules } from "./subRules";
|
||||
import { getRulesWithDefault, setRules } from "./storage";
|
||||
import { trySyncRules } from "./sync";
|
||||
// import { FIXER_ALL } from "./webfix";
|
||||
import { kissLog } from "./log";
|
||||
|
||||
const fromLangs = OPT_LANGS_FROM.map((item) => item[0]);
|
||||
const toLangs = OPT_LANGS_TO.map((item) => item[0]);
|
||||
/**
|
||||
* 根据href匹配规则
|
||||
* @param {*} rules
|
||||
* @param {string} href
|
||||
* @returns
|
||||
*/
|
||||
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 subRules = await loadOrFetchSubRules(selectedSub.url);
|
||||
rules.splice(-1, 0, ...subRules);
|
||||
}
|
||||
} catch (err) {
|
||||
kissLog("load injectRules", err);
|
||||
}
|
||||
}
|
||||
|
||||
const rule = rules.find((r) =>
|
||||
r.pattern.split(",").some((p) => isMatch(href, p.trim()))
|
||||
);
|
||||
const globalRule = {
|
||||
...GLOBLA_RULE,
|
||||
...(rules.find((r) => r.pattern === GLOBAL_KEY) || {}),
|
||||
};
|
||||
if (!rule) {
|
||||
return globalRule;
|
||||
}
|
||||
|
||||
[
|
||||
"selector",
|
||||
"keepSelector",
|
||||
"rootsSelector",
|
||||
"ignoreSelector",
|
||||
"terms",
|
||||
"aiTerms",
|
||||
"termsStyle",
|
||||
"highlightStyle",
|
||||
"selectStyle",
|
||||
"parentStyle",
|
||||
"grandStyle",
|
||||
"injectJs",
|
||||
// "injectCss",
|
||||
// "fixerSelector",
|
||||
"transStartHook",
|
||||
"transEndHook",
|
||||
// "transRemoveHook",
|
||||
].forEach((key) => {
|
||||
if (!rule[key]?.trim()) {
|
||||
rule[key] = globalRule[key];
|
||||
}
|
||||
});
|
||||
|
||||
[
|
||||
"apiSlug",
|
||||
"fromLang",
|
||||
"toLang",
|
||||
"transOpen",
|
||||
"transOnly",
|
||||
// "transTiming",
|
||||
"autoScan",
|
||||
"hasRichText",
|
||||
"hasShadowroot",
|
||||
"transTag",
|
||||
"transTitle",
|
||||
// "detectRemote",
|
||||
// "fixerFunc",
|
||||
"splitParagraph",
|
||||
"highlightWords",
|
||||
].forEach((key) => {
|
||||
if (!rule[key] || rule[key] === GLOBAL_KEY) {
|
||||
rule[key] = globalRule[key];
|
||||
}
|
||||
});
|
||||
|
||||
["splitLength"].forEach((key) => {
|
||||
if (!rule[key]) {
|
||||
rule[key] = globalRule[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;
|
||||
} else {
|
||||
rule.bgColor = rule.bgColor?.trim() || globalRule.bgColor;
|
||||
rule.textDiyStyle = rule.textDiyStyle?.trim() || globalRule.textDiyStyle;
|
||||
}
|
||||
|
||||
return rule;
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查过滤rules
|
||||
@@ -27,6 +127,8 @@ export const checkRules = (rules) => {
|
||||
throw new Error("data error");
|
||||
}
|
||||
|
||||
const fromLangs = OPT_LANGS_FROM.map((item) => item[0]);
|
||||
const toLangs = OPT_LANGS_TO.map((item) => item[0]);
|
||||
const patternSet = new Set();
|
||||
rules = rules
|
||||
.filter((rule) => type(rule) === "object")
|
||||
@@ -41,21 +143,91 @@ export const checkRules = (rules) => {
|
||||
({
|
||||
pattern,
|
||||
selector,
|
||||
translator,
|
||||
keepSelector,
|
||||
rootsSelector,
|
||||
ignoreSelector,
|
||||
terms,
|
||||
aiTerms,
|
||||
termsStyle,
|
||||
highlightStyle,
|
||||
selectStyle,
|
||||
parentStyle,
|
||||
grandStyle,
|
||||
injectJs,
|
||||
// injectCss,
|
||||
apiSlug,
|
||||
fromLang,
|
||||
toLang,
|
||||
textStyle,
|
||||
transOpen,
|
||||
bgColor,
|
||||
textDiyStyle,
|
||||
transOnly,
|
||||
autoScan,
|
||||
hasRichText,
|
||||
hasShadowroot,
|
||||
// transTiming,
|
||||
transTag,
|
||||
transTitle,
|
||||
// detectRemote,
|
||||
// skipLangs,
|
||||
// fixerSelector,
|
||||
// fixerFunc,
|
||||
transStartHook,
|
||||
transEndHook,
|
||||
// transRemoveHook,
|
||||
splitParagraph,
|
||||
splitLength,
|
||||
highlightWords,
|
||||
}) => ({
|
||||
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 : "",
|
||||
termsStyle: type(termsStyle) === "string" ? termsStyle : "",
|
||||
highlightStyle: type(highlightStyle) === "string" ? highlightStyle : "",
|
||||
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 : "",
|
||||
translator: matchValue([GLOBAL_KEY, ...OPT_TRANS_ALL], translator),
|
||||
textDiyStyle: type(textDiyStyle) === "string" ? textDiyStyle : "",
|
||||
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),
|
||||
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 : "",
|
||||
transStartHook: type(transStartHook) === "string" ? transStartHook : "",
|
||||
transEndHook: type(transEndHook) === "string" ? transEndHook : "",
|
||||
// transRemoveHook:
|
||||
// type(transRemoveHook) === "string" ? transRemoveHook : "",
|
||||
// fixerFunc: matchValue([GLOBAL_KEY, ...FIXER_ALL], fixerFunc),
|
||||
splitParagraph: matchValue(
|
||||
[GLOBAL_KEY, ...OPT_SPLIT_PARAGRAPH_ALL],
|
||||
splitParagraph
|
||||
),
|
||||
splitLength: Number.isInteger(splitLength) ? splitLength : 0,
|
||||
highlightWords: matchValue(
|
||||
[GLOBAL_KEY, ...OPT_HIGHLIGHT_WORDS_ALL],
|
||||
highlightWords
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -63,83 +235,29 @@ export const checkRules = (rules) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 订阅规则的本地缓存
|
||||
* 保存或更新rule
|
||||
* @param {*} curRule
|
||||
*/
|
||||
export const rulesCache = {
|
||||
fetch: async (url, isBg = false) => {
|
||||
const res = await fetchPolyfill(url, { isBg });
|
||||
const rules = checkRules(res).filter(
|
||||
(rule) => rule.pattern.replaceAll(GLOBAL_KEY, "") !== ""
|
||||
);
|
||||
return rules;
|
||||
},
|
||||
set: async (url, rules) => {
|
||||
await storage.setObj(`${STOKEY_RULESCACHE_PREFIX}${url}`, rules);
|
||||
},
|
||||
get: async (url) => {
|
||||
return await storage.getObj(`${STOKEY_RULESCACHE_PREFIX}${url}`);
|
||||
},
|
||||
del: async (url) => {
|
||||
await storage.del(`${STOKEY_RULESCACHE_PREFIX}${url}`);
|
||||
},
|
||||
};
|
||||
export const saveRule = async (curRule) => {
|
||||
const rules = await getRulesWithDefault();
|
||||
|
||||
/**
|
||||
* 同步订阅规则
|
||||
* @param {*} url
|
||||
* @returns
|
||||
*/
|
||||
export const syncSubRules = async (url, isBg = false) => {
|
||||
const rules = await rulesCache.fetch(url, isBg);
|
||||
if (rules.length > 0) {
|
||||
await rulesCache.set(url, rules);
|
||||
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 };
|
||||
}
|
||||
return rules;
|
||||
};
|
||||
|
||||
/**
|
||||
* 同步所有订阅规则
|
||||
* @param {*} url
|
||||
* @returns
|
||||
*/
|
||||
export const syncAllSubRules = async (subrulesList, isBg = false) => {
|
||||
for (let subrules of subrulesList) {
|
||||
try {
|
||||
await syncSubRules(subrules.url, isBg);
|
||||
} catch (err) {
|
||||
console.log(`[sync subrule error]: ${subrules.url}`, err);
|
||||
}
|
||||
}
|
||||
};
|
||||
const newRule = {};
|
||||
Object.entries(GLOBLA_RULE).forEach(([key, val]) => {
|
||||
newRule[key] =
|
||||
!curRule[key] || curRule[key] === val ? DEFAULT_RULE[key] : curRule[key];
|
||||
});
|
||||
|
||||
/**
|
||||
* 根据时间同步所有订阅规则
|
||||
* @param {*} url
|
||||
* @returns
|
||||
*/
|
||||
export const trySyncAllSubRules = async ({ subrulesList }, isBg = false) => {
|
||||
try {
|
||||
const { subRulesSyncAt } = await syncOpt.load();
|
||||
const now = Date.now();
|
||||
const interval = 24 * 60 * 60 * 1000; // 间隔一天
|
||||
if (now - subRulesSyncAt > interval) {
|
||||
await syncAllSubRules(subrulesList, isBg);
|
||||
await syncOpt.update({ subRulesSyncAt: now });
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[try sync all subrules]", err);
|
||||
}
|
||||
};
|
||||
rules.unshift(newRule);
|
||||
await setRules(rules);
|
||||
|
||||
/**
|
||||
* 从缓存或远程加载订阅规则
|
||||
* @param {*} url
|
||||
* @returns
|
||||
*/
|
||||
export const loadSubRules = async (url) => {
|
||||
const rules = await rulesCache.get(url);
|
||||
if (rules?.length) {
|
||||
return rules;
|
||||
}
|
||||
return await syncSubRules(url);
|
||||
trySyncRules();
|
||||
};
|
||||
|
||||
128
src/libs/shadowDomManager.js
Normal file
128
src/libs/shadowDomManager.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { CacheProvider } from "@emotion/react";
|
||||
import createCache from "@emotion/cache";
|
||||
import { logger } from "./log";
|
||||
|
||||
export default class ShadowDomManager {
|
||||
#hostElement = null;
|
||||
#reactRoot = null;
|
||||
#isVisible = false;
|
||||
#isProcessing = false;
|
||||
|
||||
_id;
|
||||
_className;
|
||||
_ReactComponent;
|
||||
_props;
|
||||
|
||||
constructor({ id, className = "", reactComponent, props = {} }) {
|
||||
if (!id || !reactComponent) {
|
||||
throw new Error("ID and a React Component must be provided.");
|
||||
}
|
||||
this._id = id;
|
||||
this._className = className;
|
||||
this._ReactComponent = reactComponent;
|
||||
this._props = props;
|
||||
}
|
||||
|
||||
get isVisible() {
|
||||
return this.#isVisible;
|
||||
}
|
||||
|
||||
show(props) {
|
||||
if (this.#isVisible || this.#isProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.#hostElement) {
|
||||
this.#isProcessing = true;
|
||||
try {
|
||||
this.#mount(props || this._props);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to mount component with id "${this._id}":`, error);
|
||||
this.#isProcessing = false;
|
||||
return;
|
||||
} finally {
|
||||
this.#isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
this.#hostElement.style.display = "";
|
||||
this.#isVisible = true;
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this.#isVisible || !this.#hostElement) {
|
||||
return;
|
||||
}
|
||||
this.#hostElement.style.display = "none";
|
||||
this.#isVisible = false;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (!this.#hostElement) {
|
||||
return;
|
||||
}
|
||||
this.#isProcessing = true;
|
||||
|
||||
if (this.#reactRoot) {
|
||||
this.#reactRoot.unmount();
|
||||
}
|
||||
|
||||
this.#hostElement.remove();
|
||||
|
||||
this.#hostElement = null;
|
||||
this.#reactRoot = null;
|
||||
this.#isVisible = false;
|
||||
this.#isProcessing = false;
|
||||
logger.info(`Component with id "${this._id}" has been destroyed.`);
|
||||
}
|
||||
|
||||
toggle(props) {
|
||||
if (this.#isVisible) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show(props || this._props);
|
||||
}
|
||||
}
|
||||
|
||||
#mount(props) {
|
||||
const host = document.createElement("div");
|
||||
host.id = this._id;
|
||||
if (this._className) {
|
||||
host.className = this._className;
|
||||
}
|
||||
host.style.display = "none";
|
||||
document.body.parentElement.appendChild(host);
|
||||
this.#hostElement = host;
|
||||
|
||||
const shadowContainer = host.attachShadow({ mode: "closed" });
|
||||
const emotionRoot = document.createElement("style");
|
||||
const appRoot = document.createElement("div");
|
||||
appRoot.className = `${this._id}_wrapper`;
|
||||
|
||||
shadowContainer.appendChild(emotionRoot);
|
||||
shadowContainer.appendChild(appRoot);
|
||||
|
||||
const cache = createCache({
|
||||
key: this._id,
|
||||
prepend: true,
|
||||
container: emotionRoot,
|
||||
});
|
||||
|
||||
const enhancedProps = {
|
||||
...props,
|
||||
onClose: this.hide.bind(this),
|
||||
};
|
||||
|
||||
const ComponentToRender = this._ReactComponent;
|
||||
this.#reactRoot = ReactDOM.createRoot(appRoot);
|
||||
this.#reactRoot.render(
|
||||
<React.StrictMode>
|
||||
<CacheProvider value={cache}>
|
||||
<ComponentToRender {...enhancedProps} />
|
||||
</CacheProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
}
|
||||
120
src/libs/shortcut.js
Normal file
120
src/libs/shortcut.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import { isSameSet } from "./utils";
|
||||
|
||||
/**
|
||||
* 键盘快捷键监听器
|
||||
* @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 = (
|
||||
onKeyDown = () => {},
|
||||
onKeyUp = () => {},
|
||||
target = document
|
||||
) => {
|
||||
const pressedKeys = new Set();
|
||||
|
||||
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) => {
|
||||
if (!e.code) {
|
||||
return;
|
||||
}
|
||||
|
||||
// onKeyUp 应该在 key 从集合中移除前触发,以便判断组合键
|
||||
onKeyUp(new Set(pressedKeys), e);
|
||||
pressedKeys.delete(e.code);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
pressedKeys.clear();
|
||||
};
|
||||
|
||||
target.addEventListener("keydown", handleKeyDown);
|
||||
target.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
|
||||
return () => {
|
||||
target.removeEventListener("keydown", handleKeyDown);
|
||||
target.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
pressedKeys.clear();
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 注册键盘快捷键
|
||||
* @param {string[]} targetKeys - 目标快捷键数组
|
||||
* @param {() => void} fn - 匹配成功后执行的回调
|
||||
* @param {EventTarget} target - 监听目标
|
||||
* @returns {() => void} - 注销函数
|
||||
*/
|
||||
export const shortcutRegister = (targetKeys = [], fn, target = document) => {
|
||||
if (targetKeys.length === 0) return () => {};
|
||||
|
||||
const targetKeySet = new Set(targetKeys);
|
||||
const onKeyDown = (pressedKeys, event) => {
|
||||
if (isSameSet(targetKeySet, pressedKeys)) {
|
||||
// event.preventDefault(); // 阻止浏览器的默认行为
|
||||
// event.stopPropagation(); // 阻止事件继续(向父元素)冒泡
|
||||
fn();
|
||||
}
|
||||
};
|
||||
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 {string[]} targetKeys - 目标快捷键数组
|
||||
* @param {() => void} fn - 成功回调
|
||||
* @param {number} step - 连续触发次数
|
||||
* @param {number} timeout - 每次触发的间隔超时
|
||||
* @param {EventTarget} target - 监听目标
|
||||
* @returns {() => void} - 注销函数
|
||||
*/
|
||||
export const stepShortcutRegister = (
|
||||
targetKeys = [],
|
||||
fn,
|
||||
step = 2,
|
||||
timeout = 500,
|
||||
target = document
|
||||
) => {
|
||||
const steppedFn = withStepCounter(fn, step, timeout);
|
||||
return shortcutRegister(targetKeys, steppedFn, target);
|
||||
};
|
||||
@@ -1,28 +1,32 @@
|
||||
import { browser, isExt, isGm } from "./browser";
|
||||
import {
|
||||
STOKEY_SETTING,
|
||||
STOKEY_SETTING_OLD,
|
||||
STOKEY_RULES,
|
||||
STOKEY_RULES_OLD,
|
||||
STOKEY_WORDS,
|
||||
STOKEY_FAB,
|
||||
STOKEY_TRANBOX,
|
||||
STOKEY_SYNC,
|
||||
STOKEY_MSAUTH,
|
||||
STOKEY_BDAUTH,
|
||||
STOKEY_RULESCACHE_PREFIX,
|
||||
DEFAULT_SETTING,
|
||||
DEFAULT_RULES,
|
||||
DEFAULT_SYNC,
|
||||
BUILTIN_RULES,
|
||||
} from "../config";
|
||||
import { isExt, isGm } from "./client";
|
||||
import { browser } from "./browser";
|
||||
import { kissLog } from "./log";
|
||||
import { debounce } from "./utils";
|
||||
|
||||
async function set(key, val) {
|
||||
if (isExt) {
|
||||
await browser.storage.local.set({ [key]: val });
|
||||
} else if (isGm) {
|
||||
const oldValue = await GM.getValue(key);
|
||||
await GM.setValue(key, val);
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", {
|
||||
key,
|
||||
oldValue,
|
||||
newValue: val,
|
||||
})
|
||||
);
|
||||
await (window.KISS_GM || GM).setValue(key, val);
|
||||
} else {
|
||||
const oldValue = window.localStorage.getItem(key);
|
||||
window.localStorage.setItem(key, val);
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", {
|
||||
key,
|
||||
oldValue,
|
||||
newValue: val,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +35,7 @@ async function get(key) {
|
||||
const val = await browser.storage.local.get([key]);
|
||||
return val[key];
|
||||
} else if (isGm) {
|
||||
const val = await GM.getValue(key);
|
||||
const val = await (window.KISS_GM || GM).getValue(key);
|
||||
return val;
|
||||
}
|
||||
return window.localStorage.getItem(key);
|
||||
@@ -41,25 +45,9 @@ async function del(key) {
|
||||
if (isExt) {
|
||||
await browser.storage.local.remove([key]);
|
||||
} else if (isGm) {
|
||||
const oldValue = await GM.getValue(key);
|
||||
await GM.deleteValue(key);
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", {
|
||||
key,
|
||||
oldValue,
|
||||
newValue: null,
|
||||
})
|
||||
);
|
||||
await (window.KISS_GM || GM).deleteValue(key);
|
||||
} else {
|
||||
const oldValue = window.localStorage.getItem(key);
|
||||
window.localStorage.removeItem(key);
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", {
|
||||
key,
|
||||
oldValue,
|
||||
newValue: null,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +63,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) {
|
||||
@@ -83,22 +77,10 @@ async function putObj(key, obj) {
|
||||
await setObj(key, { ...cur, ...obj });
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听storage事件
|
||||
* @param {*} handleChanged
|
||||
*/
|
||||
function onChanged(handleChanged) {
|
||||
if (isExt) {
|
||||
browser.storage.onChanged.addListener(handleChanged);
|
||||
} else {
|
||||
window.addEventListener("storage", handleChanged);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对storage的封装
|
||||
*/
|
||||
const storage = {
|
||||
export const storage = {
|
||||
get,
|
||||
set,
|
||||
del,
|
||||
@@ -106,7 +88,99 @@ const storage = {
|
||||
trySetObj,
|
||||
getObj,
|
||||
putObj,
|
||||
onChanged,
|
||||
// onChanged,
|
||||
};
|
||||
|
||||
export default 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 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);
|
||||
|
||||
/**
|
||||
* 词汇列表
|
||||
*/
|
||||
export const getWords = () => getObj(STOKEY_WORDS);
|
||||
export const getWordsWithDefault = async () => (await getWords()) || {};
|
||||
export const setWords = (val) => setObj(STOKEY_WORDS, val);
|
||||
|
||||
/**
|
||||
* 订阅规则
|
||||
*/
|
||||
export const getSubRules = (url) => getObj(STOKEY_RULESCACHE_PREFIX + url);
|
||||
export const getSubRulesWithDefault = async () => (await getSubRules()) || [];
|
||||
export const delSubRules = (url) => del(STOKEY_RULESCACHE_PREFIX + url);
|
||||
export const setSubRules = (url, val) =>
|
||||
setObj(STOKEY_RULESCACHE_PREFIX + url, val);
|
||||
|
||||
/**
|
||||
* fab位置
|
||||
*/
|
||||
export const getFab = () => getObj(STOKEY_FAB);
|
||||
export const getFabWithDefault = async () => (await getFab()) || {};
|
||||
export const setFab = (obj) => setObj(STOKEY_FAB, obj);
|
||||
export const putFab = (obj) => putObj(STOKEY_FAB, obj);
|
||||
|
||||
/**
|
||||
* tranbox位置大小
|
||||
*/
|
||||
export const getTranBox = () => getObj(STOKEY_TRANBOX);
|
||||
export const putTranBox = (obj) => putObj(STOKEY_TRANBOX, obj);
|
||||
export const debouncePutTranBox = debounce(putTranBox, 300);
|
||||
|
||||
/**
|
||||
* 数据同步
|
||||
*/
|
||||
export const getSync = () => getObj(STOKEY_SYNC);
|
||||
export const getSyncWithDefault = async () => (await getSync()) || DEFAULT_SYNC;
|
||||
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
|
||||
*/
|
||||
export const getMsauth = () => getObj(STOKEY_MSAUTH);
|
||||
export const setMsauth = (val) => setObj(STOKEY_MSAUTH, val);
|
||||
|
||||
/**
|
||||
* baidu auth
|
||||
*/
|
||||
export const getBdauth = () => getObj(STOKEY_BDAUTH);
|
||||
export const setBdauth = (val) => setObj(STOKEY_BDAUTH, val);
|
||||
|
||||
/**
|
||||
* 存入默认数据
|
||||
*/
|
||||
export const tryInitDefaultData = async () => {
|
||||
try {
|
||||
await trySetObj(STOKEY_SETTING, DEFAULT_SETTING);
|
||||
await trySetObj(STOKEY_RULES, DEFAULT_RULES);
|
||||
await trySetObj(STOKEY_SYNC, DEFAULT_SYNC);
|
||||
await trySetObj(
|
||||
`${STOKEY_RULESCACHE_PREFIX}${process.env.REACT_APP_RULESURL}`,
|
||||
BUILTIN_RULES
|
||||
);
|
||||
} catch (err) {
|
||||
kissLog("init default", err);
|
||||
}
|
||||
};
|
||||
|
||||
166
src/libs/style.js
Normal file
166
src/libs/style.js
Normal file
@@ -0,0 +1,166 @@
|
||||
import { css, keyframes } from "@emotion/css";
|
||||
import {
|
||||
OPT_STYLE_NONE,
|
||||
OPT_STYLE_LINE,
|
||||
OPT_STYLE_DOTLINE,
|
||||
OPT_STYLE_DASHLINE,
|
||||
OPT_STYLE_WAVYLINE,
|
||||
OPT_STYLE_DASHBOX,
|
||||
OPT_STYLE_FUZZY,
|
||||
OPT_STYLE_HIGHLIGHT,
|
||||
OPT_STYLE_BLOCKQUOTE,
|
||||
OPT_STYLE_GRADIENT,
|
||||
OPT_STYLE_BLINK,
|
||||
OPT_STYLE_GLOW,
|
||||
OPT_STYLE_DIY,
|
||||
DEFAULT_DIY_STYLE,
|
||||
DEFAULT_COLOR,
|
||||
} from "../config";
|
||||
|
||||
const gradientFlow = keyframes`
|
||||
to {
|
||||
background-position: 200% center;
|
||||
}
|
||||
`;
|
||||
|
||||
const blink = keyframes`
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const glow = keyframes`
|
||||
from {
|
||||
text-shadow: 0 0 10px #fff,
|
||||
0 0 20px #fff,
|
||||
0 0 30px #0073e6,
|
||||
0 0 40px #0073e6;
|
||||
}
|
||||
to {
|
||||
text-shadow: 0 0 20px #fff,
|
||||
0 0 30px #ff4da6,
|
||||
0 0 40px #ff4da6,
|
||||
0 0 50px #ff4da6;
|
||||
}
|
||||
`;
|
||||
|
||||
const genLineStyle = (style, color) => `
|
||||
text-decoration-line: underline;
|
||||
text-decoration-style: ${style};
|
||||
text-decoration-color: ${color};
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 0.3em;
|
||||
-webkit-text-decoration-line: underline;
|
||||
-webkit-text-decoration-style: ${style};
|
||||
-webkit-text-decoration-color: ${color};
|
||||
-webkit-text-decoration-thickness: 2px;
|
||||
-webkit-text-underline-offset: 0.3em;
|
||||
|
||||
/* opacity: 0.8;
|
||||
-webkit-opacity: 0.8;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
-webkit-opacity: 1;
|
||||
} */
|
||||
`;
|
||||
|
||||
const genStyles = ({
|
||||
textDiyStyle = DEFAULT_DIY_STYLE,
|
||||
bgColor = DEFAULT_COLOR,
|
||||
} = {}) => ({
|
||||
// 无样式
|
||||
[OPT_STYLE_NONE]: ``,
|
||||
// 下划线
|
||||
[OPT_STYLE_LINE]: genLineStyle("solid", bgColor),
|
||||
// 点状线
|
||||
[OPT_STYLE_DOTLINE]: genLineStyle("dotted", bgColor),
|
||||
// 虚线
|
||||
[OPT_STYLE_DASHLINE]: genLineStyle("dashed", bgColor),
|
||||
// 波浪线
|
||||
[OPT_STYLE_WAVYLINE]: genLineStyle("wavy", bgColor),
|
||||
// 虚线框
|
||||
[OPT_STYLE_DASHBOX]: `
|
||||
border: 2px dashed ${bgColor || DEFAULT_COLOR};
|
||||
display: 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();
|
||||
87
src/libs/subRules.js
Normal file
87
src/libs/subRules.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { GLOBAL_KEY } from "../config";
|
||||
import {
|
||||
getSyncWithDefault,
|
||||
putSync,
|
||||
setSubRules,
|
||||
getSubRules,
|
||||
} from "./storage";
|
||||
import { apiFetch } from "../apis";
|
||||
import { checkRules } from "./rules";
|
||||
import { isAllchar } from "./utils";
|
||||
import { kissLog } from "./log";
|
||||
|
||||
/**
|
||||
* 更新缓存同步时间
|
||||
* @param {*} url
|
||||
*/
|
||||
const updateSyncDataCache = async (url) => {
|
||||
const { dataCaches = {} } = await getSyncWithDefault();
|
||||
dataCaches[url] = Date.now();
|
||||
await putSync({ dataCaches });
|
||||
};
|
||||
|
||||
/**
|
||||
* 同步订阅规则
|
||||
* @param {*} url
|
||||
* @returns
|
||||
*/
|
||||
export const syncSubRules = async (url) => {
|
||||
const res = await apiFetch(url);
|
||||
const rules = checkRules(res).filter(
|
||||
({ pattern }) => !isAllchar(pattern, GLOBAL_KEY)
|
||||
);
|
||||
if (rules.length > 0) {
|
||||
await setSubRules(url, rules);
|
||||
}
|
||||
return rules;
|
||||
};
|
||||
|
||||
/**
|
||||
* 同步所有订阅规则
|
||||
* @param {*} url
|
||||
* @returns
|
||||
*/
|
||||
export const syncAllSubRules = async (subrulesList) => {
|
||||
for (const subrules of subrulesList) {
|
||||
try {
|
||||
await syncSubRules(subrules.url);
|
||||
await updateSyncDataCache(subrules.url);
|
||||
} catch (err) {
|
||||
kissLog(`sync subrule error: ${subrules.url}`, err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据时间同步所有订阅规则
|
||||
* @param {*} url
|
||||
* @returns
|
||||
*/
|
||||
export const trySyncAllSubRules = async ({ subrulesList }) => {
|
||||
try {
|
||||
const { subRulesSyncAt } = await getSyncWithDefault();
|
||||
const now = Date.now();
|
||||
const interval = 24 * 60 * 60 * 1000; // 间隔一天
|
||||
if (now - subRulesSyncAt > interval) {
|
||||
// 同步订阅规则
|
||||
await syncAllSubRules(subrulesList);
|
||||
await putSync({ subRulesSyncAt: now });
|
||||
}
|
||||
} catch (err) {
|
||||
kissLog("try sync all subrules", err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从缓存或远程加载订阅规则
|
||||
* @param {*} url
|
||||
* @returns
|
||||
*/
|
||||
export const loadOrFetchSubRules = async (url) => {
|
||||
let rules = await getSubRules(url);
|
||||
if (!rules || rules.length === 0) {
|
||||
rules = await syncSubRules(url);
|
||||
await updateSyncDataCache(url);
|
||||
}
|
||||
return rules || [];
|
||||
};
|
||||
105
src/libs/svg.js
Normal file
105
src/libs/svg.js
Normal file
@@ -0,0 +1,105 @@
|
||||
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 = "24",
|
||||
height = "24",
|
||||
viewBox = "-5 -5 40 40",
|
||||
isSelected = false,
|
||||
} = {}) {
|
||||
const svg = createSVGElement("svg", {
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
width,
|
||||
height,
|
||||
viewBox,
|
||||
version: "1.1",
|
||||
});
|
||||
|
||||
const primaryColor = "#209CEE";
|
||||
const secondaryColor = "#E9F5FD";
|
||||
|
||||
const path1Fill = isSelected ? primaryColor : secondaryColor;
|
||||
const path2Fill = isSelected ? secondaryColor : primaryColor;
|
||||
|
||||
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: path1Fill,
|
||||
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: path2Fill,
|
||||
transform: "translate(4,5)",
|
||||
});
|
||||
|
||||
svg.appendChild(path1);
|
||||
svg.appendChild(path2);
|
||||
|
||||
return svg;
|
||||
}
|
||||
242
src/libs/sync.js
242
src/libs/sync.js
@@ -1,62 +1,133 @@
|
||||
import {
|
||||
STOKEY_SYNC,
|
||||
DEFAULT_SYNC,
|
||||
APP_LCNAME,
|
||||
KV_SETTING_KEY,
|
||||
KV_RULES_KEY,
|
||||
KV_WORDS_KEY,
|
||||
KV_RULES_SHARE_KEY,
|
||||
STOKEY_SETTING,
|
||||
STOKEY_RULES,
|
||||
KV_SALT_SHARE,
|
||||
OPT_SYNCTYPE_WEBDAV,
|
||||
} from "../config";
|
||||
import storage from "../libs/storage";
|
||||
import { getSetting, getRules } from ".";
|
||||
import {
|
||||
getSyncWithDefault,
|
||||
putSync,
|
||||
getSettingWithDefault,
|
||||
getRulesWithDefault,
|
||||
getWordsWithDefault,
|
||||
setSetting,
|
||||
setRules,
|
||||
setWords,
|
||||
} from "./storage";
|
||||
import { apiSyncData } from "../apis";
|
||||
import { sha256 } from "./utils";
|
||||
import { sha256, removeEndchar } from "./utils";
|
||||
import { createClient, getPatcher } from "webdav";
|
||||
import { fetchPatcher } from "./fetch";
|
||||
import { kissLog } from "./log";
|
||||
|
||||
/**
|
||||
* 同步相关数据
|
||||
*/
|
||||
export const syncOpt = {
|
||||
load: async () => (await storage.getObj(STOKEY_SYNC)) || DEFAULT_SYNC,
|
||||
update: async (obj) => {
|
||||
await storage.putObj(STOKEY_SYNC, obj);
|
||||
},
|
||||
getPatcher().patch("request", (opts) => {
|
||||
return fetchPatcher(opts.url, {
|
||||
method: opts.method,
|
||||
headers: opts.headers,
|
||||
body: opts.data,
|
||||
});
|
||||
});
|
||||
|
||||
const syncByWebdav = async (data, { syncUrl, syncUser, syncKey }) => {
|
||||
const client = createClient(syncUrl, {
|
||||
username: syncUser,
|
||||
password: syncKey,
|
||||
});
|
||||
const pathname = `/${APP_LCNAME}`;
|
||||
const filename = `/${APP_LCNAME}/${data.key}`;
|
||||
|
||||
if ((await client.exists(pathname)) === false) {
|
||||
await client.createDirectory(pathname);
|
||||
}
|
||||
|
||||
const isExist = await client.exists(filename);
|
||||
if (isExist) {
|
||||
const cont = await client.getFileContents(filename, { format: "text" });
|
||||
const webData = JSON.parse(cont);
|
||||
if (webData.updateAt >= data.updateAt) {
|
||||
return webData;
|
||||
}
|
||||
}
|
||||
|
||||
await client.putFileContents(filename, JSON.stringify(data, null, 2));
|
||||
return data;
|
||||
};
|
||||
|
||||
const syncByWorker = async (data, { syncUrl, syncKey }) => {
|
||||
syncUrl = removeEndchar(syncUrl, "/");
|
||||
return await apiSyncData(`${syncUrl}/sync`, syncKey, data);
|
||||
};
|
||||
|
||||
export const syncData = async (key, value) => {
|
||||
const {
|
||||
syncType,
|
||||
syncUrl,
|
||||
syncUser,
|
||||
syncKey,
|
||||
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] || {};
|
||||
if (syncAt === 0) {
|
||||
updateAt = 0; // 没有同步过,更新时间置零
|
||||
}
|
||||
|
||||
const data = {
|
||||
key,
|
||||
value: JSON.stringify(value),
|
||||
updateAt,
|
||||
};
|
||||
const args = {
|
||||
syncUrl,
|
||||
syncUser,
|
||||
syncKey,
|
||||
};
|
||||
|
||||
const res =
|
||||
syncType === OPT_SYNCTYPE_WEBDAV
|
||||
? 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 putSync({ syncMeta });
|
||||
|
||||
return { value: newVal, isNew };
|
||||
};
|
||||
|
||||
/**
|
||||
* 同步设置
|
||||
* @returns
|
||||
*/
|
||||
export const syncSetting = async (isBg = false) => {
|
||||
const syncSetting = async () => {
|
||||
const value = await getSettingWithDefault();
|
||||
const res = await syncData(KV_SETTING_KEY, value);
|
||||
if (res?.isNew) {
|
||||
await setSetting(res.value);
|
||||
}
|
||||
};
|
||||
|
||||
export const trySyncSetting = async () => {
|
||||
try {
|
||||
const { syncUrl, syncKey, settingUpdateAt } = await syncOpt.load();
|
||||
if (!syncUrl || !syncKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setting = await getSetting();
|
||||
const res = await apiSyncData(
|
||||
syncUrl,
|
||||
syncKey,
|
||||
{
|
||||
key: KV_SETTING_KEY,
|
||||
value: setting,
|
||||
updateAt: settingUpdateAt,
|
||||
},
|
||||
isBg
|
||||
);
|
||||
|
||||
if (res && res.updateAt > settingUpdateAt) {
|
||||
await syncOpt.update({
|
||||
settingUpdateAt: res.updateAt,
|
||||
settingSyncAt: res.updateAt,
|
||||
});
|
||||
await storage.setObj(STOKEY_SETTING, res.value);
|
||||
} else {
|
||||
await syncOpt.update({ settingSyncAt: res.updateAt });
|
||||
}
|
||||
await syncSetting();
|
||||
} catch (err) {
|
||||
console.log("[sync setting]", err);
|
||||
kissLog("sync setting", err.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -64,36 +135,39 @@ export const syncSetting = async (isBg = false) => {
|
||||
* 同步规则
|
||||
* @returns
|
||||
*/
|
||||
export const syncRules = async (isBg = false) => {
|
||||
const syncRules = async () => {
|
||||
const value = await getRulesWithDefault();
|
||||
const res = await syncData(KV_RULES_KEY, value);
|
||||
if (res?.isNew) {
|
||||
await setRules(res.value);
|
||||
}
|
||||
};
|
||||
|
||||
export const trySyncRules = async () => {
|
||||
try {
|
||||
const { syncUrl, syncKey, rulesUpdateAt } = await syncOpt.load();
|
||||
if (!syncUrl || !syncKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rules = await getRules();
|
||||
const res = await apiSyncData(
|
||||
syncUrl,
|
||||
syncKey,
|
||||
{
|
||||
key: KV_RULES_KEY,
|
||||
value: rules,
|
||||
updateAt: rulesUpdateAt,
|
||||
},
|
||||
isBg
|
||||
);
|
||||
|
||||
if (res && res.updateAt > rulesUpdateAt) {
|
||||
await syncOpt.update({
|
||||
rulesUpdateAt: res.updateAt,
|
||||
rulesSyncAt: res.updateAt,
|
||||
});
|
||||
await storage.setObj(STOKEY_RULES, res.value);
|
||||
} else {
|
||||
await syncOpt.update({ rulesSyncAt: res.updateAt });
|
||||
}
|
||||
await syncRules();
|
||||
} catch (err) {
|
||||
console.log("[sync user rules]", err);
|
||||
kissLog("sync user rules", err.message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 同步词汇
|
||||
* @returns
|
||||
*/
|
||||
const syncWords = async () => {
|
||||
const value = await getWordsWithDefault();
|
||||
const res = await syncData(KV_WORDS_KEY, value);
|
||||
if (res?.isNew) {
|
||||
await setWords(res.value);
|
||||
}
|
||||
};
|
||||
|
||||
export const trySyncWords = async () => {
|
||||
try {
|
||||
await syncWords();
|
||||
} catch (err) {
|
||||
kissLog("sync fav words", err.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -103,13 +177,18 @@ export const syncRules = async (isBg = false) => {
|
||||
* @returns
|
||||
*/
|
||||
export const syncShareRules = async ({ rules, syncUrl, syncKey }) => {
|
||||
await apiSyncData(syncUrl, syncKey, {
|
||||
const data = {
|
||||
key: KV_RULES_SHARE_KEY,
|
||||
value: rules,
|
||||
value: JSON.stringify(rules, null, 2),
|
||||
updateAt: Date.now(),
|
||||
});
|
||||
};
|
||||
const args = {
|
||||
syncUrl,
|
||||
syncKey,
|
||||
};
|
||||
await syncByWorker(data, args);
|
||||
const psk = await sha256(syncKey, KV_SALT_SHARE);
|
||||
const shareUrl = `${syncUrl}?psk=${psk}`;
|
||||
const shareUrl = `${syncUrl}/rules?psk=${psk}`;
|
||||
return shareUrl;
|
||||
};
|
||||
|
||||
@@ -117,7 +196,14 @@ export const syncShareRules = async ({ rules, syncUrl, syncKey }) => {
|
||||
* 同步个人设置和规则
|
||||
* @returns
|
||||
*/
|
||||
export const syncAll = async (isBg = false) => {
|
||||
await syncSetting(isBg);
|
||||
await syncRules(isBg);
|
||||
export const syncSettingAndRules = async () => {
|
||||
await syncSetting();
|
||||
await syncRules();
|
||||
await syncWords();
|
||||
};
|
||||
|
||||
export const trySyncSettingAndRules = async () => {
|
||||
await trySyncSetting();
|
||||
await trySyncRules();
|
||||
await trySyncWords();
|
||||
};
|
||||
|
||||
47
src/libs/touch.js
Normal file
47
src/libs/touch.js
Normal file
@@ -0,0 +1,47 @@
|
||||
export function touchTapListener(fn, options = {}) {
|
||||
const config = {
|
||||
taps: 2,
|
||||
fingers: 1,
|
||||
delay: 300,
|
||||
...options,
|
||||
};
|
||||
|
||||
let maxTouches = 0;
|
||||
let tapCount = 0;
|
||||
let tapTimer = null;
|
||||
|
||||
const handleTouchStart = (e) => {
|
||||
maxTouches = Math.max(maxTouches, e.touches.length);
|
||||
};
|
||||
|
||||
const handleTouchend = (e) => {
|
||||
if (e.touches.length === 0) {
|
||||
if (maxTouches === config.fingers) {
|
||||
tapCount++;
|
||||
clearTimeout(tapTimer);
|
||||
|
||||
if (tapCount === config.taps) {
|
||||
fn(e);
|
||||
tapCount = 0;
|
||||
} else {
|
||||
tapTimer = setTimeout(() => {
|
||||
tapCount = 0;
|
||||
}, config.delay);
|
||||
}
|
||||
} else {
|
||||
tapCount = 0;
|
||||
clearTimeout(tapTimer);
|
||||
}
|
||||
maxTouches = 0;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("touchstart", handleTouchStart, { passive: true });
|
||||
document.addEventListener("touchend", handleTouchend, { passive: true });
|
||||
|
||||
return () => {
|
||||
clearTimeout(tapTimer);
|
||||
document.removeEventListener("touchstart", handleTouchStart);
|
||||
document.removeEventListener("touchend", handleTouchend);
|
||||
};
|
||||
}
|
||||
96
src/libs/tranbox.js
Normal file
96
src/libs/tranbox.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import createCache from "@emotion/cache";
|
||||
import { CacheProvider } from "@emotion/react";
|
||||
import Slection from "../views/Selection";
|
||||
import { DEFAULT_TRANBOX_SETTING, APP_CONSTS } from "../config";
|
||||
|
||||
export class TransboxManager {
|
||||
#container = null;
|
||||
#reactRoot = null;
|
||||
#shadowContainer = null;
|
||||
#props = {};
|
||||
|
||||
constructor(initialProps = {}) {
|
||||
this.#props = initialProps;
|
||||
|
||||
const { tranboxSetting = DEFAULT_TRANBOX_SETTING } = this.#props;
|
||||
if (tranboxSetting?.transOpen) {
|
||||
this.enable();
|
||||
}
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return (
|
||||
!!this.#container && document.body.parentElement.contains(this.#container)
|
||||
);
|
||||
}
|
||||
|
||||
enable() {
|
||||
if (!this.isEnabled()) {
|
||||
this.#container = document.createElement("div");
|
||||
this.#container.id = APP_CONSTS.boxID;
|
||||
this.#container.className = "notranslate";
|
||||
this.#container.style.cssText =
|
||||
"font-size: 0; width: 0; height: 0; border: 0; padding: 0; margin: 0;";
|
||||
document.body.parentElement.appendChild(this.#container);
|
||||
|
||||
this.#shadowContainer = this.#container.attachShadow({ mode: "closed" });
|
||||
const emotionRoot = document.createElement("style");
|
||||
const shadowRootElement = document.createElement("div");
|
||||
shadowRootElement.className = `${APP_CONSTS.boxID}_warpper notranslate`;
|
||||
this.#shadowContainer.appendChild(emotionRoot);
|
||||
this.#shadowContainer.appendChild(shadowRootElement);
|
||||
const cache = createCache({
|
||||
key: APP_CONSTS.boxID,
|
||||
prepend: true,
|
||||
container: emotionRoot,
|
||||
});
|
||||
|
||||
this.#reactRoot = ReactDOM.createRoot(shadowRootElement);
|
||||
this.CacheProvider = ({ children }) => (
|
||||
<CacheProvider value={cache}>{children}</CacheProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const AppProvider = this.CacheProvider;
|
||||
this.#reactRoot.render(
|
||||
<React.StrictMode>
|
||||
<AppProvider>
|
||||
<Slection {...this.#props} />
|
||||
</AppProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
disable() {
|
||||
if (!this.isEnabled() || !this.#reactRoot) {
|
||||
return;
|
||||
}
|
||||
this.#reactRoot.unmount();
|
||||
this.#container.remove();
|
||||
this.#container = null;
|
||||
this.#reactRoot = null;
|
||||
this.#shadowContainer = null;
|
||||
this.CacheProvider = null;
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.isEnabled()) {
|
||||
this.disable();
|
||||
} else {
|
||||
this.enable();
|
||||
}
|
||||
}
|
||||
|
||||
update(newProps) {
|
||||
this.#props = { ...this.#props, ...newProps };
|
||||
if (this.isEnabled()) {
|
||||
if (!this.#props.tranboxSetting?.transOpen) {
|
||||
this.disable();
|
||||
} else {
|
||||
this.enable();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user