docs: update README with future plans

This commit is contained in:
lrsister
2025-08-19 10:11:40 +08:00
commit 5e1101baeb
128 changed files with 28886 additions and 0 deletions

5
.babelrc Normal file
View File

@@ -0,0 +1,5 @@
{
"presets": [
"@babel/preset-env"
]
}

19
.env Normal file
View File

@@ -0,0 +1,19 @@
GENERATE_SOURCEMAP=false
REACT_APP_NAME=KISS Translator
REACT_APP_NAME_CN=简约翻译
REACT_APP_VERSION=1.9.2
REACT_APP_HOMEPAGE=https://github.com/fishjar/kiss-translator
REACT_APP_OPTIONSPAGE=https://fishjar.github.io/kiss-translator/options.html
REACT_APP_OPTIONSPAGE_DEV=http://localhost:3000/options.html
REACT_APP_LOGOURL=https://fishjar.github.io/kiss-translator/images/logo192.png
REACT_APP_RULESURL=https://fishjar.github.io/kiss-rules/kiss-rules.json
REACT_APP_RULESURL_ON=https://fishjar.github.io/kiss-rules/kiss-rules-on.json
REACT_APP_RULESURL_OFF=https://fishjar.github.io/kiss-rules/kiss-rules-off.json
REACT_APP_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

73
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: publish release version
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
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-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: build-artifacts
path: build
- name: Deploy to GitHub Pages
uses: JamesIves/github-pages-deploy-action@v4
with:
folder: build/web
create-release:
needs: build
runs-on: ubuntu-latest
outputs:
upload_url: ${{ steps.create-release.outputs.upload_url }}
steps:
- uses: actions/create-release@v1
id: create-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: false
upload-release:
needs: [build, create-release]
strategy:
matrix:
client: ["chrome", "edge", "firefox", "userscript", "thunderbird"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: build-artifacts
path: build
- uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: ./build/${{ matrix.client }}.zip
asset_name: kiss-translator_${{ github.ref_name }}_${{ matrix.client }}.zip
asset_content_type: application/zip

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.crx
*.pem
*.zip

1
.obsidian/app.json vendored Normal file
View File

@@ -0,0 +1 @@
{}

1
.obsidian/appearance.json vendored Normal file
View File

@@ -0,0 +1 @@
{}

31
.obsidian/core-plugins.json vendored Normal file
View File

@@ -0,0 +1,31 @@
{
"file-explorer": true,
"global-search": true,
"switcher": true,
"graph": true,
"backlink": true,
"canvas": true,
"outgoing-link": true,
"tag-pane": true,
"properties": false,
"page-preview": true,
"daily-notes": true,
"templates": true,
"note-composer": true,
"command-palette": true,
"slash-command": false,
"editor-status": true,
"bookmarks": true,
"markdown-importer": false,
"zk-prefixer": false,
"random-note": false,
"outline": true,
"word-count": true,
"slides": false,
"audio-recorder": false,
"workspaces": false,
"file-recovery": true,
"publish": false,
"sync": true,
"webviewer": false
}

171
.obsidian/workspace.json vendored Normal file
View File

@@ -0,0 +1,171 @@
{
"main": {
"id": "cbf891568fc78c43",
"type": "split",
"children": [
{
"id": "d124d5a918f363e6",
"type": "tabs",
"children": [
{
"id": "eccb24c47a38046a",
"type": "leaf",
"state": {
"type": "empty",
"state": {},
"icon": "lucide-file",
"title": "新标签页"
}
}
]
}
],
"direction": "vertical"
},
"left": {
"id": "a206b20e17a47143",
"type": "split",
"children": [
{
"id": "8eaf7247916eb675",
"type": "tabs",
"children": [
{
"id": "acbe859844e3c61f",
"type": "leaf",
"state": {
"type": "file-explorer",
"state": {
"sortOrder": "alphabetical",
"autoReveal": false
},
"icon": "lucide-folder-closed",
"title": "文件列表"
}
},
{
"id": "a26def8dfef6d24f",
"type": "leaf",
"state": {
"type": "search",
"state": {
"query": "",
"matchingCase": false,
"explainSearch": false,
"collapseAll": false,
"extraContext": false,
"sortOrder": "alphabetical"
},
"icon": "lucide-search",
"title": "搜索"
}
},
{
"id": "c9eb9713b8d21b0a",
"type": "leaf",
"state": {
"type": "bookmarks",
"state": {},
"icon": "lucide-bookmark",
"title": "书签"
}
}
]
}
],
"direction": "horizontal",
"width": 300
},
"right": {
"id": "03abd16995f706d4",
"type": "split",
"children": [
{
"id": "4fc6c366e2698f0a",
"type": "tabs",
"children": [
{
"id": "1cdf7d3b425ea21f",
"type": "leaf",
"state": {
"type": "backlink",
"state": {
"collapseAll": false,
"extraContext": false,
"sortOrder": "alphabetical",
"showSearch": false,
"searchQuery": "",
"backlinkCollapsed": false,
"unlinkedCollapsed": true
},
"icon": "links-coming-in",
"title": "反向链接"
}
},
{
"id": "3e09334caaeb6973",
"type": "leaf",
"state": {
"type": "outgoing-link",
"state": {
"linksCollapsed": false,
"unlinkedCollapsed": true
},
"icon": "links-going-out",
"title": "出链"
}
},
{
"id": "672281771c8b5c6c",
"type": "leaf",
"state": {
"type": "tag",
"state": {
"sortOrder": "frequency",
"useHierarchy": true,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-tags",
"title": "标签"
}
},
{
"id": "8464d879aaa3512d",
"type": "leaf",
"state": {
"type": "outline",
"state": {
"followCursor": false,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-list",
"title": "大纲"
}
}
]
}
],
"direction": "horizontal",
"width": 300,
"collapsed": true
},
"left-ribbon": {
"hiddenItems": {
"switcher:打开快速切换": false,
"graph:查看关系图谱": false,
"canvas:新建白板": false,
"daily-notes:打开/创建今天的日记": false,
"templates:插入模板": false,
"command-palette:打开命令面板": false
}
},
"active": "eccb24c47a38046a",
"lastOpenFiles": [
"README.en.md",
"README.md",
"kiss-translator.webm",
"kiss-translator.png"
]
}

4
.prettierignore Normal file
View File

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

24
.prettierrc Normal file
View File

@@ -0,0 +1,24 @@
{
"arrowParens": "always",
"bracketSpacing": true,
"endOfLine": "lf",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"singleAttributePerLine": false,
"bracketSameLine": false,
"jsxBracketSameLine": false,
"jsxSingleQuote": false,
"printWidth": 80,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"useTabs": false,
"embeddedLanguageFormatting": "auto",
"vueIndentScriptAndStyle": false,
"experimentalTernaries": false,
"parser": "babel"
}

674
LICENSE Normal file
View File

@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

211
README.en.md Normal file
View File

@@ -0,0 +1,211 @@
# 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)
## Features
- [x] Keep it simple, smart
- [x] Open source
- [x] Adapt to common browsers
- [x] Chrome/Edge
- [x] Firefox
- [x] Kiwi (Android)
- [x] Orion (iOS)
- [ ] Safari
- [x] Safari (Mac)
- [x] Thunderbird
- [x] Supports multiple translation services
- [x] Google/Microsoft
- [x] Baidu/Tencent/Volcengine
- [x] OpenAI/Gemini/Claude/Ollama/DeepSeek/CloudflareAI
- [x] DeepL/DeepLX/NiuTrans
- [x] Custom translation interface
- [x] Covers common translation scenarios
- [x] Web bilingual translation
- [x] Input box translation
- [x] Seletction translation
- [x] Favorite Words
- [x] Mouseover translation
- [x] YouTube subtitle translation
- [x] Cross-client data synchronization
- [x] KISS-Workercloudflare/docker
- [x] WebDAV
- [x] Custom translation rules
- [x] Rule subscription/rule sharing
- [x] Customized terminology
- [x] Custom translation style
- [x] Custom shortcut keys
- `Alt+Q` Toggle Translation
- `Alt+C` Toggle Styles
- `Alt+K` Open Setting Popup
- `Alt+S` Open Translate Popup / Translate Selected Text
- `Alt+O` Open Options Page
- `Alt+I` Input Box Translation
## 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
- [x] Safari (Mac) Compiled by a third party, not verified, obtained by yourself: https://www.nodeloc.com/t/topic/54245
- [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.
- Translation interface agent: [https://github.com/fishjar/kiss-proxy](https://github.com/fishjar/kiss-proxy)
- If you encounter network problems when accessing a certain translation interface, this proxy service may help you.
- Deploy and manage by yourself.
## Frequently Asked Questions
### How to Turn Off Automatic Translation
You can achieve this through `Rules Setting` with the following methods:
- Personal Rules: RULES-> Global Rule -> Translate Switch -> Disaabled
- Subscription Rules: SUBSCRIBE -> Select the third option `kiss-rules-off.json`
- Override Subscription Rules: OVERWRITE -> Translate Switch -> Disaabled
- Add a Personal Rule for a Specific Website: Translate Switch -> Disaabled
### 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)
### How to Turn Off Selection Translation
Set this in the `Rules Setting`: RULES -> Global Rule -> If translate selected -> Disable
### How to Set it to Show Only the Translation
Set this in the `Rules Setting`: RULES -> Global Rule -> Show Only Translations -> Enable
### How to Set Mouse Hover Translation
Set this in the `Rules Setting`: RULES -> Global Rule -> TTrigger Mode
### Why are some web pages not fully translated?
This extension's webpage translation is based on CSS selectors. Generic rules cannot adapt to all websites, and sometimes you need to manually add site-specific rules. If you don't know how to write rules, you can seek help here:
https://github.com/fishjar/kiss-rules/issues
### What is the priority order of rule settings?
Personal Rules > Override Subscription Rules > Subscription Rules > Global Rules
Among these, Global Rules have the lowest priority but are very important as they serve as the default rules.
### Why are YouTube subtitles translated in broken sentences?
This extension has no special development for video content. Support for YouTube is also treated as regular webpage translation. Auto-generated subtitles are streamed and output progressively, resulting in poorer support.
To disable this extension's subtitle translation, add a rule. Reference: https://github.com/fishjar/kiss-translator/issues/62
### Local Ollama interface cannot be used
If encountering a 403 error, refer to: https://github.com/fishjar/kiss-translator/issues/174
### Custom API doesn't work in Tampermonkey scripts
Tampermonkey scripts require adding domains to the whitelist; otherwise, requests cannot be sent.
### How to Set Up Hook Functions for Custom Interfaces
The custom interface feature is highly flexible and can theoretically integrate with any translation interface.
Example of a Request Hook function:
```js
/**
* Request Hook
* @param {string} text Text to be translated
* @param {string} from Source language
* @param {string} to Target language
* @param {string} url Translation interface URL
* @param {string} key Translation interface API key
* @returns {Array[string, object]} [Interface URL, request object]
*/
(text, from, to, url, key) => [url, {
headers: {
"Content-type": "application/json",
"Authorization": `Bearer ${key}`
},
method: "POST",
body: { text, to },
}]
```
Example of a Response Hook function:
```js
* Response Hook
* @param {string} res JSON data returned by the interface
* @param {string} text Text to be translated
* @param {string} from Source language
* @param {string} to Target language
* @returns {Array[string, boolean]} [Translated text, whether target language is same as source]
* Note: If the second return value is true (target language same as source),
* the translation will not be displayed on the page,
* If the parameters are incomplete, it is recommended to return false directly
*/
(res, text, from, to) => [res.text, to === res.src]
```
For more custom interface examples, refer to: [custom-api.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api.md)
## Future Plans
This is a side project with no strict timeline. Community contributions are welcome. The following are preliminary feature directions:
- [ ] **Batch Text Requests**: Optimize request strategy to reduce translation API calls and improve performance.
- [ ] **Enhanced Rich Text Translation**: Support accurate translation of complex page structures and rich text content.
- [ ] **Advanced Custom/AI Interfaces**: Add support for context memory, multi-turn conversations, and other advanced AI features.
- [ ] **Fallback English Dictionary**: When translation services fail, fall back to a local dictionary lookup.
- [ ] **Improved YouTube Subtitle Support**: Enhance merging and translation experience for streaming subtitles, reducing sentence fragmentation.
- [ ] **Upgraded Rule Collaboration System**: Introduce more flexible rule sharing, version management, and community review processes.
If you're interested in any of these directions, feel free to discuss in [Issues](https://github.com/fishjar/kiss-translator/issues) or submit a PR!
## Development Guidelines
```sh
git clone https://github.com/fishjar/kiss-translator.git
cd kiss-translator
git checkout dev # Submit a PR suggestion to push to the dev branch
pnpm install
pnpm build
```
## Discussion
- Join [Telegram Group](https://t.me/+RRCu_4oNwrM2NmFl)
## Appreciate
![appreciate](https://github.com/fishjar/kiss-translator/assets/1157624/ebaecabe-2934-4172-8085-af236f5ee399)

210
README.md Normal file
View File

@@ -0,0 +1,210 @@
# 简约翻译
[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)
## 特性
- [x] 保持简约
- [x] 开放源代码
- [x] 适配常见浏览器
- [x] Chrome/Edge
- [x] Firefox
- [x] Kiwi (Android)
- [x] Orion (iOS)
- [ ] Safari
- [x] Safari (Mac)
- [x] Thunderbird
- [x] 支持多种翻译服务
- [x] Google/Microsoft
- [x] Baidu/Tencent/Volcengine
- [x] OpenAI/Gemini/Claude/Ollama/DeepSeek/CloudflareAI
- [x] DeepL/DeepLX/NiuTrans
- [x] 自定义翻译接口
- [x] 覆盖常见翻译场景
- [x] 网页双语对照翻译
- [x] 输入框翻译
- [x] 划词翻译
- [x] 收藏词汇
- [x] 鼠标悬停翻译
- [x] YouTube 字幕翻译
- [x] 跨客户端数据同步
- [x] KISS-Workercloudflare/docker
- [x] WebDAV
- [x] 自定义翻译规则
- [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
- [x] Safari (Mac) 第三方编译,未作验证,自行获取: https://www.nodeloc.com/t/topic/54245
- [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)
- 提供社区维护的,最新最全的订阅规则列表。
- 求助规则相关的问题。
- 翻译接口代理: [https://github.com/fishjar/kiss-proxy](https://github.com/fishjar/kiss-proxy)
- 如果访问某个翻译接口遇到网络问题,这个代理服务也许可以帮到你。
- 自己部署,自己管理。
## 常见问题
### 如何关闭自动翻译
通过规则设置,以下方法均可实现:
- 个人规则:全局规则 -> 开启翻译 -> 默认关闭
- 订阅规则:选择第三个 `kiss-rules-off.json`
- 覆写订阅规则:开启翻译 -> 默认关闭
- 添加一条针对某个网站的个人规则:开启翻译 -> 默认关闭
### 如何设置快捷键
在插件管理那里设置,例如:
- chrome [chrome://extensions/shortcuts](chrome://extensions/shortcuts)
- firefox [about:addons](about:addons)
### 如何关闭划词翻译
通过规则设置:个人规则 -> 全局规则 -> 是否启用划词翻译 -> 禁用
### 如何设置仅显示译文
通过规则设置:个人规则 -> 全局规则 -> 仅显示译文 -> 启用
### 如何设置鼠标悬停翻译
通过规则设置:个人规则 -> 全局规则 -> 触发方式
### 为什么有些网页翻译不全
本插件的网页翻译是基于CSS选择器的通用规则不能适配所有网页有时需要自行添加相应网站的单独规则。如果不会写规则可以到这里求助 https://github.com/fishjar/kiss-rules/issues
### 规则设置的优先级是如何的
个人规则 > 覆写订阅规则 > 订阅规则 > 全局规则
其中全局规则优先级最低,但非常重要,相当于默认规则。
### 为什么油管字幕一句话会断开翻译
本插件目前没有针对视频做特殊开发,对油管的支持也是当做网页翻译看待,自动生成字幕是流式生成并输出的,所以支持较差。
如果需要关闭本插件的字幕翻译增加一条规则即可参考https://github.com/fishjar/kiss-translator/issues/62
### 本地的Ollama接口不能使用
如果出现403的情况参考https://github.com/fishjar/kiss-translator/issues/174
### 填写的接口在油猴脚本不能使用
油猴脚本需要增加域名白名单,否则不能发出请求。
### 如何设置自定义接口的hook函数
自定义接口功能非常灵活,理论可以接入任何翻译接口。
Request Hook 函数示例如下:
```js
/**
* Request Hook
* @param {string} text 需要翻译的原文
* @param {string} from 原文语言
* @param {string} to 译文语言
* @param {string} url 翻译接口地址
* @param {string} key 翻译接口密钥
* @returns {Array[string, object]} [接口地址, 请求参数对象]
*/
(text, from, to, url, key) => [url, {
headers: {
"Content-type": "application/json",
"Authorization": `Bearer ${key}`
},
method: "POST",
body: { text, to },
}]
```
Response Hook 函数示例如下:
```js
/**
* Request Hook
* @param {string} res 接口返回的json数据
* @param {string} text 需要翻译的原文
* @param {string} from 原文语言
* @param {string} to 译文语言
* @returns {Array[string, boolean]} [译文, 译文语言与原文语言是否相同]
* 注如果返回值第二个值为true译文语言与原文语言相同则译文不会在页面显示
* 参数不全的情况建议直接返回false
*/
(res, text, from, to) => [res.text, to === res.src]
```
更多的自定义接口示例,请参考: [custom-api.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api.md)
## 未来规划
本项目为业余开发,无严格时间表,欢迎社区共建。以下为初步设想的功能方向:
- [ ] **聚合发送文本**:优化请求策略,减少翻译接口调用次数,提升性能。
- [ ] **增强富文本翻译**:支持更复杂的页面结构和富文本内容的准确翻译。
- [ ] **强化自定义/AI 接口**:支持上下文记忆、多轮对话等高级 AI 功能。
- [ ] **英文词典备灾机制**:当翻译服务失效时,可 fallback 到本地词典查询。
- [ ] **优化 YouTube 字幕支持**:改进流式字幕的合并与翻译体验,减少断句。
- [ ] **规则共建机制升级**:引入更灵活的规则分享、版本管理与社区评审流程。
如果你对某个方向感兴趣,欢迎在 [Issues](https://github.com/fishjar/kiss-translator/issues) 中讨论或提交 PR
## 开发指引
```sh
git clone https://github.com/fishjar/kiss-translator.git
cd kiss-translator
git checkout dev # 提交PR建议推送到dev分支
pnpm install
pnpm build
```
## 交流
- 加入 [Telegram 群](https://t.me/+RRCu_4oNwrM2NmFl)
## 赞赏
![appreciate](https://github.com/fishjar/kiss-translator/assets/1157624/ebaecabe-2934-4172-8085-af236f5ee399)

218
config-overrides.js Normal file
View File

@@ -0,0 +1,218 @@
const paths = require("react-scripts/config/paths");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { WebpackManifestPlugin } = require("webpack-manifest-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const webpack = require("webpack");
console.log("process.env.REACT_APP_CLIENT", process.env.REACT_APP_CLIENT);
// 扩展
const extWebpack = (config, env) => {
const isEnvProduction = env === "production";
const minify = isEnvProduction && {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
};
const names = [
"HtmlWebpackPlugin",
"WebpackManifestPlugin",
"MiniCssExtractPlugin",
];
config.entry = {
popup: paths.appSrc + "/popup.js",
options: paths.appSrc + "/options.js",
background: paths.appSrc + "/background.js",
content: paths.appSrc + "/content.js",
};
config.output.filename = "[name].js";
config.output.assetModuleFilename = "media/[name][ext]";
config.optimization.splitChunks = { cacheGroups: { default: false } };
config.optimization.runtimeChunk = false;
config.plugins = config.plugins.filter(
(plugin) => !names.includes(plugin.constructor.name)
);
config.plugins.push(
new HtmlWebpackPlugin({
inject: true,
chunks: ["options"],
template: paths.appHtml,
filename: "options.html",
minify,
}),
new HtmlWebpackPlugin({
inject: true,
chunks: ["popup"],
template: paths.appHtml,
filename: "popup.html",
minify,
}),
new WebpackManifestPlugin({
fileName: "asset-manifest.json",
}),
new MiniCssExtractPlugin({
filename: "css/[name].css",
})
);
return config;
};
// 油猴
const userscriptWebpack = (config, env) => {
const banner = `// ==UserScript==
// @name ${process.env.REACT_APP_NAME}
// @namespace ${process.env.REACT_APP_HOMEPAGE}
// @version ${process.env.REACT_APP_VERSION}
// @description A simple bilingual translation extension & Greasemonkey script (一个简约的双语对照翻译扩展 & 油猴脚本)
// @author Gabe<yugang2002@gmail.com>
// @homepageURL ${process.env.REACT_APP_HOMEPAGE}
// @license GPL-3.0
// @match *://*/*
// @icon ${process.env.REACT_APP_LOGOURL}
// @downloadURL ${process.env.REACT_APP_USERSCRIPT_DOWNLOADURL}
// @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 api-edge.cognitive.microsofttranslator.com
// @connect edge.microsoft.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 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 localhost
// @connect 127.0.0.1
// @run-at document-end
// ==/UserScript==
`;
const names = ["HtmlWebpackPlugin"];
config.entry = {
main: paths.appIndexJs,
options: paths.appSrc + "/options.js",
"kiss-translator.user": paths.appSrc + "/userscript.js",
};
config.output.filename = "[name].js";
config.output.publicPath = env === "production" ? "./" : "/";
config.optimization.splitChunks = { cacheGroups: { default: false } };
config.optimization.runtimeChunk = false;
config.optimization.minimize = false;
config.plugins = config.plugins.filter(
(plugin) => !names.includes(plugin.constructor.name)
);
config.plugins.push(
new HtmlWebpackPlugin({
inject: true,
chunks: ["main"],
template: paths.appHtml,
filename: "index.html",
}),
new HtmlWebpackPlugin({
inject: true,
chunks: ["options"],
template: paths.appHtml,
filename: "options.html",
}),
new webpack.BannerPlugin({
banner,
raw: true,
entryOnly: true,
include: "kiss-translator.user",
})
);
return config;
};
// 开发
const webWebpack = (config, env) => {
const names = ["HtmlWebpackPlugin"];
config.entry = {
main: paths.appIndexJs,
options: paths.appSrc + "/options.js",
content: paths.appSrc + "/userscript.js",
};
config.output.filename = "[name].js";
config.output.publicPath = "/";
config.plugins = config.plugins.filter(
(plugin) => !names.includes(plugin.constructor.name)
);
config.plugins.push(
new HtmlWebpackPlugin({
inject: true,
chunks: ["main"],
template: paths.appHtml,
filename: "index.html",
}),
new HtmlWebpackPlugin({
inject: true,
chunks: ["options"],
template: paths.appHtml,
filename: "options.html",
}),
new HtmlWebpackPlugin({
inject: true,
chunks: ["content"],
template: paths.appPublic + "/content.html",
filename: "content.html",
})
);
return config;
};
let webpackConfig;
switch (process.env.REACT_APP_CLIENT) {
case "userscript":
webpackConfig = userscriptWebpack;
break;
case "web":
webpackConfig = webWebpack;
break;
default:
webpackConfig = extWebpack;
}
module.exports = {
webpack: webpackConfig,
};

346
custom-api.md Normal file
View File

@@ -0,0 +1,346 @@
# 自定义接口示例
以下示例为网友提供,仅供学习参考。
## 本地运行 Seed-X-PPO-7B 量化模型
> 由网友 emptyghost6 提供来源https://linux.do/t/topic/828257
URL
```sh
http://localhost:8000/v1/completions
```
Request Hook
```js
(text, from, to, url, key) => {
// 模型支持的语言代码到完整名称的映射
const langFullNameMap = {
ar: 'Arabic', fr: 'French', ms: 'Malay', ru: 'Russian',
cs: 'Czech', hr: 'Croatian', nb: 'Norwegian Bokmal', sv: 'Swedish',
da: 'Danish', hu: 'Hungarian', nl: 'Dutch', th: 'Thai',
de: 'German', id: 'Indonesian', no: 'Norwegian', tr: 'Turkish',
en: 'English', it: 'Italian', pl: 'Polish', uk: 'Ukrainian',
es: 'Spanish', ja: 'Japanese', pt: 'Portuguese', vi: 'Vietnamese',
fi: 'Finnish', ko: 'Korean', ro: 'Romanian', zh: 'Chinese'
};
// 将 Hook 系统的语言代码转换为模型 API 支持的代码
const getModelLangCode = (lang) => {
if (lang === 'zh-CN' || lang === 'zh-TW') return 'zh';
return lang;
};
const sourceLangCode = getModelLangCode(from);
const targetLangCode = getModelLangCode(to);
const sourceLangName = langFullNameMap[sourceLangCode] || from;
const targetLangName = langFullNameMap[targetLangCode] || to;
const prompt = `Translate it to ${targetLangName}:\n${text} <${targetLangCode}>`;
// 构建请求体对象
const bodyObject = {
model: "./ByteDance-Seed/Seed-X-PPO-7B-AWQ-Int4",
prompt: prompt,
max_tokens: 2048,
temperature: 0.0,
};
// 返回最终的请求配置
return [url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
// 关键改动:将 JavaScript 对象转换为 JSON 字符串
body: JSON.stringify(bodyObject),
}];
}
```
Response Hook
```js
(res, text, from, to) => {
// 检查返回是否有效
if (res && res.choices && res.choices.length > 0 && res.choices[0].text) {
// 提取译文并去除可能存在的前后空格
const translatedText = res.choices[0].text.trim();
// 比较原文与译文,相同为 true否则为 false。
const areTextsIdentical = text.trim() === translatedText;
// 返回数组:[翻译后的文本, 是否与原文相同]
return [translatedText, areTextsIdentical];
}
// 如果响应格式不正确或没有结果,则抛出错误
throw new Error("Invalid API response format or no translation found.");
}
```
## 接入 openrouter
> 由网友 Rick Sanchez 提供
URL
```sh
https://openrouter.ai/api/v1/chat/completions
```
Request Hook
```js
(text, from, to, url, key) => [url, {
method: "POST",
headers: {
"Authorization": `Bearer ${key}`,
"Content-type": "application/json",
},
body: JSON.stringify({
"model": "deepseek/deepseek-chat-v3-0324:free", //可自定义你的模型
"messages": [
{
"role": "user",
"content": //可自定义你的提示词
`You are a professional ${to} native translator. Your task is to produce a fluent, natural, and culturally appropriate translation of the following text from ${from} to ${to}, fully conveying the meaning, tone, and nuance of the original.
## Translation Rules
1. Output only the final polished translation — no explanations, intermediate drafts, or notes.
2. Translate in a way that reads naturally to a native ${to} audience, adapting idioms, cultural references, and tone when necessary.
3. Preserve proper nouns, technical terms, brand names, and URLs exactly as in the original text unless a widely accepted ${to} equivalent exists.
4. Keep any formatting (Markdown, HTML tags, bullet points, numbering) intact and positioned naturally within the translation.
5. Adapt humor, metaphors, and figurative language to culturally relevant forms in ${to} while keeping the original intent.
6. Maintain the same level of formality or informality as the original.
Source Text: ${text}
Translated Text:`
}
]
})
}]
```
Response Hook
```js
(res, text, from, to) => [
res.choices?.[0]?.message?.content ?? "",
false
]
```
## 接入 gemini-2.5-flash, 关闭思考模式, 去审查
> 由网友 Rick Sanchez 提供
URL
```sh
https://generativelanguage.googleapis.com/v1beta/models
```
Request Hook
```js
(text, from, to, url, key) => [`${url}/gemini-2.5-flash:generateContent?key=${key}`, {
headers: {
"Content-Type": "application/json",
},
method: "POST",
body: JSON.stringify({
"generationConfig": {
"temperature": 0.8,
"thinkingConfig": {
"thinkingBudget": 0, //gemini-2.5-flash设为0关闭思考模式
},
},
"safetySettings": [
{
"category": "HARM_CATEGORY_HARASSMENT",
"threshold": "BLOCK_NONE",
},
{
"category": "HARM_CATEGORY_HATE_SPEECH",
"threshold": "BLOCK_NONE",
},
{
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
"threshold": "BLOCK_NONE",
},
{
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
"threshold": "BLOCK_NONE",
}
],
"contents": [{
"parts": [{
"text": `自定义提示词`
}]
}],
}),
}]
```
Response Hook
```js
(res, text, from, to) => [
res.candidates?.[0]?.content?.parts?.[0]?.text ?? "",
false
]
```
## 接入 Qwen-MT
> 由网友 atom 提供
URL
```sh
https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions
```
Request Hook
```js
(text, from, to, url, key) => {
const mapLanguageCode = (lang) => ({
'zh-CN': 'zh',
'zh-TW': 'zh_tw',
})[lang] || lang;
const targetLang = mapLanguageCode(to);
return [
url,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${key}`
},
body: JSON.stringify({
"model": "qwen-mt-turbo",
"messages": [
{
"role": "user",
"content": text
}
],
"translation_options": {
"source_lang": "auto",
"target_lang": targetLang
}
})
}
];
}
```
Response Hook
```js
(res, text, from, to) => [res.choices?.[0]?.message?.content ?? "", false]
```
## 接入 deepl 接口
> 来源: https://github.com/fishjar/kiss-translator/issues/101#issuecomment-2123786236
Request Hook
```js
(text, from, to, url, key) => [
url,
{
headers: {
"Content-type": "application/json",
},
method: "POST",
body: JSON.stringify({
text,
target_lang: "ZH",
source_lang: "auto",
}),
},
]
```
Response Hook
```js
(res, text, from, to) => [res.data, "ZH" === res.source_lang]
```
## 接入智谱AI大模型
> 来源: https://github.com/fishjar/kiss-translator/issues/205#issuecomment-2642422679
Request Hook
```js
(text, from, to, url, key) => [url, {
"method": "POST",
"headers": {
"Content-type": "application/json",
"Authorization": key
},
"body": JSON.stringify({
"model": "glm-4-flash",
"messages": [
{
"role":"system",
"content": "You are a professional, authentic machine translation engine. You only return the translated text, without any explanations."
},
{
"role": "user",
"content": `Translate the following text into ${to}. If translation is unnecessary (e.g. proper nouns, codes, etc.), return the original text. NO explanations. NO notes:\n\n ${text} `
}
]
})
}]
```
## 接入谷歌新接口
> 由网友 Bush2021 提供来源https://github.com/fishjar/kiss-translator/issues/225#issuecomment-2810950717
URL
```sh
https://translate-pa.googleapis.com/v1/translateHtml
```
KEY
```sh
AIzaSyATBXajvzQLTDHEQbcpq0Ihe0vWDHmO520
```
Request Hook
```js
(text, from, to, url, key) => [url, {
method: "POST",
headers: {
"Content-Type": "application/json+protobuf",
"X-Goog-API-Key": key
},
body: JSON.stringify([[[text], from || "auto", to], "wt_lib"])
}]
```
Response Hook
```js
(res, text, from, to) => [res?.[0]?.join(" ") || "Translation unavailable", to === res?.[1]?.[0]]
```

BIN
kiss-translator.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

BIN
kiss-translator.webm Normal file

Binary file not shown.

74
package.json Normal file
View File

@@ -0,0 +1,74 @@
{
"name": "kiss-translator",
"description": "A minimalist bilingual translation Extension & Greasemonkey Script",
"version": "1.9.2",
"author": "Gabe<yugang2002@gmail.com>",
"private": true,
"dependencies": {
"@emotion/cache": "^11.11.0",
"@emotion/react": "^11.11.1",
"@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.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 && rm build/chrome/content.html",
"build:edge": "rm -rf build/edge && cp -r build/chrome build/edge",
"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 && cp -r build/chrome build/firefox && cat ./build/firefox/manifest.firefox.json > ./build/firefox/manifest.json && rm build/*/manifest.firefox.json",
"build:web": "rm -rf build/web && BUILD_PATH=./build/web REACT_APP_CLIENT=userscript react-app-rewired build",
"build:userscript-ios": "file1=build/web/kiss-translator.user.js file2=build/web/kiss-translator-ios-safari.user.js && cp $file1 $file2 && sed -i 's|// @grant unsafeWindow|// @inject-into content|g' $file2",
"build:userscript": "rm -rf build/userscript && mkdir build/userscript && cp build/web/*.user.js build/userscript/",
"build:rules": "babel-node src/rules.js",
"build": "pnpm 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"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
],
"globals": {
"GM": true,
"unsafeWindow": true,
"globalThis": true,
"messenger": true
}
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@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.20",
"prettier": "3.6.2",
"react-app-rewired": "^2.2.1"
}
}

13020
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

0
public/.nojekyll Normal file
View File

View File

@@ -0,0 +1,20 @@
{
"app_name": {
"message": "KISS Translator"
},
"app_description": {
"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"
}
}

View File

@@ -0,0 +1,20 @@
{
"app_name": {
"message": "简约翻译"
},
"app_description": {
"message": "一个简约的双语对照翻译扩展 & 油猴脚本"
},
"toggle_translate": {
"message": "开启翻译"
},
"toggle_style": {
"message": "切换样式"
},
"open_options": {
"message": "打开设置"
},
"open_tranbox": {
"message": "翻译弹窗/选中文字"
}
}

334
public/content.html Normal file
View File

@@ -0,0 +1,334 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>%REACT_APP_NAME%</title>
<style>
img {
width: 1.2em;
height: 1.2em;
}
svg {
max-width: 1.2em;
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">
<p>You need to enable <code>JavaScript</code> to run <span>this app.</span></p>
<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 />
<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 />
<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>
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 />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<h2>Weve first shared our research on RSC in an introductory talk and an RFC.</h2>
<br />
<br />
<br />
<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 />
<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 />
<div class="cont cont1">
<h2>
Server Components can run during the build, letting you read from the filesystem
or fetch static content.
</h2>
<ul>
<li>
They can also run on the server, letting you access your data layer without
having to build an API. You can pass data by props from Server Components to
the interactive Client Components in the browser.
</li>
<li>以声明式编写 UI可以让你的代码更加可靠且方便调试。</li>
</ul>
</div>
<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>
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
变得轻而易举。为你应用的每一个状态设计简洁的视图,当数据变动时 React
能高效更新并渲染合适的组件。
</li>
<li>以声明式编写 UI可以让你的代码更加可靠且方便调试。</li>
</ul>
</div>
</div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/images/logo128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
public/images/logo16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

BIN
public/images/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

BIN
public/images/logo32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 898 B

BIN
public/images/logo48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

22
public/index.html Normal file
View File

@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>%REACT_APP_NAME%</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@@ -0,0 +1,71 @@
{
"manifest_version": 2,
"name": "__MSG_app_name__",
"description": "__MSG_app_description__",
"version": "1.9.2",
"default_locale": "en",
"author": "Gabe<yugang2002@gmail.com>",
"homepage_url": "https://github.com/fishjar/kiss-translator",
"background": {
"scripts": ["background.js"]
},
"content_scripts": [
{
"js": ["content.js"],
"matches": ["<all_urls>"],
"all_frames": true
}
],
"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",
"contextMenus",
"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
}
}

67
public/manifest.json Normal file
View File

@@ -0,0 +1,67 @@
{
"manifest_version": 3,
"name": "__MSG_app_name__",
"description": "__MSG_app_description__",
"version": "1.9.2",
"default_locale": "en",
"author": "Gabe<yugang2002@gmail.com>",
"homepage_url": "https://github.com/fishjar/kiss-translator",
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"js": ["content.js"],
"matches": ["<all_urls>"],
"all_frames": true
}
],
"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", "contextMenus", "scripting", "declarativeNetRequest"],
"host_permissions": ["<all_urls>"],
"icons": {
"16": "images/logo16.png",
"32": "images/logo32.png",
"48": "images/logo48.png",
"128": "images/logo128.png"
},
"action": {
"default_icon": {
"128": "images/logo128.png"
},
"default_title": "__MSG_app_name__",
"default_popup": "popup.html"
},
"options_ui": {
"page": "options.html",
"open_in_tab": true
}
}

View File

@@ -0,0 +1,78 @@
{
"manifest_version": 2,
"name": "__MSG_app_name__",
"description": "__MSG_app_description__",
"version": "1.9.2",
"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>"],
"all_frames": true
}
],
"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
}
}

BIN
screenshot-01.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
screenshot-02.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

23
src/apis/baidu.js Normal file
View File

@@ -0,0 +1,23 @@
import queryString from "query-string";
import { URL_BAIDU_TRANSAPI, DEFAULT_USER_AGENT } from "../config";
export const genBaidu = async ({ text, from, to }) => {
const data = {
from,
to,
query: text,
source: "txt",
};
const init = {
headers: {
// Origin: "https://fanyi.baidu.com",
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"User-Agent": DEFAULT_USER_AGENT,
},
method: "POST",
body: queryString.stringify(data),
};
return [URL_BAIDU_TRANSAPI, init];
};

58
src/apis/deepl.js Normal file
View File

@@ -0,0 +1,58 @@
import { URL_DEEPLFREE_TRAN } from "../config";
let id = 1e4 * Math.round(1e4 * Math.random());
export const genDeeplFree = ({ text, from, to }) => {
const iCount = (text.match(/[i]/g) || []).length + 1;
let timestamp = Date.now();
timestamp = timestamp + (iCount - (timestamp % iCount));
id++;
let body = JSON.stringify({
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,
},
],
},
});
body = body.replace(
'method":"',
(id + 3) % 13 === 0 || (id + 5) % 29 === 0 ? 'method" : "' : 'method": "'
);
const init = {
headers: {
"Content-Type": "application/json",
Accept: "*/*",
"x-app-os-name": "iOS",
"x-app-os-version": "16.3.0",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"x-app-device": "iPhone13,2",
"User-Agent": "DeepL-iOS/2.9.1 iOS 16.3.0 (iPhone13,2)",
"x-app-build": "510265",
"x-app-version": "2.9.1",
},
method: "POST",
body,
};
return [URL_DEEPLFREE_TRAN, init];
};

366
src/apis/index.js Normal file
View File

@@ -0,0 +1,366 @@
import queryString from "query-string";
import { fetchData } from "../libs/fetch";
import {
OPT_TRANS_GOOGLE,
OPT_TRANS_GOOGLE_2,
OPT_TRANS_MICROSOFT,
OPT_TRANS_DEEPL,
OPT_TRANS_DEEPLFREE,
OPT_TRANS_DEEPLX,
OPT_TRANS_NIUTRANS,
OPT_TRANS_BAIDU,
OPT_TRANS_TENCENT,
OPT_TRANS_VOLCENGINE,
OPT_TRANS_OPENAI,
OPT_TRANS_OPENAI_2,
OPT_TRANS_OPENAI_3,
OPT_TRANS_GEMINI,
OPT_TRANS_GEMINI_2,
OPT_TRANS_CLAUDE,
OPT_TRANS_CLOUDFLAREAI,
OPT_TRANS_OLLAMA,
OPT_TRANS_OLLAMA_2,
OPT_TRANS_OLLAMA_3,
OPT_TRANS_CUSTOMIZE,
OPT_TRANS_CUSTOMIZE_2,
OPT_TRANS_CUSTOMIZE_3,
OPT_TRANS_CUSTOMIZE_4,
OPT_TRANS_CUSTOMIZE_5,
URL_CACHE_TRAN,
KV_SALT_SYNC,
URL_GOOGLE_TRAN,
URL_MICROSOFT_LANGDETECT,
URL_BAIDU_LANGDETECT,
URL_BAIDU_SUGGEST,
URL_BAIDU_TTS,
OPT_LANGS_BAIDU,
URL_TENCENT_TRANSMART,
OPT_LANGS_TENCENT,
OPT_LANGS_SPECIAL,
OPT_LANGS_MICROSOFT,
} from "../config";
import { sha256 } from "../libs/utils";
import interpreter from "../libs/interpreter";
import { msAuth } from "../libs/auth";
import { kissLog } from "../libs/log";
/**
* 同步数据
* @param {*} url
* @param {*} key
* @param {*} data
* @returns
*/
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),
});
/**
* 下载数据
* @param {*} url
* @returns
*/
export const apiFetch = (url) => fetchData(url);
/**
* Google语言识别
* @param {*} text
* @returns
*/
export const apiGoogleLangdetect = async (text) => {
const params = {
client: "gtx",
dt: "t",
dj: 1,
ie: "UTF-8",
sl: "auto",
tl: "zh-CN",
q: text,
};
const input = `${URL_GOOGLE_TRAN}?${queryString.stringify(params)}`;
const res = await fetchData(input, {
headers: {
"Content-type": "application/json",
},
useCache: true,
});
return res.src;
};
/**
* Microsoft语言识别
* @param {*} text
* @returns
*/
export const apiMicrosoftLangdetect = async (text) => {
const [token] = await msAuth();
const res = await fetchData(URL_MICROSOFT_LANGDETECT, {
headers: {
"Content-type": "application/json",
Authorization: `Bearer ${token}`,
},
method: "POST",
body: JSON.stringify([{ Text: text }]),
useCache: true,
});
return OPT_LANGS_MICROSOFT.get(res[0].language) ?? res[0].language;
};
/**
* 百度语言识别
* @param {*} text
* @returns
*/
export const apiBaiduLangdetect = async (text) => {
const res = await fetchData(URL_BAIDU_LANGDETECT, {
headers: {
"Content-type": "application/json",
},
method: "POST",
body: JSON.stringify({
query: text,
}),
useCache: true,
});
if (res.error === 0) {
return OPT_LANGS_BAIDU.get(res.lan) ?? res.lan;
}
return "";
};
/**
* 百度翻译建议
* @param {*} text
* @returns
*/
export const apiBaiduSuggest = async (text) => {
const res = await fetchData(URL_BAIDU_SUGGEST, {
headers: {
"Content-type": "application/json",
},
method: "POST",
body: JSON.stringify({
kw: text,
}),
useCache: true,
});
if (res.errno === 0) {
return res.data;
}
return [];
};
/**
* 百度语音
* @param {*} text
* @param {*} lan
* @param {*} spd
* @returns
*/
export const apiBaiduTTS = (text, lan = "uk", spd = 3) => {
const url = `${URL_BAIDU_TTS}?${queryString.stringify({ lan, text, spd })}`;
return fetchData(url, {
useCache: false, // 为避免缓存过快增长,禁用缓存语音数据
});
};
/**
* 腾讯语言识别
* @param {*} text
* @returns
*/
export const apiTencentLangdetect = async (text) => {
const body = JSON.stringify({
header: {
fn: "text_analysis",
},
text,
});
const res = await fetchData(URL_TENCENT_TRANSMART, {
headers: {
"Content-type": "application/json",
},
method: "POST",
body,
useCache: true,
});
return OPT_LANGS_TENCENT.get(res.language) ?? res.language;
};
/**
* 统一翻译接口
* @param {*} param0
* @returns
*/
export const apiTranslate = async ({
translator,
text,
fromLang,
toLang,
apiSetting = {},
useCache = true,
usePool = true,
}) => {
let trText = "";
let isSame = false;
if (!text) {
return [trText, true];
}
const from =
OPT_LANGS_SPECIAL[translator].get(fromLang) ??
OPT_LANGS_SPECIAL[translator].get("auto");
const to = OPT_LANGS_SPECIAL[translator].get(toLang);
if (!to) {
kissLog(`target lang: ${toLang} not support`, "translate");
return [trText, isSame];
}
// 版本号一/二位升级,旧缓存失效
const [v1, v2] = process.env.REACT_APP_VERSION.split(".");
const cacheOpts = {
translator,
text,
fromLang,
toLang,
version: [v1, v2].join("."),
};
const transOpts = {
translator,
text,
from,
to,
};
const res = await fetchData(
`${URL_CACHE_TRAN}?${queryString.stringify(cacheOpts)}`,
{
useCache,
usePool,
transOpts,
apiSetting,
}
);
switch (translator) {
case OPT_TRANS_GOOGLE:
trText = res.sentences.map((item) => item.trans).join(" ");
isSame = to === res.src;
break;
case OPT_TRANS_GOOGLE_2:
trText = res?.[0]?.[0] || "";
isSame = to === res.src;
break;
case OPT_TRANS_MICROSOFT:
trText = res
.map((item) => item.translations.map((item) => item.text).join(" "))
.join(" ");
isSame = text === trText;
break;
case OPT_TRANS_DEEPL:
trText = res.translations.map((item) => item.text).join(" ");
isSame = to === res.translations[0].detected_source_language;
break;
case OPT_TRANS_DEEPLFREE:
trText = res.result?.texts.map((item) => item.text).join(" ");
isSame = to === res.result?.lang;
break;
case OPT_TRANS_DEEPLX:
trText = res.data;
isSame = to === res.source_lang;
break;
case OPT_TRANS_NIUTRANS:
const json = JSON.parse(res);
if (json.error_msg) {
throw new Error(json.error_msg);
}
trText = json.tgt_text;
isSame = to === json.from;
break;
case OPT_TRANS_BAIDU:
// trText = res.trans_result?.data.map((item) => item.dst).join(" ");
// isSame = res.trans_result?.to === res.trans_result?.from;
if (res.type === 1) {
trText = Object.keys(JSON.parse(res.result).content[0].mean[0].cont)[0];
isSame = to === res.from;
} else if (res.type === 2) {
trText = res.data.map((item) => item.dst).join(" ");
isSame = to === res.from;
}
break;
case OPT_TRANS_TENCENT:
trText = res?.auto_translation?.[0];
isSame = text === trText;
break;
case OPT_TRANS_VOLCENGINE:
trText = res?.translation || "";
isSame = to === res?.detected_language;
break;
case OPT_TRANS_OPENAI:
case OPT_TRANS_OPENAI_2:
case OPT_TRANS_OPENAI_3:
case OPT_TRANS_GEMINI_2:
trText = res?.choices?.map((item) => item.message.content).join(" ");
isSame = text === trText;
break;
case OPT_TRANS_GEMINI:
trText = res?.candidates
?.map((item) => item.content?.parts.map((item) => item.text).join(" "))
.join(" ");
isSame = text === trText;
break;
case OPT_TRANS_CLAUDE:
trText = res?.content?.map((item) => item.text).join(" ");
isSame = text === trText;
break;
case OPT_TRANS_CLOUDFLAREAI:
trText = res?.result?.translated_text;
isSame = text === trText;
break;
case OPT_TRANS_OLLAMA:
case OPT_TRANS_OLLAMA_2:
case OPT_TRANS_OLLAMA_3:
const { thinkIgnore = "" } = apiSetting;
const deepModels = thinkIgnore.split(",").filter((model) => model.trim());
if (deepModels.some((model) => res?.model?.startsWith(model))) {
trText = res?.response.replace(/<think>[\s\S]*<\/think>/i, "");
} else {
trText = res?.response;
}
isSame = text === trText;
break;
case OPT_TRANS_CUSTOMIZE:
case OPT_TRANS_CUSTOMIZE_2:
case OPT_TRANS_CUSTOMIZE_3:
case OPT_TRANS_CUSTOMIZE_4:
case OPT_TRANS_CUSTOMIZE_5:
const { resHook } = apiSetting;
if (resHook?.trim()) {
interpreter.run(`exports.resHook = ${resHook}`);
[trText, isSame] = interpreter.exports.resHook(res, text, from, to);
} else {
trText = res.text;
isSame = to === res.from;
}
break;
default:
}
return [trText, isSame, res];
};

612
src/apis/trans.js Normal file
View File

@@ -0,0 +1,612 @@
import queryString from "query-string";
import {
OPT_TRANS_GOOGLE,
OPT_TRANS_GOOGLE_2,
OPT_TRANS_MICROSOFT,
OPT_TRANS_DEEPL,
OPT_TRANS_DEEPLFREE,
OPT_TRANS_DEEPLX,
OPT_TRANS_NIUTRANS,
OPT_TRANS_BAIDU,
OPT_TRANS_TENCENT,
OPT_TRANS_VOLCENGINE,
OPT_TRANS_OPENAI,
OPT_TRANS_OPENAI_2,
OPT_TRANS_OPENAI_3,
OPT_TRANS_GEMINI,
OPT_TRANS_GEMINI_2,
OPT_TRANS_CLAUDE,
OPT_TRANS_CLOUDFLAREAI,
OPT_TRANS_OLLAMA,
OPT_TRANS_OLLAMA_2,
OPT_TRANS_OLLAMA_3,
OPT_TRANS_CUSTOMIZE,
OPT_TRANS_CUSTOMIZE_2,
OPT_TRANS_CUSTOMIZE_3,
OPT_TRANS_CUSTOMIZE_4,
OPT_TRANS_CUSTOMIZE_5,
URL_MICROSOFT_TRAN,
URL_TENCENT_TRANSMART,
URL_VOLCENGINE_TRAN,
INPUT_PLACE_URL,
INPUT_PLACE_FROM,
INPUT_PLACE_TO,
INPUT_PLACE_TEXT,
INPUT_PLACE_KEY,
INPUT_PLACE_MODEL,
} from "../config";
import { msAuth } from "../libs/auth";
import { genDeeplFree } from "./deepl";
import { genBaidu } from "./baidu";
import interpreter from "../libs/interpreter";
const keyMap = new Map();
const urlMap = new Map();
// 轮询key/url
const keyPick = (translator, key = "", cacheMap) => {
const keys = key
.split(/\n|,/)
.map((item) => item.trim())
.filter(Boolean);
if (keys.length === 0) {
return "";
}
const preIndex = cacheMap.get(translator) ?? -1;
const curIndex = (preIndex + 1) % keys.length;
cacheMap.set(translator, curIndex);
return keys[curIndex];
};
const genGoogle = ({ text, from, to, url, key }) => {
const params = {
client: "gtx",
dt: "t",
dj: 1,
ie: "UTF-8",
sl: from,
tl: to,
q: text,
};
const input = `${url}?${queryString.stringify(params)}`;
const init = {
headers: {
"Content-type": "application/json",
},
};
if (key) {
init.headers.Authorization = `Bearer ${key}`;
}
return [input, init];
};
const genGoogle2 = ({ text, from, to, url, key }) => {
const body = JSON.stringify([[[text], from, to], "wt_lib"]);
const init = {
method: "POST",
headers: {
"Content-Type": "application/json+protobuf",
"X-Goog-API-Key": key,
},
body,
};
return [url, init];
};
const genMicrosoft = async ({ text, from, to }) => {
const [token] = await msAuth();
const params = {
from,
to,
"api-version": "3.0",
};
const input = `${URL_MICROSOFT_TRAN}?${queryString.stringify(params)}`;
const init = {
headers: {
"Content-type": "application/json",
Authorization: `Bearer ${token}`,
},
method: "POST",
body: JSON.stringify([{ Text: text }]),
};
return [input, init];
};
const genDeepl = ({ text, from, to, url, key }) => {
const data = {
text: [text],
target_lang: to,
source_lang: from,
// split_sentences: "0",
};
const init = {
headers: {
"Content-type": "application/json",
Authorization: `DeepL-Auth-Key ${key}`,
},
method: "POST",
body: JSON.stringify(data),
};
return [url, init];
};
const genDeeplX = ({ text, from, to, url, key }) => {
const data = {
text,
target_lang: to,
source_lang: from,
};
const init = {
headers: {
"Content-type": "application/json",
},
method: "POST",
body: JSON.stringify(data),
};
if (key) {
init.headers.Authorization = `Bearer ${key}`;
}
return [url, init];
};
const genNiuTrans = ({ text, from, to, url, key, dictNo, memoryNo }) => {
const data = {
from,
to,
apikey: key,
src_text: text,
dictNo,
memoryNo,
};
const init = {
headers: {
"Content-type": "application/json",
},
method: "POST",
body: JSON.stringify(data),
};
return [url, init];
};
const genTencent = ({ text, from, to }) => {
const data = {
header: {
fn: "auto_translation",
client_key:
"browser-chrome-110.0.0-Mac OS-df4bd4c5-a65d-44b2-a40f-42f34f3535f2-1677486696487",
},
type: "plain",
model_category: "normal",
source: {
text_list: [text],
lang: from,
},
target: {
lang: to,
},
};
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: JSON.stringify(data),
};
return [URL_TENCENT_TRANSMART, init];
};
const genVolcengine = ({ text, from, to }) => {
const data = {
source_language: from,
target_language: to,
text: text,
};
const init = {
headers: {
"Content-type": "application/json",
},
method: "POST",
body: JSON.stringify(data),
};
return [URL_VOLCENGINE_TRAN, init];
};
const genOpenAI = ({
text,
from,
to,
url,
key,
systemPrompt,
userPrompt,
model,
temperature,
maxTokens,
}) => {
// 兼容历史上作为systemPrompt的prompt如果prompt中不包含带翻译文本则添加文本到prompt末尾
// if (!prompt.includes(INPUT_PLACE_TEXT)) {
// prompt += `\nSource Text: ${INPUT_PLACE_TEXT}`;
// }
systemPrompt = systemPrompt
.replaceAll(INPUT_PLACE_FROM, from)
.replaceAll(INPUT_PLACE_TO, to)
.replaceAll(INPUT_PLACE_TEXT, text);
userPrompt = userPrompt
.replaceAll(INPUT_PLACE_FROM, from)
.replaceAll(INPUT_PLACE_TO, to)
.replaceAll(INPUT_PLACE_TEXT, text);
const data = {
model,
messages: [
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: userPrompt,
},
],
temperature,
max_completion_tokens: maxTokens,
};
const init = {
headers: {
"Content-type": "application/json",
Authorization: `Bearer ${key}`, // OpenAI
"api-key": key, // Azure OpenAI
},
method: "POST",
body: JSON.stringify(data),
};
return [url, init];
};
const genGemini = ({
text,
from,
to,
url,
key,
systemPrompt,
userPrompt,
model,
temperature,
maxTokens,
}) => {
url = url
.replaceAll(INPUT_PLACE_MODEL, model)
.replaceAll(INPUT_PLACE_KEY, key);
systemPrompt = systemPrompt
.replaceAll(INPUT_PLACE_FROM, from)
.replaceAll(INPUT_PLACE_TO, to)
.replaceAll(INPUT_PLACE_TEXT, text);
userPrompt = userPrompt
.replaceAll(INPUT_PLACE_FROM, from)
.replaceAll(INPUT_PLACE_TO, to)
.replaceAll(INPUT_PLACE_TEXT, text);
const data = {
system_instruction: {
parts: {
text: systemPrompt,
},
},
contents: {
role: "user",
parts: {
text: userPrompt,
},
},
generationConfig: {
maxOutputTokens: maxTokens,
temperature,
// topP: 0.8,
// topK: 10,
},
};
const init = {
headers: {
"Content-type": "application/json",
},
method: "POST",
body: JSON.stringify(data),
};
return [url, init];
};
const genGemini2 = ({
text,
from,
to,
url,
key,
systemPrompt,
userPrompt,
model,
temperature,
maxTokens,
}) => {
systemPrompt = systemPrompt
.replaceAll(INPUT_PLACE_FROM, from)
.replaceAll(INPUT_PLACE_TO, to)
.replaceAll(INPUT_PLACE_TEXT, text);
userPrompt = userPrompt
.replaceAll(INPUT_PLACE_FROM, from)
.replaceAll(INPUT_PLACE_TO, to)
.replaceAll(INPUT_PLACE_TEXT, text);
const data = {
model,
messages: [
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: userPrompt,
},
],
temperature,
max_tokens: maxTokens,
};
const init = {
headers: {
"Content-type": "application/json",
Authorization: `Bearer ${key}`,
},
method: "POST",
body: JSON.stringify(data),
};
return [url, init];
};
const genClaude = ({
text,
from,
to,
url,
key,
systemPrompt,
userPrompt,
model,
temperature,
maxTokens,
}) => {
systemPrompt = systemPrompt
.replaceAll(INPUT_PLACE_FROM, from)
.replaceAll(INPUT_PLACE_TO, to)
.replaceAll(INPUT_PLACE_TEXT, text);
userPrompt = userPrompt
.replaceAll(INPUT_PLACE_FROM, from)
.replaceAll(INPUT_PLACE_TO, to)
.replaceAll(INPUT_PLACE_TEXT, text);
const data = {
model,
system: systemPrompt,
messages: [
{
role: "user",
content: userPrompt,
},
],
temperature,
max_tokens: maxTokens,
};
const init = {
headers: {
"Content-type": "application/json",
"anthropic-version": "2023-06-01",
"x-api-key": key,
},
method: "POST",
body: JSON.stringify(data),
};
return [url, init];
};
const genOllama = ({
text,
from,
to,
think,
url,
key,
systemPrompt,
userPrompt,
model,
}) => {
systemPrompt = systemPrompt
.replaceAll(INPUT_PLACE_FROM, from)
.replaceAll(INPUT_PLACE_TO, to)
.replaceAll(INPUT_PLACE_TEXT, text);
userPrompt = userPrompt
.replaceAll(INPUT_PLACE_FROM, from)
.replaceAll(INPUT_PLACE_TO, to)
.replaceAll(INPUT_PLACE_TEXT, text);
const data = {
model,
system: systemPrompt,
prompt: userPrompt,
think: think,
stream: false,
};
const init = {
headers: {
"Content-type": "application/json",
},
method: "POST",
body: JSON.stringify(data),
};
if (key) {
init.headers.Authorization = `Bearer ${key}`;
}
return [url, init];
};
const genCloudflareAI = ({ text, from, to, url, key }) => {
const data = {
text,
source_lang: from,
target_lang: to,
};
const init = {
headers: {
"Content-type": "application/json",
Authorization: `Bearer ${key}`,
},
method: "POST",
body: JSON.stringify(data),
};
return [url, init];
};
const genCustom = ({ text, from, to, url, key, reqHook }) => {
url = url
.replaceAll(INPUT_PLACE_URL, url)
.replaceAll(INPUT_PLACE_FROM, from)
.replaceAll(INPUT_PLACE_TO, to)
.replaceAll(INPUT_PLACE_TEXT, text)
.replaceAll(INPUT_PLACE_KEY, key);
let init = {};
if (reqHook?.trim()) {
interpreter.run(`exports.reqHook = ${reqHook}`);
[url, init] = interpreter.exports.reqHook(text, from, to, url, key);
return [url, init];
}
const data = {
text,
from,
to,
};
init = {
headers: {
"Content-type": "application/json",
},
method: "POST",
body: JSON.stringify(data),
};
if (key) {
init.headers.Authorization = `Bearer ${key}`;
}
return [url, init];
};
/**
* 构造翻译接口请求参数
* @param {*}
* @returns
*/
export const genTransReq = ({ translator, text, from, to }, apiSetting) => {
const args = { text, from, to, ...apiSetting };
switch (translator) {
case OPT_TRANS_DEEPL:
case OPT_TRANS_OPENAI:
case OPT_TRANS_OPENAI_2:
case OPT_TRANS_OPENAI_3:
case OPT_TRANS_GEMINI:
case OPT_TRANS_GEMINI_2:
case OPT_TRANS_CLAUDE:
case OPT_TRANS_CLOUDFLAREAI:
case OPT_TRANS_OLLAMA:
case OPT_TRANS_OLLAMA_2:
case OPT_TRANS_OLLAMA_3:
case OPT_TRANS_NIUTRANS:
case OPT_TRANS_CUSTOMIZE:
case OPT_TRANS_CUSTOMIZE_2:
case OPT_TRANS_CUSTOMIZE_3:
case OPT_TRANS_CUSTOMIZE_4:
case OPT_TRANS_CUSTOMIZE_5:
args.key = keyPick(translator, args.key, keyMap);
break;
case OPT_TRANS_DEEPLX:
args.url = keyPick(translator, args.url, urlMap);
break;
default:
}
switch (translator) {
case OPT_TRANS_GOOGLE:
return genGoogle(args);
case OPT_TRANS_GOOGLE_2:
return genGoogle2(args);
case OPT_TRANS_MICROSOFT:
return genMicrosoft(args);
case OPT_TRANS_DEEPL:
return genDeepl(args);
case OPT_TRANS_DEEPLFREE:
return genDeeplFree(args);
case OPT_TRANS_DEEPLX:
return genDeeplX(args);
case OPT_TRANS_NIUTRANS:
return genNiuTrans(args);
case OPT_TRANS_BAIDU:
return genBaidu(args);
case OPT_TRANS_TENCENT:
return genTencent(args);
case OPT_TRANS_VOLCENGINE:
return genVolcengine(args);
case OPT_TRANS_OPENAI:
case OPT_TRANS_OPENAI_2:
case OPT_TRANS_OPENAI_3:
return genOpenAI(args);
case OPT_TRANS_GEMINI:
return genGemini(args);
case OPT_TRANS_GEMINI_2:
return genGemini2(args);
case OPT_TRANS_CLAUDE:
return genClaude(args);
case OPT_TRANS_CLOUDFLAREAI:
return genCloudflareAI(args);
case OPT_TRANS_OLLAMA:
case OPT_TRANS_OLLAMA_2:
case OPT_TRANS_OLLAMA_3:
return genOllama(args);
case OPT_TRANS_CUSTOMIZE:
case OPT_TRANS_CUSTOMIZE_2:
case OPT_TRANS_CUSTOMIZE_3:
case OPT_TRANS_CUSTOMIZE_4:
case OPT_TRANS_CUSTOMIZE_5:
return genCustom(args);
default:
throw new Error(`[trans] translator: ${translator} not support`);
}
};

265
src/background.js Normal file
View File

@@ -0,0 +1,265 @@
import browser from "webextension-polyfill";
import {
MSG_FETCH,
MSG_GET_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,
DEFAULT_CSPLIST,
CMD_TOGGLE_TRANSLATE,
CMD_TOGGLE_STYLE,
CMD_OPEN_OPTIONS,
CMD_OPEN_TRANBOX,
CLIENT_THUNDERBIRD,
} from "./config";
import { getSettingWithDefault, tryInitDefaultData } from "./libs/storage";
import { trySyncSettingAndRules } from "./libs/sync";
import { fetchHandle, getHttpCache } from "./libs/fetch";
import { sendTabMsg } from "./libs/msg";
import { trySyncAllSubRules } from "./libs/subRules";
import { tryClearCaches } from "./libs";
import { saveRule } from "./libs/rules";
import { getCurTabId } from "./libs/msg";
import { injectInlineJs, injectInternalCss } from "./libs/injector";
import { kissLog } from "./libs/log";
globalThis.ContextType = "BACKGROUND";
const 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(err, "remove contextMenus");
}
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 = DEFAULT_CSPLIST.join(",\n")) {
try {
const newRules = csplist
.split(/\n|,/)
.map((url) => url.trim())
.filter(Boolean)
.map((url, idx) => ({
id: idx + 1,
action: {
type: "modifyHeaders",
responseHeaders: REMOVE_HEADERS.map((header) => ({
operation: "remove",
header,
})),
},
condition: {
urlFilter: url,
resourceTypes: ["main_frame", "sub_frame"],
},
}));
const oldRules = await browser.declarativeNetRequest.getDynamicRules();
const oldRuleIds = oldRules.map((rule) => rule.id);
await browser.declarativeNetRequest.updateDynamicRules({
removeRuleIds: oldRuleIds,
addRules: newRules,
});
} catch (err) {
kissLog(err, "update csp rules");
}
}
/**
* 注册邮件显示脚本
*/
async function registerMsgDisplayScript() {
await messenger.messageDisplayScripts.register({
js: [{ file: "/content.js" }],
});
}
/**
* 插件安装
*/
browser.runtime.onInstalled.addListener(() => {
tryInitDefaultData();
//在thunderbird中注册脚本
if (process.env.REACT_APP_CLIENT === CLIENT_THUNDERBIRD) {
registerMsgDisplayScript();
}
// 右键菜单
addContextMenus();
// 禁用CSP
updateCspRules();
});
/**
* 浏览器启动
*/
browser.runtime.onStartup.addListener(async () => {
// 同步数据
await trySyncSettingAndRules();
const { clearCache, contextMenuType, subrulesList, csplist } =
await getSettingWithDefault();
// 清除缓存
if (clearCache) {
tryClearCaches();
}
//在thunderbird中注册脚本
if (process.env.REACT_APP_CLIENT === CLIENT_THUNDERBIRD) {
registerMsgDisplayScript();
}
// 右键菜单
// firefox重启后菜单会消失,故重复添加
addContextMenus(contextMenuType);
// 禁用CSP
updateCspRules(csplist);
// 同步订阅规则
trySyncAllSubRules({ subrulesList });
});
/**
* 监听消息
*/
browser.runtime.onMessage.addListener(async ({ action, args }) => {
switch (action) {
case MSG_FETCH:
return await fetchHandle(args);
case MSG_GET_HTTPCACHE:
const { input, init } = args;
return await getHttpCache(input, init);
case MSG_OPEN_OPTIONS:
return await browser.runtime.openOptionsPage();
case MSG_SAVE_RULE:
return await saveRule(args);
case MSG_INJECT_JS:
return await browser.scripting.executeScript({
target: { tabId: await getCurTabId(), allFrames: true },
func: injectInlineJs,
args: [args],
world: "MAIN",
});
case MSG_INJECT_CSS:
return await browser.scripting.executeScript({
target: { tabId: await getCurTabId(), allFrames: true },
func: injectInternalCss,
args: [args],
world: "MAIN",
});
case MSG_UPDATE_CSP:
return await updateCspRules(args);
case MSG_CONTEXT_MENUS:
return await addContextMenus(args);
case MSG_COMMAND_SHORTCUTS:
return await browser.commands.getAll();
default:
throw new Error(`message action is unavailable: ${action}`);
}
});
/**
* 监听快捷键
*/
browser.commands.onCommand.addListener((command) => {
// console.log(`Command: ${command}`);
switch (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:
}
});

271
src/common.js Normal file
View File

@@ -0,0 +1,271 @@
import React from "react";
import ReactDOM from "react-dom/client";
import Action from "./views/Action";
import createCache from "@emotion/cache";
import { CacheProvider } from "@emotion/react";
import {
MSG_TRANS_TOGGLE,
MSG_TRANS_TOGGLE_STYLE,
MSG_TRANS_GETRULE,
MSG_TRANS_PUTRULE,
MSG_OPEN_TRANBOX,
APP_LCNAME,
DEFAULT_TRANBOX_SETTING,
} from "./config";
import { getFabWithDefault, getSettingWithDefault } from "./libs/storage";
import { Translator } from "./libs/translator";
import { isIframe, sendIframeMsg } from "./libs/iframe";
import Slection from "./views/Selection";
import { touchTapListener } from "./libs/touch";
import { debounce, genEventName } from "./libs/utils";
import { handlePing, injectScript } from "./libs/gm";
import { browser } from "./libs/browser";
import { matchRule } from "./libs/rules";
import { trySyncAllSubRules } from "./libs/subRules";
import { isInBlacklist } from "./libs/blacklist";
import inputTranslate from "./libs/inputTranslate";
/**
* 油猴脚本设置页面
*/
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
const script = document.createElement("script");
script.textContent = `(${injectScript})("${ping}")`;
document.head.append(script);
}
}
/**
* 插件监听后端事件
* @param {*} translator
*/
function runtimeListener(translator) {
browser?.runtime.onMessage.addListener(async ({ action, args }) => {
switch (action) {
case MSG_TRANS_TOGGLE:
translator.toggle();
sendIframeMsg(MSG_TRANS_TOGGLE);
break;
case MSG_TRANS_TOGGLE_STYLE:
translator.toggleStyle();
sendIframeMsg(MSG_TRANS_TOGGLE_STYLE);
break;
case MSG_TRANS_GETRULE:
break;
case MSG_TRANS_PUTRULE:
translator.updateRule(args);
sendIframeMsg(MSG_TRANS_PUTRULE, args);
break;
case MSG_OPEN_TRANBOX:
window.dispatchEvent(new CustomEvent(MSG_OPEN_TRANBOX));
break;
default:
return { error: `message action is unavailable: ${action}` };
}
return { rule: translator.rule, setting: translator.setting };
});
}
/**
* iframe 页面执行
* @param {*} translator
*/
function runIframe(translator) {
window.addEventListener("message", (e) => {
const { action, args } = e.data || {};
switch (action) {
case MSG_TRANS_TOGGLE:
translator?.toggle();
break;
case MSG_TRANS_TOGGLE_STYLE:
translator?.toggleStyle();
break;
case MSG_TRANS_PUTRULE:
translator.updateRule(args || {});
break;
default:
}
});
}
/**
* 悬浮按钮
* @param {*} translator
* @returns
*/
async function showFab(translator) {
const fab = await getFabWithDefault();
const $action = document.createElement("div");
$action.setAttribute("id", APP_LCNAME);
$action.style.fontSize = "0";
$action.style.width = "0";
$action.style.height = "0";
document.body.parentElement.appendChild($action);
const shadowContainer = $action.attachShadow({ mode: "closed" });
const emotionRoot = document.createElement("style");
const shadowRootElement = document.createElement("div");
shadowContainer.appendChild(emotionRoot);
shadowContainer.appendChild(shadowRootElement);
const cache = createCache({
key: APP_LCNAME,
prepend: true,
container: emotionRoot,
});
ReactDOM.createRoot(shadowRootElement).render(
<React.StrictMode>
<CacheProvider value={cache}>
<Action translator={translator} fab={fab} />
</CacheProvider>
</React.StrictMode>
);
}
/**
* 划词翻译
* @param {*} param0
* @returns
*/
function showTransbox(
{
contextMenuType,
tranboxSetting = DEFAULT_TRANBOX_SETTING,
transApis,
darkMode,
uiLang,
langDetector,
},
{ transSelected }
) {
if (transSelected === "false") {
return;
}
const $tranbox = document.createElement("div");
$tranbox.setAttribute("id", "kiss-transbox");
$tranbox.style.fontSize = "0";
$tranbox.style.width = "0";
$tranbox.style.height = "0";
document.body.parentElement.appendChild($tranbox);
const shadowContainer = $tranbox.attachShadow({ mode: "closed" });
const emotionRoot = document.createElement("style");
const shadowRootElement = document.createElement("div");
shadowRootElement.classList.add(`KT-transbox`);
shadowRootElement.classList.add(`KT-transbox_${darkMode ? "dark" : "light"}`);
shadowContainer.appendChild(emotionRoot);
shadowContainer.appendChild(shadowRootElement);
const cache = createCache({
key: "kiss-transbox",
prepend: true,
container: emotionRoot,
});
ReactDOM.createRoot(shadowRootElement).render(
<React.StrictMode>
<CacheProvider value={cache}>
<Slection
contextMenuType={contextMenuType}
tranboxSetting={tranboxSetting}
transApis={transApis}
uiLang={uiLang}
langDetector={langDetector}
/>
</CacheProvider>
</React.StrictMode>
);
}
/**
* 显示错误信息到页面顶部
* @param {*} message
*/
function showErr(message) {
const $err = document.createElement("div");
$err.innerText = `KISS-Translator: ${message}`;
$err.style.cssText = "background:red; color:#fff;";
document.body.prepend($err);
}
/**
* 监听触屏操作
* @param {*} translator
* @returns
*/
function touchOperation(translator) {
const { touchTranslate = 2 } = translator.setting;
if (touchTranslate === 0) {
return;
}
const handleTap = debounce(() => {
translator.toggle();
sendIframeMsg(MSG_TRANS_TOGGLE);
});
touchTapListener(handleTap, touchTranslate);
}
/**
* 入口函数
*/
export async function run(isUserscript = false) {
try {
const href = document.location.href;
// 设置页面
if (
isUserscript &&
(href.includes(process.env.REACT_APP_OPTIONSPAGE_DEV) ||
href.includes(process.env.REACT_APP_OPTIONSPAGE))
) {
runSettingPage();
return;
}
// 读取设置信息
const setting = await getSettingWithDefault();
// 黑名单
if (isInBlacklist(href, setting)) {
return;
}
// 翻译网页
const rule = await matchRule(href, setting);
const translator = new Translator(rule, setting);
// 适配iframe
if (isIframe) {
runIframe(translator);
return;
}
// 监听消息
!isUserscript && runtimeListener(translator);
// 输入框翻译
inputTranslate(setting);
// 划词翻译
showTransbox(setting, rule);
// 浮球按钮
await showFab(translator);
// 触屏操作
touchOperation(translator);
// 同步订阅规则
isUserscript && (await trySyncAllSubRules(setting));
} catch (err) {
console.error("[KISS-Translator]", err);
showErr(err.message);
}
}

4
src/config/app.js Normal file
View File

@@ -0,0 +1,4 @@
export const APP_NAME = process.env.REACT_APP_NAME.trim()
.split(/\s+/)
.join("-");
export const APP_LCNAME = APP_NAME.toLowerCase();

995
src/config/i18n.js Normal file
View File

@@ -0,0 +1,995 @@
export const UI_LANGS = [
["en", "English"],
["zh", "中文"],
];
const customApiLangs = `["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"],
`;
const customApiHelpZH = `// 请求数据默认格式
{
"url": "{{url}}",
"method": "POST",
"headers": {
"Content-type": "application/json",
"Authorization": "Bearer {{key}}"
},
"body": {
"text": "{{text}}", // 待翻译文字
"from": "{{from}}", // 文字的语言(可能为空)
"to": "{{to}}", // 目标语言
},
}
// 返回数据默认格式
{
text: "", // 翻译后的文字
from: "", // 识别的源语言
to: "", // 目标语言(可选)
}
// Hook 范例
// 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
(text, from, to, url, key) => [url, {
headers: {
"Content-type": "application/json",
},
method: "GET",
body: null,
}]
// Response Hook
// 其中返回数组第一个值表示译文字符串,第二个值为布尔值,表示原文语言与目标语言是否相同
(res, text, from, to) => [res.sentences.map((item) => item.trans).join(" "), to === res.src]
// 支持的语言代码如下
${customApiLangs}
`;
const customApiHelpEN = `// Default request
{
"url": "{{url}}",
"method": "POST",
"headers": {
"Content-type": "application/json",
"Authorization": "Bearer {{key}}"
},
"body": {
"text": "{{text}}", // Text to be translated
"from": "{{from}}", // The language of the text (may be empty)
"to": "{{to}}", // Target language
},
}
// Default response
{
text: "", // translated text
from: "", // Recognized source language
to: "", // Target language (optional)
}
/// Hook Example
// 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
(text, from, to, url, key) => [url, {
headers: {
"Content-type": "application/json",
},
method: "GET",
body: null,
}]
// Response Hook
// In the returned array, the first value is the translated string, while the second value is a boolean
// that indicates whether the source language is the same as the target language.
(res, text, from, to) => [res.sentences.map((item) => item.trans).join(" "), to === res.src]
// The supported language codes are as follows
${customApiLangs}
`;
export const I18N = {
app_name: {
zh: `简约翻译`,
en: `KISS Translator`,
},
translate: {
zh: `翻译`,
en: `Translate`,
},
custom_api_help: {
zh: customApiHelpZH,
en: customApiHelpEN,
},
translate_alt: {
zh: `翻译`,
en: `Translate`,
},
basic_setting: {
zh: `基本设置`,
en: `Basic Setting`,
},
rules_setting: {
zh: `规则设置`,
en: `Rules Setting`,
},
apis_setting: {
zh: `接口设置`,
en: `Apis Setting`,
},
sync_setting: {
zh: `同步设置`,
en: `Sync Setting`,
},
patch_setting: {
zh: `补丁设置`,
en: `Patch Setting`,
},
patch_setting_help: {
zh: `针对一些特殊网站的修正脚本,以便翻译软件得到更好的展示效果。`,
en: `Corrected scripts for some special websites so that the translation software can get better display results.`,
},
inject_webfix: {
zh: `注入修复补丁`,
en: `Inject Webfix`,
},
about: {
zh: `关于`,
en: `About`,
},
about_md: {
zh: `README.md`,
en: `README.en.md`,
},
about_md_local: {
zh: `请 [点击这里](${process.env.REACT_APP_HOMEPAGE}) 查看详情。`,
en: `Please [click here](${process.env.REACT_APP_HOMEPAGE}) for details.`,
},
ui_lang: {
zh: `界面语言`,
en: `Interface Language`,
},
fetch_limit: {
zh: `最大并发请求数量 (1-100)`,
en: `Maximum Number Of Concurrent Requests (1-100)`,
},
if_think: {
zh: `启用或禁用模型的深度思考能力`,
en: `Enable or disable the models thinking behavior `,
},
think: {
zh: `启用深度思考`,
en: `enable thinking`,
},
nothink: {
zh: `禁用深度思考`,
en: `disable thinking`,
},
think_ignore: {
zh: `忽略以下模型的<think>输出,逗号(,)分割,当模型支持思考但ollama不支持时需要填写本参数`,
en: `Ignore the <think> block for the following models, comma (,) separated`,
},
fetch_interval: {
zh: `每次请求间隔时间 (0-5000ms)`,
en: `Time Between Requests (0-5000ms)`,
},
translate_interval: {
zh: `重新翻译间隔时间 (100-5000ms)`,
en: `Retranslation Interval (100-5000ms)`,
},
http_timeout: {
zh: `请求超时时间 (5000-30000ms)`,
en: `Request Timeout Time (5000-30000ms)`,
},
min_translate_length: {
zh: `最小翻译字符数 (1-100)`,
en: `Minimum number Of Translated Characters (1-100)`,
},
max_translate_length: {
zh: `最大翻译字符数 (100-10000)`,
en: `Maximum number Of Translated Characters (100-10000)`,
},
num_of_newline_characters: {
zh: `换行字符数 (1-1000)`,
en: `Number of Newline Characters (1-1000)`,
},
translate_service: {
zh: `翻译服务`,
en: `Translate Service`,
},
translate_timing: {
zh: `翻译时机`,
en: `Translate Timing`,
},
mk_pagescroll: {
zh: `滚动加载翻译(推荐)`,
en: `Rolling Loading (Suggested)`,
},
mk_pageopen: {
zh: `页面打开全部翻译`,
en: `Page Open`,
},
mk_mouseover: {
zh: `鼠标悬停翻译`,
en: `Mouseover`,
},
mk_ctrlKey: {
zh: `Control + 鼠标悬停`,
en: `Control + Mouseover`,
},
mk_shiftKey: {
zh: `Shift + 鼠标悬停`,
en: `Shift + Mouseover`,
},
mk_altKey: {
zh: `Alt + 鼠标悬停`,
en: `Alt + Mouseover`,
},
from_lang: {
zh: `原文语言`,
en: `Source Language`,
},
to_lang: {
zh: `目标语言`,
en: `Target Language`,
},
to_lang2: {
zh: `第二目标语言`,
en: `Target Language 2`,
},
to_lang2_helper: {
zh: `设定后,与目标语言产生互译效果,但依赖远程语言识别。`,
en: `After setting, it will produce mutual translation effect with the target language, but it relies on remote language recognition.`,
},
text_style: {
zh: `译文样式`,
en: `Text Style`,
},
text_style_alt: {
zh: `译文样式`,
en: `Text Style`,
},
bg_color: {
zh: `样式颜色`,
en: `Style Color`,
},
remain_unchanged: {
zh: `保留不变`,
en: `Remain Unchanged`,
},
google_api: {
zh: `谷歌翻译接口`,
en: `Google Translate API`,
},
default_selector: {
zh: `默认选择器`,
en: `Default selector`,
},
selector_rules: {
zh: `选择器规则`,
en: `Selector Rules`,
},
save: {
zh: `保存`,
en: `Save`,
},
edit: {
zh: `编辑`,
en: `Edit`,
},
cancel: {
zh: `取消`,
en: `Cancel`,
},
delete: {
zh: `删除`,
en: `Delete`,
},
reset: {
zh: `重置`,
en: `Reset`,
},
add: {
zh: `添加`,
en: `Add`,
},
inject_rules: {
zh: `注入订阅规则`,
en: `Inject Subscribe Rules`,
},
personal_rules: {
zh: `个人规则`,
en: `Rules`,
},
subscribe_rules: {
zh: `订阅规则`,
en: `Subscribe`,
},
overwrite_subscribe_rules: {
zh: `覆写订阅规则`,
en: `Overwrite`,
},
subscribe_url: {
zh: `订阅地址`,
en: `Subscribe URL`,
},
rules_warn_1: {
zh: `1、“个人规则”一直生效选择“注入订阅规则”后“订阅规则”才会生效。`,
en: `1. The "Personal Rules" are always in effect. After selecting "Inject Subscription Rules", the "Subscription Rules" will take effect.`,
},
rules_warn_2: {
zh: `2、“订阅规则”的注入位置是倒数第二的位置因此除全局规则(*)外,“个人规则”优先级比“订阅规则”高,“个人规则”填写同样的网址会覆盖”订阅规则“的条目。`,
en: `2. The injection position of "Subscription Rules" is the penultimate position. Therefore, except for the global rules (*), the priority of "Personal Rules" is higher than that of "Subscription Rules". Filling in the same url in "Personal Rules" will overwrite "Subscription Rules" entry.`,
},
rules_warn_3: {
zh: `3、关于规则填写输入框留空或下拉框选“*”表示采用全局规则。`,
en: `3. Regarding filling in the rules: Leave the input box blank or select "*" in the drop-down box to use global rule.`,
},
sync_warn: {
zh: `涉及隐私数据的同步请谨慎选择第三方同步服务,建议自行搭建 kiss-worker 或 WebDAV 服务。`,
en: `When synchronizing data that involves privacy, please be cautious about choosing third-party sync services. It is recommended to set up your own sync service using kiss-worker or WebDAV.`,
},
sync_warn_2: {
zh: `如果服务器存在其他客户端同步的数据,第一次同步将直接覆盖本地配置,后面则根据修改时间,新的覆盖旧的。`,
en: `If the server has data synchronized by other clients, the first synchronization will directly overwrite the local configuration, and later, according to the modification time, the new one will overwrite the old one.`,
},
about_sync_api: {
zh: `自建kiss-wroker数据同步服务`,
en: `Self-hosting a Kiss-worker data sync service`,
},
about_api: {
zh: `暂未列出的接口,理论上都可以通过自定义接口的形式支持。`,
en: `Interfaces that have not yet been launched can theoretically be supported through custom interfaces.`,
},
about_api_proxy: {
zh: `查看自建一个翻译接口代理`,
en: `Check out the self-built translation interface proxy`,
},
style_none: {
zh: ``,
en: `None`,
},
under_line: {
zh: `下划直线`,
en: `Underline`,
},
dot_line: {
zh: `下划点状线`,
en: `Dotted Underline`,
},
dash_line: {
zh: `下划虚线`,
en: `Dashed Underline`,
},
wavy_line: {
zh: `下划波浪线`,
en: `Wavy Underline`,
},
fuzzy: {
zh: `模糊`,
en: `Fuzzy`,
},
highlight: {
zh: `高亮`,
en: `Highlight`,
},
blockquote: {
zh: `引用`,
en: `Blockquote`,
},
diy_style: {
zh: `自定义样式`,
en: `Custom Style`,
},
diy_style_helper: {
zh: `遵循“CSS”的语法`,
en: `Follow the syntax of "CSS"`,
},
setting: {
zh: `设置`,
en: `Setting`,
},
pattern: {
zh: `匹配网址`,
en: `URL pattern`,
},
pattern_helper: {
zh: `1、支持星号(*)通配符。2、多个URL用换行或英文逗号“,”分隔。`,
en: `1. Supports the asterisk (*) wildcard character. 2. Separate multiple URLs with newlines or English commas ",".`,
},
selector_helper: {
zh: `1、遵循CSS选择器语法。2、多个CSS选择器之间用“;”隔开。3、“shadow root”选择器和内部选择器用“>>>”隔开。`,
en: `1. Follow CSS selector syntax. 2. Separate multiple CSS selectors with ";". 3. The "shadow root" selector and the internal selector are separated by ">>>".`,
},
translate_switch: {
zh: `开启翻译`,
en: `Translate Switch`,
},
default_enabled: {
zh: `默认开启`,
en: `Enabled`,
},
default_disabled: {
zh: `默认关闭`,
en: `Disabled`,
},
selector: {
zh: `选择器`,
en: `Selector`,
},
keep_selector: {
zh: `保留元素选择器`,
en: `Keep unchanged selector`,
},
keep_selector_helper: {
zh: `1、遵循CSS选择器语法。`,
en: `1. Follow CSS selector syntax.`,
},
terms: {
zh: `专业术语`,
en: `Terms`,
},
terms_helper: {
zh: `1、支持正则表达式匹配无需斜杆不支持修饰符。2、多条术语用换行或分号“;”隔开。3、术语和译文用英文逗号“,”隔开。4、没有译文视为不翻译术语。`,
en: `1. Supports regular expression matching, no slash required, and no modifiers are supported. 2. Separate multiple terms with newlines or semicolons ";". 3. Terms and translations are separated by English commas ",". 4. If there is no translation, the term will be deemed not to be translated.`,
},
selector_style: {
zh: `选择器节点样式`,
en: `Selector Style`,
},
selector_style_helper: {
zh: `开启翻译时注入,关闭翻译时不会移除。`,
en: `It is injected when translation is turned on and will not be removed when translation is turned off.`,
},
selector_parent_style: {
zh: `选择器父节点样式`,
en: `Selector Parent Style`,
},
inject_js: {
zh: `注入JS`,
en: `Inject JS`,
},
inject_js_helper: {
zh: `1、开启翻译时注入运行关闭翻译时移除。2、随着页面变化可能会多次注入运行。`,
en: `1. Inject and run when translation is turned on, and removed when translation is turned off. 2. As the page changes, it may be injected and run multiple times.`,
},
inject_css: {
zh: `注入CSS`,
en: `Inject CSS`,
},
inject_css_helper: {
zh: `开启翻译时注入,关闭翻译时将移除。`,
en: `Injected when translation is enabled and removed when translation is disabled.`,
},
root_selector: {
zh: `根选择器`,
en: `Root Selector`,
},
fixer_function: {
zh: `修复函数`,
en: `Fixer Function`,
},
fixer_function_helper: {
zh: `1、br是将<br>换行替换成<p "kiss-p">。2、bn是将\\n换行替换成<p "kiss-p">。3、brToDiv和bnToDiv是替换成<div class="kiss-p">。`,
en: `1. br replaces <br> line breaks with <p "kiss-p">. 2. bn replaces \\n newline with <p "kiss-p">. 3. brToDiv and bnToDiv are replaced with <div class="kiss-p">.`,
},
import: {
zh: `导入`,
en: `Import`,
},
export: {
zh: `导出`,
en: `Export`,
},
export_translation: {
zh: `导出释义`,
en: `Export Translation`,
},
error_cant_be_blank: {
zh: `不能为空`,
en: `Can not be blank`,
},
error_duplicate_values: {
zh: `存在重复的值`,
en: `There are duplicate values`,
},
error_wrong_file_type: {
zh: `错误的文件类型`,
en: `Wrong file type`,
},
error_fetch_url: {
zh: `请检查url地址是否正确或稍后再试。`,
en: `Please check if the url address is correct or try again later.`,
},
deepl_api: {
zh: `DeepL 接口`,
en: `DeepL API`,
},
deepl_key: {
zh: `DeepL 密钥`,
en: `DeepL Key`,
},
openai_api: {
zh: `OpenAI 接口`,
en: `OpenAI API`,
},
openai_key: {
zh: `OpenAI 密钥`,
en: `OpenAI Key`,
},
openai_model: {
zh: `OpenAI 模型`,
en: `OpenAI Model`,
},
openai_prompt: {
zh: `OpenAI 提示词`,
en: `OpenAI Prompt`,
},
if_clear_cache: {
zh: `是否清除缓存`,
en: `Whether clear cache`,
},
clear_cache_never: {
zh: `不清除缓存`,
en: `Never clear cache`,
},
clear_cache_restart: {
zh: `重启浏览器时清除缓存`,
en: `Clear cache when restarting browser`,
},
data_sync_type: {
zh: `数据同步方式`,
en: `Data Sync Type`,
},
data_sync_url: {
zh: `数据同步接口`,
en: `Data Sync API`,
},
data_sync_user: {
zh: `数据同步账户`,
en: `Data Sync User`,
},
data_sync_key: {
zh: `数据同步密钥`,
en: `Data Sync Key`,
},
sync_now: {
zh: `立即同步`,
en: `Sync Now`,
},
sync_success: {
zh: `同步成功!`,
en: `Sync Success`,
},
sync_failed: {
zh: `同步失败!`,
en: `Sync Error`,
},
error_got_some_wrong: {
zh: `抱歉,出错了!`,
en: `Sorry, something went wrong!`,
},
error_sync_setting: {
zh: `您的同步类型必须为“KISS-Worker”且需填写完整`,
en: `Your sync type must be "KISS-Worker" and must be filled in completely`,
},
click_test: {
zh: `点击测试`,
en: `Click Test`,
},
test_success: {
zh: `测试成功`,
en: `Test success`,
},
test_failed: {
zh: `测试失败`,
en: `Test failed`,
},
clear_all_cache_now: {
zh: `立即清除全部缓存`,
en: `Clear all cache now`,
},
clear_cache: {
zh: `清除缓存`,
en: `Clear Cache`,
},
clear_success: {
zh: `清除成功`,
en: `Clear success`,
},
clear_failed: {
zh: `清除失败`,
en: `Clear failed`,
},
share: {
zh: `分享`,
en: `Share`,
},
clear_all: {
zh: `清空`,
en: `Clear All`,
},
help: {
zh: `求助`,
en: `Help`,
},
restore_default: {
zh: `恢复默认`,
en: `Restore Default`,
},
shortcuts_setting: {
zh: `快捷键设置`,
en: `Shortcuts Setting`,
},
toggle_translate_shortcut: {
zh: `"开启翻译"快捷键`,
en: `"Toggle Translate" Shortcut`,
},
toggle_style_shortcut: {
zh: `"切换样式"快捷键`,
en: `"Toggle Style" Shortcut`,
},
toggle_popup_shortcut: {
zh: `"打开弹窗"快捷键`,
en: `"Open Popup" Shortcut`,
},
open_setting_shortcut: {
zh: `"打开设置"快捷键`,
en: `"Open Setting" Shortcut`,
},
hide_fab_button: {
zh: `隐藏悬浮按钮`,
en: `Hide Fab Button`,
},
hide_tran_button: {
zh: `隐藏翻译按钮`,
en: `Hide Translate Button`,
},
hide_click_away: {
zh: `点击外部关闭弹窗`,
en: `Click outside to close the pop-up window`,
},
use_simple_style: {
zh: `使用简洁界面`,
en: `Use a simple interface`,
},
show: {
zh: `显示`,
en: `Show`,
},
hide: {
zh: `隐藏`,
en: `Hide`,
},
save_rule: {
zh: `保存规则`,
en: `Save Rule`,
},
global_rule: {
zh: `全局规则`,
en: `Global Rule`,
},
input_translate: {
zh: `输入框翻译`,
en: `Input Box Translation`,
},
use_input_box_translation: {
zh: `启用输入框翻译`,
en: `Input Box Translation`,
},
input_selector: {
zh: `输入框选择器`,
en: `Input Selector`,
},
input_selector_helper: {
zh: `用于输入框翻译。`,
en: `Used for input box translation.`,
},
trigger_trans_shortcut: {
zh: `触发翻译快捷键`,
en: `Trigger Translation Shortcut Keys`,
},
trigger_trans_shortcut_help: {
zh: `默认为单击“AltLeft+KeyI”`,
en: `Default is "AltLeft+KeyI"`,
},
shortcut_press_count: {
zh: `快捷键连击次数`,
en: `Shortcut Press Number`,
},
combo_timeout: {
zh: `连击超时时间 (10-1000ms)`,
en: `Combo Timeout (10-1000ms)`,
},
input_trans_start_sign: {
zh: `翻译起始标识`,
en: `Translation Start Sign`,
},
input_trans_start_sign_help: {
zh: `标识后面可以加目标语言代码,如: “/en 你好”、“/zh hello”`,
en: `The target language code can be added after the sign, such as: "/en 你好", "/zh hello"`,
},
detect_lang_remote: {
zh: `远程语言检测`,
en: `Remote language detection`,
},
detect_lang_remote_help: {
zh: `启用后检测准确度增加,但会降低翻译速度,请酌情开启`,
en: `After enabling, the detection accuracy will increase, but it will reduce the translation speed. Please enable it as appropriate.`,
},
disable: {
zh: `禁用`,
en: `Disable`,
},
enable: {
zh: `启用`,
en: `Enable`,
},
selection_translate: {
zh: `划词翻译`,
en: `Selection Translate`,
},
toggle_selection_translate: {
zh: `启用划词翻译`,
en: `Use Selection Translate`,
},
trigger_tranbox_shortcut: {
zh: `显示翻译框/翻译选中文字快捷键`,
en: `Open Translate Popup/Translate Selected Shortcut`,
},
tranbtn_offset_x: {
zh: `翻译按钮偏移X±200`,
en: `Translate Button Offset X (±200)`,
},
tranbtn_offset_y: {
zh: `翻译按钮偏移Y±200`,
en: `Translate Button Offset Y (±200)`,
},
tranbox_offset_x: {
zh: `翻译框偏移X±200`,
en: `Translate Box Offset X (±200)`,
},
tranbox_offset_y: {
zh: `翻译框偏移Y±200`,
en: `Translate Box Offset Y (±200)`,
},
translated_text: {
zh: `译文`,
en: `Translated Text`,
},
original_text: {
zh: `原文`,
en: `Original Text`,
},
favorite_words: {
zh: `收藏词汇`,
en: `Favorite Words`,
},
touch_setting: {
zh: `触屏设置`,
en: `Touch Setting`,
},
touch_translate_shortcut: {
zh: `触屏翻译快捷方式`,
en: `Touch Translate Shortcut`,
},
touch_tap_0: {
zh: `禁用`,
en: `Disable`,
},
touch_tap_2: {
zh: `双指轻触`,
en: `Two finger tap`,
},
touch_tap_3: {
zh: `三指轻触`,
en: `Three finger tap`,
},
touch_tap_4: {
zh: `四指轻触`,
en: `Four finger tap`,
},
translate_blacklist: {
zh: `禁用翻译名单`,
en: `Translate Blacklist`,
},
disabled_csplist: {
zh: `禁用CSP名单`,
en: `Disabled CSP List`,
},
disabled_csplist_helper: {
zh: `3、通过调整CSP策略使得某些页面能够注入JS/CSS/Media请谨慎使用除非您已知晓相关风险。`,
en: `3. By adjusting the CSP policy, some pages can inject JS/CSS/Media. Please use it with caution unless you are aware of the related risks.`,
},
skip_langs: {
zh: `不翻译的语言`,
en: `Disable Languages`,
},
skip_langs_helper: {
zh: `此功能依赖准确的语言检测,建议启用远程语言检测。`,
en: `This feature relies on accurate language detection. It is recommended to enable remote language detection.`,
},
context_menus: {
zh: `右键菜单`,
en: `Context Menus`,
},
hide_context_menus: {
zh: `隐藏右键菜单`,
en: `Hide Context Menus`,
},
simple_context_menus: {
zh: `简单右键菜单`,
en: `Simple_context_menus Context Menus`,
},
secondary_context_menus: {
zh: `二级右键菜单`,
en: `Secondary Context Menus`,
},
mulkeys_help: {
zh: `支持用换行或英文逗号“,”分隔,轮询调用。`,
en: `Supports polling calls separated by newlines or English commas ",".`,
},
translation_element_tag: {
zh: `译文元素标签`,
en: `Translation Element Tag`,
},
show_only_translations: {
zh: `仅显示译文`,
en: `Show Only Translations`,
},
show_only_translations_help: {
zh: `非完美实现,某些页面可能有样式等问题。`,
en: `It is not a perfect implementation and some pages may have style issues.`,
},
translate_page_title: {
zh: `是否翻译页面标题`,
en: `Translate Page Title`,
},
more: {
zh: `更多`,
en: `More`,
},
less: {
zh: `更少`,
en: `Less`,
},
fixer_selector: {
zh: `网页修复选择器`,
en: `Fixer Selector`,
},
reg_niutrans: {
zh: `获取小牛翻译密钥【简约翻译专属新用户注册赠送300万字符】`,
en: `Get NiuTrans APIKey [KISS Translator Exclusive New User Registration Free 3 Million Characters]`,
},
trigger_mode: {
zh: `触发方式`,
en: `Trigger Mode`,
},
trigger_click: {
zh: `点击触发`,
en: `Click Trigger`,
},
trigger_hover: {
zh: `鼠标悬停触发`,
en: `Hover Trigger`,
},
trigger_select: {
zh: `选中触发`,
en: `Select Trigger`,
},
extend_styles: {
zh: `附加样式`,
en: `Extend Styles`,
},
custom_option: {
zh: `自定义选项`,
en: `Custom Option`,
},
translate_selected_text: {
zh: `翻译选中文字`,
en: `Translate Selected Text`,
},
toggle_style: {
zh: `切换样式`,
en: `Toggle Style`,
},
open_menu: {
zh: `打开弹窗菜单`,
en: `Open Popup Menu`,
},
open_setting: {
zh: `打开设置`,
en: `Open Setting`,
},
follow_selection: {
zh: `翻译框跟随选中文本`,
en: `Transbox Follow Selection`,
},
translate_start_hook: {
zh: `翻译开始钩子函数`,
en: `Translate Start Hook`,
},
translate_start_hook_helper: {
zh: `翻译开始时运行,入参为: 翻译节点,原文文本。`,
en: `Run when translation starts, the input parameters are: translation node, original text.`,
},
translate_end_hook: {
zh: `翻译完成钩子函数`,
en: `Translate End Hook`,
},
translate_end_hook_helper: {
zh: `翻译完成时运行,入参为: 翻译节点,原文文本,译文文本,保留元素。`,
en: `Run when the translation is completed, the input parameters are: translation node, original text, translation text, retained elements.`,
},
translate_remove_hook: {
zh: `翻译移除钩子函数`,
en: `Translate Removed Hook`,
},
translate_remove_hook_helper: {
zh: `翻译移除时运行,入参为: 翻译节点。`,
en: `Run when translation is removed, the input parameters are: translation node.`,
},
english_dict: {
zh: `英文词典`,
en: `English Dictionary`,
},
api_name: {
zh: `接口名称`,
en: `API Name`,
},
is_disabled: {
zh: `是否禁用`,
en: `Is Disabled`,
},
translate_selected: {
zh: `是否启用划词翻译`,
en: `If translate selected`,
},
};

806
src/config/index.js Normal file
View File

@@ -0,0 +1,806 @@
import {
DEFAULT_SELECTOR,
DEFAULT_KEEP_SELECTOR,
GLOBAL_KEY,
REMAIN_KEY,
SHADOW_KEY,
DEFAULT_RULE,
DEFAULT_OW_RULE,
BUILTIN_RULES,
} from "./rules";
import { APP_NAME, APP_LCNAME } from "./app";
export { I18N, UI_LANGS } from "./i18n";
export {
GLOBAL_KEY,
REMAIN_KEY,
SHADOW_KEY,
DEFAULT_RULE,
DEFAULT_OW_RULE,
BUILTIN_RULES,
APP_LCNAME,
};
export const STOKEY_MSAUTH = `${APP_NAME}_msauth`;
export const STOKEY_BDAUTH = `${APP_NAME}_bdauth`;
export const STOKEY_SETTING = `${APP_NAME}_setting`;
export const STOKEY_RULES = `${APP_NAME}_rules`;
export const STOKEY_WORDS = `${APP_NAME}_words`;
export const STOKEY_SYNC = `${APP_NAME}_sync`;
export const STOKEY_FAB = `${APP_NAME}_fab`;
export const STOKEY_RULESCACHE_PREFIX = `${APP_NAME}_rulescache_`;
export const CMD_TOGGLE_TRANSLATE = "toggleTranslate";
export const CMD_TOGGLE_STYLE = "toggleStyle";
export const CMD_OPEN_OPTIONS = "openOptions";
export const CMD_OPEN_TRANBOX = "openTranbox";
export const CLIENT_WEB = "web";
export const CLIENT_CHROME = "chrome";
export const CLIENT_EDGE = "edge";
export const CLIENT_FIREFOX = "firefox";
export const CLIENT_USERSCRIPT = "userscript";
export const CLIENT_THUNDERBIRD = "thunderbird";
export const CLIENT_EXTS = [
CLIENT_CHROME,
CLIENT_EDGE,
CLIENT_FIREFOX,
CLIENT_THUNDERBIRD,
];
export const KV_RULES_KEY = "kiss-rules.json";
export const KV_WORDS_KEY = "kiss-words.json";
export const KV_RULES_SHARE_KEY = "kiss-rules-share.json";
export const KV_SETTING_KEY = "kiss-setting.json";
export const KV_SALT_SYNC = "KISS-Translator-SYNC";
export const KV_SALT_SHARE = "KISS-Translator-SHARE";
export const CACHE_NAME = `${APP_NAME}_cache`;
export const MSG_FETCH = "fetch";
export const MSG_GET_HTTPCACHE = "get_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_CONTEXT_MENUS = "context_menus";
export const MSG_COMMAND_SHORTCUTS = "command_shortcuts";
export const MSG_INJECT_JS = "inject_js";
export const MSG_INJECT_CSS = "inject_css";
export const MSG_UPDATE_CSP = "update_csp";
export const THEME_LIGHT = "light";
export const THEME_DARK = "dark";
export const URL_KISS_WORKER = "https://github.com/fishjar/kiss-worker";
export const URL_KISS_PROXY = "https://github.com/fishjar/kiss-proxy";
export const URL_KISS_RULES = "https://github.com/fishjar/kiss-rules";
export const URL_KISS_RULES_NEW_ISSUE =
"https://github.com/fishjar/kiss-rules/issues/new";
export const URL_RAW_PREFIX =
"https://raw.githubusercontent.com/fishjar/kiss-translator/master";
export const URL_CACHE_TRAN = `https://${APP_LCNAME}/translate`;
// api.cognitive.microsofttranslator.com
export const URL_MICROSOFT_TRAN =
"https://api-edge.cognitive.microsofttranslator.com/translate";
export const URL_MICROSOFT_AUTH = "https://edge.microsoft.com/translate/auth";
export const URL_MICROSOFT_LANGDETECT =
"https://api-edge.cognitive.microsofttranslator.com/detect?api-version=3.0";
export const URL_GOOGLE_TRAN =
"https://translate.googleapis.com/translate_a/single";
export const URL_GOOGLE_TRAN2 =
"https://translate-pa.googleapis.com/v1/translateHtml";
export const DEFAULT_GOOGLE_API_KEY = "AIzaSyATBXajvzQLTDHEQbcpq0Ihe0vWDHmO520";
export const URL_BAIDU_LANGDETECT = "https://fanyi.baidu.com/langdetect";
export const URL_BAIDU_SUGGEST = "https://fanyi.baidu.com/sug";
export const URL_BAIDU_TTS = "https://fanyi.baidu.com/gettts";
export const URL_BAIDU_WEB = "https://fanyi.baidu.com/";
export const URL_BAIDU_TRANSAPI = "https://fanyi.baidu.com/transapi";
export const URL_BAIDU_TRANSAPI_V2 = "https://fanyi.baidu.com/v2transapi";
export const URL_DEEPLFREE_TRAN = "https://www2.deepl.com/jsonrpc";
export const URL_TENCENT_TRANSMART = "https://transmart.qq.com/api/imt";
export const URL_VOLCENGINE_TRAN =
"https://translate.volcengine.com/crx/translate/v1";
export const URL_NIUTRANS_REG =
"https://niutrans.com/login?active=3&userSource=kiss-translator";
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";
export const OPT_DICT_BAIDU = "Baidu";
export const OPT_TRANS_GOOGLE = "Google";
export const OPT_TRANS_GOOGLE_2 = "Google2";
export const OPT_TRANS_MICROSOFT = "Microsoft";
export const OPT_TRANS_DEEPL = "DeepL";
export const OPT_TRANS_DEEPLX = "DeepLX";
export const OPT_TRANS_DEEPLFREE = "DeepLFree";
export const OPT_TRANS_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_OPENAI_2 = "OpenAI2";
export const OPT_TRANS_OPENAI_3 = "OpenAI3";
export const OPT_TRANS_GEMINI = "Gemini";
export const OPT_TRANS_GEMINI_2 = "Gemini2";
export const OPT_TRANS_CLAUDE = "Claude";
export const OPT_TRANS_CLOUDFLAREAI = "CloudflareAI";
export const OPT_TRANS_OLLAMA = "Ollama";
export const OPT_TRANS_OLLAMA_2 = "Ollama2";
export const OPT_TRANS_OLLAMA_3 = "Ollama3";
export const OPT_TRANS_CUSTOMIZE = "Custom";
export const OPT_TRANS_CUSTOMIZE_2 = "Custom2";
export const OPT_TRANS_CUSTOMIZE_3 = "Custom3";
export const OPT_TRANS_CUSTOMIZE_4 = "Custom4";
export const OPT_TRANS_CUSTOMIZE_5 = "Custom5";
export const OPT_TRANS_ALL = [
OPT_TRANS_GOOGLE,
OPT_TRANS_GOOGLE_2,
OPT_TRANS_MICROSOFT,
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_OPENAI_2,
OPT_TRANS_OPENAI_3,
OPT_TRANS_GEMINI,
OPT_TRANS_GEMINI_2,
OPT_TRANS_CLAUDE,
OPT_TRANS_CLOUDFLAREAI,
OPT_TRANS_OLLAMA,
OPT_TRANS_OLLAMA_2,
OPT_TRANS_OLLAMA_3,
OPT_TRANS_CUSTOMIZE,
OPT_TRANS_CUSTOMIZE_2,
OPT_TRANS_CUSTOMIZE_3,
OPT_TRANS_CUSTOMIZE_4,
OPT_TRANS_CUSTOMIZE_5,
];
export const OPT_LANGDETECTOR_ALL = [
OPT_TRANS_GOOGLE,
OPT_TRANS_MICROSOFT,
OPT_TRANS_BAIDU,
OPT_TRANS_TENCENT,
];
export const OPT_LANGS_TO = [
["en", "English - English"],
["zh-CN", "Simplified Chinese - 简体中文"],
["zh-TW", "Traditional Chinese - 繁體中文"],
["ar", "Arabic - العربية"],
["bg", "Bulgarian - Български"],
["ca", "Catalan - Català"],
["hr", "Croatian - Hrvatski"],
["cs", "Czech - Čeština"],
["da", "Danish - Dansk"],
["nl", "Dutch - Nederlands"],
["fi", "Finnish - Suomi"],
["fr", "French - Français"],
["de", "German - Deutsch"],
["el", "Greek - Ελληνικά"],
["hi", "Hindi - हिन्दी"],
["hu", "Hungarian - Magyar"],
["id", "Indonesian - Indonesia"],
["it", "Italian - Italiano"],
["ja", "Japanese - 日本語"],
["ko", "Korean - 한국어"],
["ms", "Malay - Melayu"],
["mt", "Maltese - Malti"],
["nb", "Norwegian - Norsk Bokmål"],
["pl", "Polish - Polski"],
["pt", "Portuguese - Português"],
["ro", "Romanian - Română"],
["ru", "Russian - Русский"],
["sk", "Slovak - Slovenčina"],
["sl", "Slovenian - Slovenščina"],
["es", "Spanish - Español"],
["sv", "Swedish - Svenska"],
["ta", "Tamil - தமிழ்"],
["te", "Telugu - తెలుగు"],
["th", "Thai - ไทย"],
["tr", "Turkish - Türkçe"],
["uk", "Ukrainian - Українська"],
["vi", "Vietnamese - Tiếng Việt"],
];
export const OPT_LANGS_FROM = [["auto", "Auto-detect"], ...OPT_LANGS_TO];
export const OPT_LANGS_SPECIAL = {
[OPT_TRANS_GOOGLE]: new Map(OPT_LANGS_FROM.map(([key]) => [key, key])),
[OPT_TRANS_GOOGLE_2]: new Map(OPT_LANGS_FROM.map(([key]) => [key, key])),
[OPT_TRANS_MICROSOFT]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key]),
["auto", ""],
["zh-CN", "zh-Hans"],
["zh-TW", "zh-Hant"],
]),
[OPT_TRANS_DEEPL]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key.toUpperCase()]),
["auto", ""],
["zh-CN", "ZH"],
["zh-TW", "ZH"],
]),
[OPT_TRANS_DEEPLFREE]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key.toUpperCase()]),
["auto", "auto"],
["zh-CN", "ZH"],
["zh-TW", "ZH"],
]),
[OPT_TRANS_DEEPLX]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key.toUpperCase()]),
["auto", "auto"],
["zh-CN", "ZH"],
["zh-TW", "ZH"],
]),
[OPT_TRANS_NIUTRANS]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key]),
["auto", "auto"],
["zh-CN", "zh"],
["zh-TW", "cht"],
]),
[OPT_TRANS_VOLCENGINE]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key]),
["auto", "auto"],
["zh-CN", "zh"],
["zh-TW", "zh-Hant"],
]),
[OPT_TRANS_BAIDU]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key]),
["zh-CN", "zh"],
["zh-TW", "cht"],
["ar", "ara"],
["bg", "bul"],
["ca", "cat"],
["hr", "hrv"],
["da", "dan"],
["fi", "fin"],
["fr", "fra"],
["hi", "mai"],
["ja", "jp"],
["ko", "kor"],
["ms", "may"],
["mt", "mlt"],
["nb", "nor"],
["ro", "rom"],
["ru", "ru"],
["sl", "slo"],
["es", "spa"],
["sv", "swe"],
["ta", "tam"],
["te", "tel"],
["uk", "ukr"],
["vi", "vie"],
]),
[OPT_TRANS_TENCENT]: new Map([
["auto", "auto"],
["zh-CN", "zh"],
["zh-TW", "zh"],
["en", "en"],
["ar", "ar"],
["de", "de"],
["ru", "ru"],
["fr", "fr"],
["fi", "fil"],
["ko", "ko"],
["ms", "ms"],
["pt", "pt"],
["ja", "ja"],
["th", "th"],
["tr", "tr"],
["es", "es"],
["it", "it"],
["hi", "hi"],
["id", "id"],
["vi", "vi"],
]),
[OPT_TRANS_OPENAI]: new Map(
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
),
[OPT_TRANS_OPENAI_2]: new Map(
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
),
[OPT_TRANS_OPENAI_3]: new Map(
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
),
[OPT_TRANS_GEMINI]: new Map(
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
),
[OPT_TRANS_GEMINI_2]: new Map(
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
),
[OPT_TRANS_CLAUDE]: new Map(
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
),
[OPT_TRANS_OLLAMA]: new Map(
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
),
[OPT_TRANS_OLLAMA_2]: new Map(
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
),
[OPT_TRANS_OLLAMA_3]: new Map(
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
),
[OPT_TRANS_CLOUDFLAREAI]: new Map([
["auto", ""],
["zh-CN", "chinese"],
["zh-TW", "chinese"],
["en", "english"],
["ar", "arabic"],
["de", "german"],
["ru", "russian"],
["fr", "french"],
["pt", "portuguese"],
["ja", "japanese"],
["es", "spanish"],
["hi", "hindi"],
]),
[OPT_TRANS_CUSTOMIZE]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key]),
["auto", ""],
]),
[OPT_TRANS_CUSTOMIZE_2]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key]),
["auto", ""],
]),
[OPT_TRANS_CUSTOMIZE_3]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key]),
["auto", ""],
]),
[OPT_TRANS_CUSTOMIZE_4]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key]),
["auto", ""],
]),
[OPT_TRANS_CUSTOMIZE_5]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key]),
["auto", ""],
]),
};
export const OPT_LANGS_LIST = OPT_LANGS_TO.map(([lang]) => lang);
export const OPT_LANGS_MICROSOFT = new Map(
Array.from(OPT_LANGS_SPECIAL[OPT_TRANS_MICROSOFT].entries()).map(([k, v]) => [
v,
k,
])
);
export const OPT_LANGS_BAIDU = new Map(
Array.from(OPT_LANGS_SPECIAL[OPT_TRANS_BAIDU].entries()).map(([k, v]) => [
v,
k,
])
);
export const OPT_LANGS_TENCENT = new Map(
Array.from(OPT_LANGS_SPECIAL[OPT_TRANS_TENCENT].entries()).map(([k, v]) => [
v,
k,
])
);
OPT_LANGS_TENCENT.set("zh", "zh-CN");
export const OPT_STYLE_NONE = "style_none"; // 无
export const OPT_STYLE_LINE = "under_line"; // 下划线
export const OPT_STYLE_DOTLINE = "dot_line"; // 点状线
export const OPT_STYLE_DASHLINE = "dash_line"; // 虚线
export const OPT_STYLE_WAVYLINE = "wavy_line"; // 波浪线
export const OPT_STYLE_FUZZY = "fuzzy"; // 模糊
export const OPT_STYLE_HIGHLIGHT = "highlight"; // 高亮
export const OPT_STYLE_BLOCKQUOTE = "blockquote"; // 引用
export const OPT_STYLE_DIY = "diy_style"; // 自定义样式
export const OPT_STYLE_ALL = [
OPT_STYLE_NONE,
OPT_STYLE_LINE,
OPT_STYLE_DOTLINE,
OPT_STYLE_DASHLINE,
OPT_STYLE_WAVYLINE,
OPT_STYLE_FUZZY,
OPT_STYLE_HIGHLIGHT,
OPT_STYLE_BLOCKQUOTE,
OPT_STYLE_DIY,
];
export const OPT_STYLE_USE_COLOR = [
OPT_STYLE_LINE,
OPT_STYLE_DOTLINE,
OPT_STYLE_DASHLINE,
OPT_STYLE_WAVYLINE,
OPT_STYLE_HIGHLIGHT,
OPT_STYLE_BLOCKQUOTE,
];
export const OPT_TIMING_PAGESCROLL = "mk_pagescroll"; // 滚动加载翻译
export const OPT_TIMING_PAGEOPEN = "mk_pageopen"; // 直接翻译到底
export const OPT_TIMING_MOUSEOVER = "mk_mouseover";
export const OPT_TIMING_CONTROL = "mk_ctrlKey";
export const OPT_TIMING_SHIFT = "mk_shiftKey";
export const OPT_TIMING_ALT = "mk_altKey";
export const OPT_TIMING_ALL = [
OPT_TIMING_PAGESCROLL,
OPT_TIMING_PAGEOPEN,
OPT_TIMING_MOUSEOVER,
OPT_TIMING_CONTROL,
OPT_TIMING_SHIFT,
OPT_TIMING_ALT,
];
export const DEFAULT_FETCH_LIMIT = 10; // 默认最大任务数量
export const DEFAULT_FETCH_INTERVAL = 100; // 默认任务间隔时间
export const 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 DEFAULT_COLOR = "#209CEE"; // 默认高亮背景色/线条颜色
export const DEFAULT_TRANS_TAG = "font";
export const DEFAULT_SELECT_STYLE =
"-webkit-line-clamp: unset; max-height: none; height: auto;";
// 全局规则
export const GLOBLA_RULE = {
pattern: "*", // 匹配网址
selector: DEFAULT_SELECTOR, // 选择器
keepSelector: DEFAULT_KEEP_SELECTOR, // 保留元素选择器
terms: "", // 专业术语
translator: OPT_TRANS_MICROSOFT, // 翻译服务
fromLang: "auto", // 源语言
toLang: "zh-CN", // 目标语言
textStyle: OPT_STYLE_DASHLINE, // 译文样式
transOpen: "false", // 开启翻译
bgColor: "", // 译文颜色
textDiyStyle: "", // 自定义译文样式
selectStyle: DEFAULT_SELECT_STYLE, // 选择器节点样式
parentStyle: DEFAULT_SELECT_STYLE, // 选择器父节点样式
injectJs: "", // 注入JS
injectCss: "", // 注入CSS
transOnly: "false", // 是否仅显示译文
transTiming: OPT_TIMING_PAGESCROLL, // 翻译时机/鼠标悬停翻译
transTag: DEFAULT_TRANS_TAG, // 译文元素标签
transTitle: "false", // 是否同时翻译页面标题
transSelected: "true", // 是否启用划词翻译
detectRemote: "false", // 是否使用远程语言检测
skipLangs: [], // 不翻译的语言
fixerSelector: "", // 修复函数选择器
fixerFunc: "-", // 修复函数
transStartHook: "", // 钩子函数
transEndHook: "", // 钩子函数
transRemoveHook: "", // 钩子函数
};
// 输入框翻译
export const OPT_INPUT_TRANS_SIGNS = ["/", "//", "\\", "\\\\", ">", ">>"];
export const DEFAULT_INPUT_SHORTCUT = ["AltLeft", "KeyI"];
export const DEFAULT_INPUT_RULE = {
transOpen: true,
translator: OPT_TRANS_MICROSOFT,
fromLang: "auto",
toLang: "en",
triggerShortcut: DEFAULT_INPUT_SHORTCUT,
triggerCount: 1,
triggerTime: 200,
transSign: OPT_INPUT_TRANS_SIGNS[0],
};
// 划词翻译
export const 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, // 是否启用划词翻译作废移至rule
translator: OPT_TRANS_MICROSOFT,
fromLang: "auto",
toLang: "zh-CN",
toLang2: "en",
tranboxShortcut: DEFAULT_TRANBOX_SHORTCUT,
btnOffsetX: 10,
btnOffsetY: 10,
boxOffsetX: 0,
boxOffsetY: 10,
hideTranBtn: false, // 是否隐藏翻译按钮
hideClickAway: false, // 是否点击外部关闭弹窗
simpleStyle: false, // 是否简洁界面
followSelection: false, // 翻译框是否跟随选中文本
triggerMode: OPT_TRANBOX_TRIGGER_CLICK, // 触发翻译方式
extStyles: "", // 附加样式
enDict: OPT_DICT_BAIDU, // 英文词典
};
// 订阅列表
export const DEFAULT_SUBRULES_LIST = [
{
url: process.env.REACT_APP_RULESURL,
selected: false,
},
{
url: process.env.REACT_APP_RULESURL_ON,
selected: true,
},
{
url: process.env.REACT_APP_RULESURL_OFF,
selected: false,
},
];
export const DEFAULT_HTTP_TIMEOUT = 5000; // 调用超时时间
// 翻译接口
const defaultCustomApi = {
url: "",
key: "",
customOption: "", // (作废)
reqHook: "", // request 钩子函数
resHook: "", // response 钩子函数
fetchLimit: DEFAULT_FETCH_LIMIT,
fetchInterval: DEFAULT_FETCH_INTERVAL,
apiName: "",
isDisabled: false,
httpTimeout: DEFAULT_HTTP_TIMEOUT,
};
const defaultOpenaiApi = {
url: "https://api.openai.com/v1/chat/completions",
key: "",
model: "gpt-4",
systemPrompt: `You are a professional, authentic machine translation engine.`,
userPrompt: `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:`,
temperature: 0,
maxTokens: 256,
fetchLimit: 1,
fetchInterval: 500,
apiName: "",
isDisabled: false,
httpTimeout: DEFAULT_HTTP_TIMEOUT * 2,
};
const defaultOllamaApi = {
url: "http://localhost:11434/api/generate",
key: "",
model: "llama3.1",
systemPrompt: `You are a professional, authentic machine translation engine.`,
userPrompt: `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:`,
think: false,
thinkIgnore: `qwen3,deepseek-r1`,
fetchLimit: 1,
fetchInterval: 500,
apiName: "",
isDisabled: false,
httpTimeout: DEFAULT_HTTP_TIMEOUT * 2,
};
export const DEFAULT_TRANS_APIS = {
[OPT_TRANS_GOOGLE]: {
url: URL_GOOGLE_TRAN,
key: "",
fetchLimit: DEFAULT_FETCH_LIMIT, // 最大任务数量
fetchInterval: DEFAULT_FETCH_INTERVAL, // 任务间隔时间
apiName: OPT_TRANS_GOOGLE, // 接口自定义名称
isDisabled: false, // 是否禁用
httpTimeout: DEFAULT_HTTP_TIMEOUT, // 超时时间
},
[OPT_TRANS_GOOGLE_2]: {
url: URL_GOOGLE_TRAN2,
key: DEFAULT_GOOGLE_API_KEY,
fetchLimit: DEFAULT_FETCH_LIMIT,
fetchInterval: DEFAULT_FETCH_INTERVAL,
apiName: OPT_TRANS_GOOGLE_2,
isDisabled: false,
httpTimeout: DEFAULT_HTTP_TIMEOUT,
},
[OPT_TRANS_MICROSOFT]: {
fetchLimit: DEFAULT_FETCH_LIMIT,
fetchInterval: DEFAULT_FETCH_INTERVAL,
apiName: OPT_TRANS_MICROSOFT,
isDisabled: false,
httpTimeout: DEFAULT_HTTP_TIMEOUT,
},
[OPT_TRANS_BAIDU]: {
fetchLimit: DEFAULT_FETCH_LIMIT,
fetchInterval: DEFAULT_FETCH_INTERVAL,
apiName: OPT_TRANS_BAIDU,
isDisabled: false,
httpTimeout: DEFAULT_HTTP_TIMEOUT,
},
[OPT_TRANS_TENCENT]: {
fetchLimit: DEFAULT_FETCH_LIMIT,
fetchInterval: DEFAULT_FETCH_INTERVAL,
apiName: OPT_TRANS_TENCENT,
isDisabled: false,
httpTimeout: DEFAULT_HTTP_TIMEOUT,
},
[OPT_TRANS_VOLCENGINE]: {
fetchLimit: DEFAULT_FETCH_LIMIT,
fetchInterval: DEFAULT_FETCH_INTERVAL,
apiName: OPT_TRANS_VOLCENGINE,
isDisabled: false,
httpTimeout: DEFAULT_HTTP_TIMEOUT,
},
[OPT_TRANS_DEEPL]: {
url: "https://api-free.deepl.com/v2/translate",
key: "",
fetchLimit: 1,
fetchInterval: 500,
apiName: OPT_TRANS_DEEPL,
isDisabled: false,
httpTimeout: DEFAULT_HTTP_TIMEOUT,
},
[OPT_TRANS_DEEPLFREE]: {
fetchLimit: 1,
fetchInterval: 500,
apiName: OPT_TRANS_DEEPLFREE,
isDisabled: false,
httpTimeout: DEFAULT_HTTP_TIMEOUT,
},
[OPT_TRANS_DEEPLX]: {
url: "http://localhost:1188/translate",
key: "",
fetchLimit: 1,
fetchInterval: 500,
apiName: OPT_TRANS_DEEPLX,
isDisabled: false,
httpTimeout: DEFAULT_HTTP_TIMEOUT,
},
[OPT_TRANS_NIUTRANS]: {
url: "https://api.niutrans.com/NiuTransServer/translation",
key: "",
dictNo: "",
memoryNo: "",
fetchLimit: DEFAULT_FETCH_LIMIT,
fetchInterval: DEFAULT_FETCH_INTERVAL,
apiName: OPT_TRANS_NIUTRANS,
isDisabled: false,
httpTimeout: DEFAULT_HTTP_TIMEOUT,
},
[OPT_TRANS_OPENAI]: defaultOpenaiApi,
[OPT_TRANS_OPENAI_2]: defaultOpenaiApi,
[OPT_TRANS_OPENAI_3]: defaultOpenaiApi,
[OPT_TRANS_GEMINI]: {
url: `https://generativelanguage.googleapis.com/v1/models/${INPUT_PLACE_MODEL}:generateContent?key=${INPUT_PLACE_KEY}`,
key: "",
model: "gemini-2.5-flash",
systemPrompt: `You are a professional, authentic machine translation engine.`,
userPrompt: `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:`,
temperature: 0,
maxTokens: 2048,
fetchLimit: 1,
fetchInterval: 500,
apiName: OPT_TRANS_GEMINI,
isDisabled: false,
httpTimeout: DEFAULT_HTTP_TIMEOUT * 2,
},
[OPT_TRANS_GEMINI_2]: {
url: `https://generativelanguage.googleapis.com/v1beta/openai/chat/completions`,
key: "",
model: "gemini-2.0-flash",
systemPrompt: `You are a professional, authentic machine translation engine.`,
userPrompt: `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:`,
temperature: 0,
maxTokens: 2048,
fetchLimit: 1,
fetchInterval: 500,
apiName: OPT_TRANS_GEMINI_2,
isDisabled: false,
httpTimeout: DEFAULT_HTTP_TIMEOUT * 2,
},
[OPT_TRANS_CLAUDE]: {
url: "https://api.anthropic.com/v1/messages",
key: "",
model: "claude-3-haiku-20240307",
systemPrompt: `You are a professional, authentic machine translation engine.`,
userPrompt: `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:`,
temperature: 0,
maxTokens: 1024,
fetchLimit: 1,
fetchInterval: 500,
apiName: OPT_TRANS_CLAUDE,
isDisabled: false,
httpTimeout: DEFAULT_HTTP_TIMEOUT * 2,
},
[OPT_TRANS_CLOUDFLAREAI]: {
url: "https://api.cloudflare.com/client/v4/accounts/{{ACCOUNT_ID}}/ai/run/@cf/meta/m2m100-1.2b",
key: "",
fetchLimit: 1,
fetchInterval: 500,
apiName: OPT_TRANS_CLOUDFLAREAI,
isDisabled: false,
httpTimeout: DEFAULT_HTTP_TIMEOUT * 2,
},
[OPT_TRANS_OLLAMA]: defaultOllamaApi,
[OPT_TRANS_OLLAMA_2]: defaultOllamaApi,
[OPT_TRANS_OLLAMA_3]: defaultOllamaApi,
[OPT_TRANS_CUSTOMIZE]: defaultCustomApi,
[OPT_TRANS_CUSTOMIZE_2]: defaultCustomApi,
[OPT_TRANS_CUSTOMIZE_3]: defaultCustomApi,
[OPT_TRANS_CUSTOMIZE_4]: defaultCustomApi,
[OPT_TRANS_CUSTOMIZE_5]: defaultCustomApi,
};
// 默认快捷键
export const OPT_SHORTCUT_TRANSLATE = "toggleTranslate";
export const OPT_SHORTCUT_STYLE = "toggleStyle";
export const OPT_SHORTCUT_POPUP = "togglePopup";
export const OPT_SHORTCUT_SETTING = "openSetting";
export const DEFAULT_SHORTCUTS = {
[OPT_SHORTCUT_TRANSLATE]: ["AltLeft", "KeyQ"],
[OPT_SHORTCUT_STYLE]: ["AltLeft", "KeyC"],
[OPT_SHORTCUT_POPUP]: ["AltLeft", "KeyK"],
[OPT_SHORTCUT_SETTING]: ["AltLeft", "KeyO"],
};
export const TRANS_MIN_LENGTH = 5; // 最短翻译长度
export const TRANS_MAX_LENGTH = 5000; // 最长翻译长度
export const TRANS_NEWLINE_LENGTH = 20; // 换行字符数
export const DEFAULT_BLACKLIST = [
"https://fishjar.github.io/kiss-translator/options.html",
"https://translate.google.com",
"https://www.deepl.com/translator",
"oapi.dingtalk.com",
"login.dingtalk.com",
]; // 禁用翻译名单
export const DEFAULT_CSPLIST = ["https://github.com"]; // 禁用CSP名单
export const DEFAULT_SETTING = {
darkMode: false, // 深色模式
uiLang: "en", // 界面语言
// fetchLimit: DEFAULT_FETCH_LIMIT, // 最大任务数量(移至transApis作废)
// fetchInterval: DEFAULT_FETCH_INTERVAL, // 任务间隔时间(移至transApis作废)
minLength: TRANS_MIN_LENGTH,
maxLength: TRANS_MAX_LENGTH,
newlineLength: TRANS_NEWLINE_LENGTH,
httpTimeout: DEFAULT_HTTP_TIMEOUT,
clearCache: false, // 是否在浏览器下次启动时清除缓存
injectRules: true, // 是否注入订阅规则
// injectWebfix: true, // 是否注入修复补丁(作废)
// detectRemote: false, // 是否使用远程语言检测(移至rule作废)
// contextMenus: true, // 是否添加右键菜单(作废)
contextMenuType: 1, // 右键菜单类型(0不显示1简单菜单2多级菜单)
// transTag: DEFAULT_TRANS_TAG, // 译文元素标签(移至rule作废)
// transOnly: false, // 是否仅显示译文(移至rule作废)
// transTitle: false, // 是否同时翻译页面标题(移至rule作废)
subrulesList: DEFAULT_SUBRULES_LIST, // 订阅列表
owSubrule: DEFAULT_OW_RULE, // 覆写订阅规则
transApis: DEFAULT_TRANS_APIS, // 翻译接口
// mouseKey: OPT_TIMING_PAGESCROLL, // 翻译时机/鼠标悬停翻译(移至rule作废)
shortcuts: DEFAULT_SHORTCUTS, // 快捷键
inputRule: DEFAULT_INPUT_RULE, // 输入框设置
tranboxSetting: DEFAULT_TRANBOX_SETTING, // 划词翻译设置
touchTranslate: 2, // 触屏翻译
blacklist: DEFAULT_BLACKLIST.join(",\n"), // 禁用翻译名单
csplist: DEFAULT_CSPLIST.join(",\n"), // 禁用CSP名单
// disableLangs: [], // 不翻译的语言(移至rule作废)
transInterval: 500, // 翻译间隔时间
langDetector: OPT_TRANS_MICROSOFT, // 远程语言识别服务
};
export const DEFAULT_RULES = [GLOBLA_RULE];
export const OPT_SYNCTYPE_WORKER = "KISS-Worker";
export const OPT_SYNCTYPE_WEBDAV = "WebDAV";
export const OPT_SYNCTYPE_ALL = [OPT_SYNCTYPE_WORKER, OPT_SYNCTYPE_WEBDAV];
export const DEFAULT_SYNC = {
syncType: OPT_SYNCTYPE_WORKER, // 同步方式
syncUrl: "", // 数据同步接口
syncUser: "", // 数据同步用户名
syncKey: "", // 数据同步密钥
syncMeta: {}, // 数据更新及同步信息
subRulesSyncAt: 0, // 订阅规则同步时间
dataCaches: {}, // 缓存同步时间
};

322
src/config/rules.js Normal file
View File

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

3
src/content.js Normal file
View File

@@ -0,0 +1,3 @@
import { run } from "./common";
run();

60
src/hooks/Alert.js Normal file
View File

@@ -0,0 +1,60 @@
import { createContext, useContext, useState, forwardRef } from "react";
import Snackbar from "@mui/material/Snackbar";
import MuiAlert from "@mui/material/Alert";
const Alert = forwardRef(function Alert(props, ref) {
return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />;
});
const AlertContext = createContext(null);
/**
* 左下角提示注入context后方便全局调用
* @param {*} param0
* @returns
*/
export function AlertProvider({ children }) {
const vertical = "top";
const horizontal = "center";
const [open, setOpen] = useState(false);
const [severity, setSeverity] = useState("info");
const [message, setMessage] = useState("");
const showAlert = (msg, type) => {
setOpen(true);
setMessage(msg);
setSeverity(type);
};
const handleClose = (_, reason) => {
if (reason === "clickaway") {
return;
}
setOpen(false);
};
const error = (msg) => showAlert(msg, "error");
const warning = (msg) => showAlert(msg, "warning");
const info = (msg) => showAlert(msg, "info");
const success = (msg) => showAlert(msg, "success");
return (
<AlertContext.Provider value={{ error, warning, info, success }}>
{children}
<Snackbar
open={open}
autoHideDuration={3000}
onClose={handleClose}
anchorOrigin={{ vertical, horizontal }}
>
<Alert onClose={handleClose} severity={severity} sx={{ width: "100%" }}>
{message}
</Alert>
</Snackbar>
</AlertContext.Provider>
);
}
export function useAlert() {
return useContext(AlertContext);
}

34
src/hooks/Api.js Normal file
View File

@@ -0,0 +1,34 @@
import { useCallback } from "react";
import { DEFAULT_TRANS_APIS } from "../config";
import { useSetting } from "./Setting";
export function useApi(translator) {
const { setting, updateSetting } = useSetting();
const transApis = setting?.transApis || DEFAULT_TRANS_APIS;
const updateApi = useCallback(
async (obj) => {
const api = {
...DEFAULT_TRANS_APIS[translator],
...(transApis[translator] || {}),
};
Object.assign(transApis, { [translator]: { ...api, ...obj } });
await updateSetting({ transApis });
},
[translator, transApis, updateSetting]
);
const resetApi = useCallback(async () => {
Object.assign(transApis, { [translator]: DEFAULT_TRANS_APIS[translator] });
await updateSetting({ transApis });
}, [translator, transApis, updateSetting]);
return {
api: {
...DEFAULT_TRANS_APIS[translator],
...(transApis[translator] || {}),
},
updateApi,
resetApi,
};
}

61
src/hooks/Audio.js Normal file
View 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(err, "baidu tts");
}
})();
}, [text, lan, spd]);
return useAudio(src);
}

19
src/hooks/ColorMode.js Normal file
View File

@@ -0,0 +1,19 @@
import { useCallback } from "react";
import { useSetting } from "./Setting";
/**
* 深色模式hook
* @returns
*/
export function useDarkMode() {
const {
setting: { darkMode },
updateSetting,
} = useSetting();
const toggleDarkMode = useCallback(async () => {
await updateSetting({ darkMode: !darkMode });
}, [darkMode, updateSetting]);
return { darkMode, toggleDarkMode };
}

11
src/hooks/Fab.js Normal file
View File

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

68
src/hooks/FavWords.js Normal file
View File

@@ -0,0 +1,68 @@
import { KV_WORDS_KEY } from "../config";
import { useCallback, useEffect, useState } from "react";
import { trySyncWords } from "../libs/sync";
import { getWordsWithDefault, setWords } from "../libs/storage";
import { useSyncMeta } from "./Sync";
import { kissLog } from "../libs/log";
export function useFavWords() {
const [loading, setLoading] = useState(false);
const [favWords, setFavWords] = useState({});
const { updateSyncMeta } = useSyncMeta();
const toggleFav = useCallback(
async (word) => {
const favs = { ...favWords };
if (favs[word]) {
delete favs[word];
} else {
favs[word] = { createdAt: Date.now() };
}
await setWords(favs);
await updateSyncMeta(KV_WORDS_KEY);
await trySyncWords();
setFavWords(favs);
},
[updateSyncMeta, favWords]
);
const mergeWords = useCallback(
async (newWords) => {
const favs = { ...favWords };
newWords.forEach((word) => {
if (!favs[word]) {
favs[word] = { createdAt: Date.now() };
}
});
await setWords(favs);
await updateSyncMeta(KV_WORDS_KEY);
await trySyncWords();
setFavWords(favs);
},
[updateSyncMeta, favWords]
);
const clearWords = useCallback(async () => {
await setWords({});
await updateSyncMeta(KV_WORDS_KEY);
await trySyncWords();
setFavWords({});
}, [updateSyncMeta]);
useEffect(() => {
(async () => {
try {
setLoading(true);
await trySyncWords();
const favWords = await getWordsWithDefault();
setFavWords(favWords);
} catch (err) {
kissLog(err, "query fav");
} finally {
setLoading(false);
}
})();
}, []);
return { loading, favWords, toggleFav, mergeWords, clearWords };
}

40
src/hooks/Fetch.js Normal file
View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
/**
* fetch data hook
* @returns
*/
export const useFetch = (url) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!url) {
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]);
return [data, loading, error];
};

29
src/hooks/I18n.js Normal file
View File

@@ -0,0 +1,29 @@
import { useSetting } from "./Setting";
import { I18N, URL_RAW_PREFIX } from "../config";
import { useFetch } 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 {
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);
};

18
src/hooks/InputRule.js Normal file
View File

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

91
src/hooks/Rules.js Normal file
View File

@@ -0,0 +1,91 @@
import { STOKEY_RULES, DEFAULT_RULES, KV_RULES_KEY } from "../config";
import { useStorage } from "./Storage";
import { trySyncRules } from "../libs/sync";
import { checkRules } from "../libs/rules";
import { useCallback } from "react";
import { useSyncMeta } from "./Sync";
/**
* 规则 hook
* @returns
*/
export function useRules() {
const { data: list, save } = useStorage(STOKEY_RULES, DEFAULT_RULES);
const { updateSyncMeta } = useSyncMeta();
const updateRules = useCallback(
async (rules) => {
await save(rules);
await updateSyncMeta(KV_RULES_KEY);
trySyncRules();
},
[save, updateSyncMeta]
);
const add = useCallback(
async (rule) => {
const rules = [...list];
if (rule.pattern === "*") {
return;
}
if (rules.map((item) => item.pattern).includes(rule.pattern)) {
return;
}
rules.unshift(rule);
await updateRules(rules);
},
[list, updateRules]
);
const del = useCallback(
async (pattern) => {
let rules = [...list];
if (pattern === "*") {
return;
}
rules = rules.filter((item) => item.pattern !== pattern);
await updateRules(rules);
},
[list, updateRules]
);
const clear = useCallback(async () => {
let rules = [...list];
rules = rules.filter((item) => item.pattern === "*");
await updateRules(rules);
}, [list, updateRules]);
const put = useCallback(
async (pattern, obj) => {
const rules = [...list];
if (pattern === "*") {
obj.pattern = "*";
}
const rule = rules.find((r) => r.pattern === pattern);
rule && Object.assign(rule, obj);
await updateRules(rules);
},
[list, updateRules]
);
const merge = useCallback(
async (newRules) => {
const rules = [...list];
newRules = checkRules(newRules);
newRules.forEach((newRule) => {
const rule = rules.find(
(oldRule) => oldRule.pattern === newRule.pattern
);
if (rule) {
Object.assign(rule, newRule);
} else {
rules.unshift(newRule);
}
});
await updateRules(rules);
},
[list, updateRules]
);
return { list, add, del, clear, put, merge };
}

58
src/hooks/Setting.js Normal file
View File

@@ -0,0 +1,58 @@
import { STOKEY_SETTING, DEFAULT_SETTING, KV_SETTING_KEY } from "../config";
import { useStorage } from "./Storage";
import { trySyncSetting } from "../libs/sync";
import { createContext, useCallback, useContext, useMemo } from "react";
import { debounce } from "../libs/utils";
import { useSyncMeta } from "./Sync";
const SettingContext = createContext({
setting: null,
updateSetting: async () => {},
reloadSetting: async () => {},
});
export function SettingProvider({ children }) {
const { data, update, reload } = useStorage(STOKEY_SETTING, DEFAULT_SETTING);
const { updateSyncMeta } = useSyncMeta();
const syncSetting = useMemo(
() =>
debounce(() => {
trySyncSetting();
}, [2000]),
[]
);
const updateSetting = useCallback(
async (obj) => {
await update(obj);
await updateSyncMeta(KV_SETTING_KEY);
syncSetting();
},
[update, syncSetting, updateSyncMeta]
);
if (!data) {
return;
}
return (
<SettingContext.Provider
value={{
setting: data,
updateSetting,
reloadSetting: reload,
}}
>
{children}
</SettingContext.Provider>
);
}
/**
* 设置 hook
* @returns
*/
export function useSetting() {
return useContext(SettingContext);
}

19
src/hooks/Shortcut.js Normal file
View File

@@ -0,0 +1,19 @@
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(
async (val) => {
Object.assign(shortcuts, { [action]: val });
await updateSetting({ shortcuts });
},
[action, shortcuts, updateSetting]
);
return { shortcut, setShortcut };
}

70
src/hooks/Storage.js Normal file
View File

@@ -0,0 +1,70 @@
import { useCallback, useEffect, useState } from "react";
import { storage } from "../libs/storage";
import { kissLog } from "../libs/log";
/**
*
* @param {*} key
* @param {*} defaultVal 需为调用hook外的常量
* @returns
*/
export function useStorage(key, defaultVal) {
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
const save = useCallback(
async (val) => {
setData(val);
await storage.setObj(key, val);
},
[key]
);
const update = useCallback(
async (obj) => {
setData((pre = {}) => ({ ...pre, ...obj }));
await storage.putObj(key, obj);
},
[key]
);
const remove = useCallback(async () => {
setData(null);
await storage.del(key);
}, [key]);
const reload = useCallback(async () => {
try {
setLoading(true);
const val = await storage.getObj(key);
if (val) {
setData(val);
}
} catch (err) {
kissLog(err, "storage reload");
} finally {
setLoading(false);
}
}, [key]);
useEffect(() => {
(async () => {
try {
setLoading(true);
const val = await storage.getObj(key);
if (val) {
setData(val);
} else if (defaultVal) {
setData(defaultVal);
await storage.setObj(key, defaultVal);
}
} catch (err) {
kissLog(err, "storage load");
} finally {
setLoading(false);
}
})();
}, [key, defaultVal]);
return { data, save, update, remove, reload, loading };
}

114
src/hooks/SubRules.js Normal file
View File

@@ -0,0 +1,114 @@
import { DEFAULT_SUBRULES_LIST, DEFAULT_OW_RULE } from "../config";
import { useSetting } from "./Setting";
import { useCallback, useEffect, useMemo, useState } from "react";
import { loadOrFetchSubRules } from "../libs/subRules";
import { delSubRules } from "../libs/storage";
import { kissLog } from "../libs/log";
/**
* 订阅规则
* @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(
async (url) => {
const subrulesList = [...list];
subrulesList.forEach((item) => {
if (item.url === url) {
item.selected = true;
} else {
item.selected = false;
}
});
await updateSetting({ subrulesList });
},
[list, updateSetting]
);
const updateSub = useCallback(
async (url, obj) => {
const subrulesList = [...list];
subrulesList.forEach((item) => {
if (item.url === url) {
Object.assign(item, obj);
}
});
await updateSetting({ subrulesList });
},
[list, updateSetting]
);
const addSub = useCallback(
async (url) => {
const subrulesList = [...list];
subrulesList.push({ url, selected: false });
await updateSetting({ subrulesList });
},
[list, updateSetting]
);
const delSub = useCallback(
async (url) => {
let subrulesList = [...list];
subrulesList = subrulesList.filter((item) => item.url !== url);
await updateSetting({ subrulesList });
await delSubRules(url);
},
[list, updateSetting]
);
useEffect(() => {
(async () => {
if (selectedUrl) {
try {
setLoading(true);
const rules = await loadOrFetchSubRules(selectedUrl);
setSelectedRules(rules);
} catch (err) {
kissLog(err, "loadOrFetchSubRules");
} finally {
setLoading(false);
}
}
})();
}, [selectedUrl]);
return {
subList: list,
selectSub,
updateSub,
addSub,
delSub,
selectedSub,
selectedUrl,
selectedRules,
setSelectedRules,
loading,
};
}
/**
* 覆写订阅规则
* @returns
*/
export function useOwSubRule() {
const { setting, updateSetting } = useSetting();
const { owSubrule = DEFAULT_OW_RULE } = setting;
const updateOwSubrule = useCallback(
async (obj) => {
await updateSetting({ owSubrule: { ...owSubrule, ...obj } });
},
[owSubrule, updateSetting]
);
return { owSubrule, updateOwSubrule };
}

63
src/hooks/Sync.js Normal file
View File

@@ -0,0 +1,63 @@
import { useCallback } from "react";
import { STOKEY_SYNC, DEFAULT_SYNC } from "../config";
import { useStorage } from "./Storage";
/**
* sync hook
* @returns
*/
export function useSync() {
const { data, update, reload } = useStorage(STOKEY_SYNC, DEFAULT_SYNC);
return { sync: data, updateSync: update, reloadSync: reload };
}
/**
* update syncmeta hook
* @returns
*/
export function useSyncMeta() {
const { sync, updateSync } = useSync();
const updateSyncMeta = useCallback(
async (key) => {
const syncMeta = sync?.syncMeta || {};
syncMeta[key] = { ...(syncMeta[key] || {}), updateAt: Date.now() };
await updateSync({ syncMeta });
},
[sync?.syncMeta, updateSync]
);
return { updateSyncMeta };
}
/**
* caches sync hook
* @param {*} url
* @returns
*/
export function useSyncCaches() {
const { sync, updateSync, reloadSync } = useSync();
const updateDataCache = useCallback(
async (url) => {
const dataCaches = sync?.dataCaches || {};
dataCaches[url] = Date.now();
await updateSync({ dataCaches });
},
[sync, updateSync]
);
const deleteDataCache = useCallback(
async (url) => {
const dataCaches = sync?.dataCaches || {};
delete dataCaches[url];
await updateSync({ dataCaches });
},
[sync, updateSync]
);
return {
dataCaches: sync?.dataCaches || {},
updateDataCache,
deleteDataCache,
reloadSync,
};
}

45
src/hooks/Theme.js Normal file
View File

@@ -0,0 +1,45 @@
import { useMemo } from "react";
import { ThemeProvider, createTheme } from "@mui/material/styles";
import { CssBaseline, GlobalStyles } from "@mui/material";
import { useDarkMode } from "./ColorMode";
import { THEME_DARK, THEME_LIGHT } from "../config";
/**
* mui 主题配置
* @param {*} param0
* @returns
*/
export default function Theme({ children, options, styles }) {
const { darkMode } = useDarkMode();
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) {
//
}
return createTheme({
palette: {
mode: darkMode ? THEME_DARK : THEME_LIGHT,
},
typography: {
htmlFontSize,
},
...options,
});
}, [darkMode, options]);
return (
<ThemeProvider theme={theme}>
{/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
<CssBaseline />
<GlobalStyles styles={styles} />
{children}
</ThemeProvider>
);
}

18
src/hooks/Tranbox.js Normal file
View File

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

66
src/hooks/Translate.js Normal file
View File

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

61
src/index.js Normal file
View File

@@ -0,0 +1,61 @@
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 Stack from "@mui/material/Stack";
import Button from "@mui/material/Button";
import Link from "@mui/material/Link";
import { useFetch } from "./hooks/Fetch";
import { I18N, URL_RAW_PREFIX } from "./config";
function App() {
const [lang, setLang] = useState("zh");
const [data, loading, error] = useFetch(
`${URL_RAW_PREFIX}/${I18N?.["about_md"]?.[lang]}`
);
return (
<Paper sx={{ padding: 2, margin: 2 }}>
<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} />
)}
</Paper>
);
}
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

45
src/libs/auth.js Normal file
View File

@@ -0,0 +1,45 @@
import { getMsauth, setMsauth } from "./storage";
import { URL_MICROSOFT_AUTH } from "../config";
import { fetchHandle } from "./fetch";
import { kissLog } from "./log";
const parseMSToken = (token) => {
try {
return JSON.parse(atob(token.split(".")[1])).exp;
} catch (err) {
kissLog(err, "parseMSToken");
}
return 0;
};
/**
* 闭包缓存token减少对storage查询
* @returns
*/
const _msAuth = () => {
let { token, exp } = {};
return async () => {
// 查询内存缓存
const now = Date.now();
if (token && exp * 1000 > now + 1000) {
return [token, exp];
}
// 查询storage缓存
const res = await getMsauth();
token = res?.token;
exp = res?.exp;
if (token && exp * 1000 > now + 1000) {
return [token, exp];
}
// 缓存没有或失效,查询接口
token = await fetchHandle({ input: URL_MICROSOFT_AUTH });
exp = parseMSToken(token);
await setMsauth({ token, exp });
return [token, exp];
};
};
export const msAuth = _msAuth();

10
src/libs/blacklist.js Normal file
View 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()));

17
src/libs/browser.js Normal file
View File

@@ -0,0 +1,17 @@
// import { CLIENT_EXTS, CLIENT_USERSCRIPT, CLIENT_WEB } from "../config";
/**
* 浏览器兼容插件,另可用于判断是插件模式还是网页模式,方便开发
* @returns
*/
function _browser() {
try {
return require("webextension-polyfill");
} catch (err) {
// kissLog(err, "browser");
}
}
export const browser = _browser();
export const isBg = () => globalThis?.ContextType === "BACKGROUND";

6
src/libs/client.js Normal file
View File

@@ -0,0 +1,6 @@
import { CLIENT_EXTS, CLIENT_USERSCRIPT, CLIENT_WEB } 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;

319
src/libs/fetch.js Normal file
View File

@@ -0,0 +1,319 @@
import { isExt, isGm } from "./client";
import { sendBgMsg } from "./msg";
import { taskPool } from "./pool";
import { getSettingWithDefault } from "./storage";
import {
MSG_FETCH,
MSG_GET_HTTPCACHE,
CACHE_NAME,
DEFAULT_FETCH_INTERVAL,
DEFAULT_FETCH_LIMIT,
DEFAULT_HTTP_TIMEOUT,
} from "../config";
import { isBg } from "./browser";
import { genTransReq } from "../apis/trans";
import { kissLog } from "./log";
import { blobToBase64 } from "./utils";
/**
* 构造缓存 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;
};
/**
* 油猴脚本的请求封装
* @param {*} input
* @param {*} init
* @returns
*/
export const fetchGM = async (
input,
{ method = "GET", headers, body, timeout } = {}
) =>
new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method,
url: input,
headers,
data: body,
// 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,
});
});
/**
* 发起请求
* @param {*} param0
* @returns
*/
export const fetchPatcher = async (input, init, transOpts, apiSetting) => {
if (transOpts?.translator) {
[input, init] = await genTransReq(transOpts, apiSetting);
}
if (!input) {
throw new Error("url is empty");
}
let timeout = apiSetting?.httpTimeout || DEFAULT_HTTP_TIMEOUT;
if (!apiSetting) {
try {
timeout = (await getSettingWithDefault()).httpTimeout;
} catch (err) {
//
}
}
if (isGm) {
// let info;
// if (window.KISS_GM) {
// info = await window.KISS_GM.getInfo();
// } else {
// info = GM.info;
// }
// Tampermonkey --> .connects
// Violentmonkey --> .connect
// const connects = info?.script?.connects || info?.script?.connect || [];
// const url = new URL(input);
// const isSafe = connects.find((item) => url.hostname.endsWith(item));
// if (isSafe) {
// // 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,
// });
// }
// 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);
};
/**
* 解析 response
* @param {*} res
* @returns
*/
const parseResponse = async (res) => {
if (!res) {
return null;
}
const contentType = res.headers.get("Content-Type");
if (contentType?.includes("json")) {
return await res.json();
} else if (contentType?.includes("audio")) {
const blob = await res.blob();
return await blobToBase64(blob);
}
return await res.text();
};
/**
* 查询 caches
* @param {*} input
* @param {*} param1
* @returns
*/
export const getHttpCache = async (input, { method, headers, body }) => {
try {
const req = await newCacheReq(input, { method, headers, body });
const cache = await caches.open(CACHE_NAME);
const res = await cache.match(req);
return parseResponse(res);
} catch (err) {
kissLog(err, "get cache");
}
return null;
};
/**
* 插入 caches
* @param {*} input
* @param {*} param1
* @param {*} res
*/
export const putHttpCache = async (input, { method, headers, body }, res) => {
try {
const req = await newCacheReq(input, { method, headers, body });
const cache = await caches.open(CACHE_NAME);
await cache.put(req, res);
} catch (err) {
kissLog(err, "put cache");
}
};
/**
* 处理请求
* @param {*} param0
* @returns
*/
export const fetchHandle = async ({
input,
useCache,
transOpts,
apiSetting,
...init
}) => {
// 发送请求
const res = await fetchPatcher(input, init, transOpts, apiSetting);
if (!res) {
throw new Error("Unknow error");
} else 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));
}
// 插入缓存
if (useCache) {
await putHttpCache(input, init, res.clone());
}
return parseResponse(res);
};
/**
* fetch 兼容性封装
* @param {*} args
* @returns
*/
export const fetchPolyfill = (args) => {
// 插件
if (isExt && !isBg()) {
return sendBgMsg(MSG_FETCH, args);
}
// 油猴/网页/BackgroundPage
return fetchHandle(args);
};
/**
* 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);
};
/**
* 请求池实例
*/
export const fetchPool = taskPool(
fetchPolyfill,
null,
DEFAULT_FETCH_INTERVAL,
DEFAULT_FETCH_LIMIT
);
/**
* 数据请求
* @param {*} input
* @param {*} param1
* @returns
*/
export const fetchData = async (input, { useCache, usePool, ...args } = {}) => {
if (!input?.trim()) {
throw new Error("URL is empty");
}
// 查询缓存
if (useCache) {
const cache = await getHttpCachePolyfill(input, args);
if (cache) {
return cache;
}
}
// 通过任务池发送请求
if (usePool) {
return fetchPool.push({ input, useCache, ...args });
}
// 直接请求
return fetchPolyfill({ input, useCache, ...args });
};
/**
* 更新 fetch pool 参数
* @param {*} interval
* @param {*} limit
*/
export const updateFetchPool = (interval, limit) => {
fetchPool.update(interval, limit);
};
/**
* 清空任务池
*/
export const clearFetchPool = () => {
fetchPool.clear();
};

102
src/libs/gm.js Normal file
View 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
View 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 }, "*");
};

65
src/libs/index.js Normal file
View File

@@ -0,0 +1,65 @@
import {
CACHE_NAME,
OPT_TRANS_GOOGLE,
OPT_TRANS_MICROSOFT,
OPT_TRANS_BAIDU,
OPT_TRANS_TENCENT,
} from "../config";
import { browser } from "./browser";
import {
apiGoogleLangdetect,
apiMicrosoftLangdetect,
apiBaiduLangdetect,
apiTencentLangdetect,
} from "../apis";
import { kissLog } from "./log";
const langdetectMap = {
[OPT_TRANS_GOOGLE]: apiGoogleLangdetect,
[OPT_TRANS_MICROSOFT]: apiMicrosoftLangdetect,
[OPT_TRANS_BAIDU]: apiBaiduLangdetect,
[OPT_TRANS_TENCENT]: apiTencentLangdetect,
};
/**
* 清除缓存数据
*/
export const tryClearCaches = async () => {
try {
caches.delete(CACHE_NAME);
} catch (err) {
kissLog(err, "clean caches");
}
};
/**
* 语言识别
* @param {*} q
* @returns
*/
export const tryDetectLang = async (
q,
useRemote = false,
langDetector = OPT_TRANS_MICROSOFT
) => {
let lang = "";
if (useRemote) {
try {
lang = await langdetectMap[langDetector](q);
} catch (err) {
kissLog(err, "detect lang remote");
}
}
if (!lang) {
try {
const res = await browser?.i18n?.detectLanguage(q);
lang = res?.languages?.[0]?.language;
} catch (err) {
kissLog(err, "detect lang local");
}
}
return lang;
};

35
src/libs/injector.js Normal file
View File

@@ -0,0 +1,35 @@
// Function to inject inline JavaScript code
export const injectInlineJs = (code) => {
const el = document.createElement("script");
el.setAttribute("data-source", "KISS-Calendar injectInlineJs");
el.setAttribute("type", "text/javascript");
el.textContent = code;
document.body?.appendChild(el);
};
// Function to inject external JavaScript file
export const injectExternalJs = (src) => {
const el = document.createElement("script");
el.setAttribute("data-source", "KISS-Calendar injectExternalJs");
el.setAttribute("type", "text/javascript");
el.setAttribute("src", src);
document.body?.appendChild(el);
};
// Function to inject internal CSS code
export const injectInternalCss = (styles) => {
const el = document.createElement("style");
el.setAttribute("data-source", "KISS-Calendar 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-Calendar injectExternalCss");
el.setAttribute("rel", "stylesheet");
el.setAttribute("type", "text/css");
el.setAttribute("href", href);
document.head?.appendChild(el);
};

199
src/libs/inputTranslate.js Normal file
View File

@@ -0,0 +1,199 @@
import {
DEFAULT_INPUT_RULE,
DEFAULT_TRANS_APIS,
DEFAULT_INPUT_SHORTCUT,
OPT_LANGS_LIST,
} from "../config";
import { genEventName, removeEndchar, matchInputStr, sleep } from "./utils";
import { stepShortcutRegister } from "./shortcut";
import { apiTranslate } from "../apis";
import { loadingSvg } from "./svg";
import { kissLog } from "./log";
function isInputNode(node) {
return node.nodeName === "INPUT" || node.nodeName === "TEXTAREA";
}
function isEditAbleNode(node) {
return node.hasAttribute("contenteditable");
}
function selectContent(node) {
node.focus();
const range = document.createRange();
range.selectNodeContents(node);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
function pasteContentEvent(node, text) {
node.focus();
const data = new DataTransfer();
data.setData("text/plain", text);
const event = new ClipboardEvent("paste", { clipboardData: data });
document.dispatchEvent(event);
data.clearData();
}
function pasteContentCommand(node, text) {
node.focus();
document.execCommand("insertText", false, text);
}
function collapseToEnd(node) {
node.focus();
const selection = window.getSelection();
selection.collapseToEnd();
}
function getNodeText(node) {
if (isInputNode(node)) {
return node.value;
}
return node.innerText || node.textContent || "";
}
function addLoading(node, loadingId) {
const div = document.createElement("div");
div.id = loadingId;
div.innerHTML = loadingSvg;
div.style.cssText = `
width: ${node.offsetWidth}px;
height: ${node.offsetHeight}px;
line-height: ${node.offsetHeight}px;
position: absolute;
text-align: center;
left: ${node.offsetLeft}px;
top: ${node.offsetTop}px;
z-index: 2147483647;
`;
node.offsetParent?.appendChild(div);
}
function removeLoading(node, loadingId) {
const div = node.offsetParent.querySelector(`#${loadingId}`);
if (div) {
div.remove();
}
}
/**
* 输入框翻译
*/
export default function inputTranslate({
inputRule: {
transOpen,
triggerShortcut,
translator,
fromLang,
toLang,
triggerCount,
triggerTime,
transSign,
} = DEFAULT_INPUT_RULE,
transApis,
}) {
if (!transOpen) {
return;
}
const apiSetting = transApis?.[translator] || DEFAULT_TRANS_APIS[translator];
if (triggerShortcut.length === 0) {
triggerShortcut = DEFAULT_INPUT_SHORTCUT;
triggerCount = 1;
}
stepShortcutRegister(
triggerShortcut,
async () => {
let node = document.activeElement;
if (!node) {
return;
}
while (node.shadowRoot) {
node = node.shadowRoot.activeElement;
}
if (!isInputNode(node) && !isEditAbleNode(node)) {
return;
}
let initText = getNodeText(node);
if (triggerShortcut.length === 1 && triggerShortcut[0].length === 1) {
// todo: remove multiple char
initText = removeEndchar(initText, triggerShortcut[0], triggerCount);
}
if (!initText.trim()) {
return;
}
let text = initText;
if (transSign) {
const res = matchInputStr(text, transSign);
if (res) {
let lang = res[1];
if (lang === "zh" || lang === "cn") {
lang = "zh-CN";
} else if (lang === "tw" || lang === "hk") {
lang = "zh-TW";
}
if (lang && OPT_LANGS_LIST.includes(lang)) {
toLang = lang;
}
text = res[2];
}
}
// console.log("input -->", text);
const loadingId = "kiss-" + genEventName();
try {
addLoading(node, loadingId);
const [trText, isSame] = await apiTranslate({
translator,
text,
fromLang,
toLang,
apiSetting,
});
if (!trText || isSame) {
return;
}
if (isInputNode(node)) {
node.value = trText;
node.dispatchEvent(
new Event("input", { bubbles: true, cancelable: true })
);
return;
}
selectContent(node);
await sleep(200);
pasteContentEvent(node, trText);
await sleep(200);
// todo: use includes?
if (getNodeText(node).startsWith(initText)) {
pasteContentCommand(node, trText);
await sleep(100);
} else {
collapseToEnd(node);
}
} catch (err) {
kissLog(err, "translate input");
} finally {
removeLoading(node, loadingId);
}
},
triggerCount,
triggerTime
);
}

16
src/libs/interpreter.js Normal file
View File

@@ -0,0 +1,16 @@
import Sval from "sval";
const interpreter = new Sval({
// ECMA Version of the code
// 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15
// or 2015 | 2016 | 2017 | 2018 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024
// or "latest"
ecmaVer: "latest",
// Code source type
// "script" or "module"
sourceType: "script",
// Whether the code runs in a sandbox
sandBox: true,
});
export default interpreter;

12
src/libs/log.js Normal file
View File

@@ -0,0 +1,12 @@
/**
* 日志函数
* @param {*} msg
* @param {*} type
*/
export const kissLog = (msg, type) => {
let prefix = `[KISS-Translator]`;
if (type) {
prefix += `[${type}]`;
}
console.log(`${prefix} ${msg}`);
};

1
src/libs/mobile.js Normal file
View File

@@ -0,0 +1 @@
export const isMobile = "ontouchstart" in document.documentElement;

38
src/libs/msg.js Normal file
View File

@@ -0,0 +1,38 @@
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 sendBgMsg = (action, args) =>
browser.runtime.sendMessage({ action, args });
/**
* 发送消息给当前页面
* @param {*} action
* @param {*} args
* @returns
*/
export const sendTabMsg = async (action, args) => {
const tabId = await getCurTabId();
return browser.tabs.sendMessage(tabId, { action, args });
};

80
src/libs/pool.js Normal file
View File

@@ -0,0 +1,80 @@
import { kissLog } from "./log";
/**
* 任务池
* @param {*} fn
* @param {*} preFn
* @param {*} _interval
* @param {*} _limit
* @returns
*/
export const taskPool = (
fn,
preFn,
_interval = 100,
_limit = 100,
_retryInteral = 1000
) => {
const pool = [];
const maxRetry = 2; // 最大重试次数
let maxCount = _limit; // 最大数量
let curCount = 0; // 当前数量
let interval = _interval; // 间隔时间
let timer = null;
const run = async () => {
// console.log("timer", timer);
timer && clearTimeout(timer);
timer = setTimeout(run, interval);
if (curCount < maxCount) {
const item = pool.shift();
if (item) {
curCount++;
const { args, resolve, reject, retry } = item;
try {
const preArgs = preFn ? await preFn(item.args) : {};
const res = await fn({ ...args, ...preArgs });
resolve(res);
} catch (err) {
kissLog(err, "task");
if (retry < maxRetry) {
const retryTimer = setTimeout(() => {
clearTimeout(retryTimer);
pool.push({ args, resolve, reject, retry: retry + 1 });
}, _retryInteral);
} else {
reject(err);
}
} finally {
curCount--;
}
}
}
};
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;
},
};
};

222
src/libs/rules.js Normal file
View File

@@ -0,0 +1,222 @@
import { matchValue, type, isMatch } from "./utils";
import {
GLOBAL_KEY,
REMAIN_KEY,
OPT_TRANS_ALL,
OPT_STYLE_ALL,
OPT_LANGS_FROM,
OPT_LANGS_TO,
OPT_TIMING_ALL,
GLOBLA_RULE,
} from "../config";
import { loadOrFetchSubRules } from "./subRules";
import { getRulesWithDefault, setRules } from "./storage";
import { trySyncRules } from "./sync";
import { FIXER_ALL } from "./webfix";
import { kissLog } from "./log";
/**
* 根据href匹配规则
* @param {*} rules
* @param {string} href
* @returns
*/
export const matchRule = async (
href,
{ injectRules, subrulesList, owSubrule }
) => {
const rules = await getRulesWithDefault();
if (injectRules) {
try {
const selectedSub = subrulesList.find((item) => item.selected);
if (selectedSub?.url) {
const mixRule = {};
Object.entries(owSubrule)
.filter(([key, val]) => {
if (
owSubrule.textStyle === REMAIN_KEY &&
(key === "bgColor" || key === "textDiyStyle")
) {
return false;
}
return val !== REMAIN_KEY;
})
.forEach(([key, val]) => {
mixRule[key] = val;
});
let subRules = await loadOrFetchSubRules(selectedSub.url);
subRules = subRules.map((item) => ({ ...item, ...mixRule }));
rules.splice(-1, 0, ...subRules);
}
} catch (err) {
kissLog(err, "load injectRules");
}
}
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",
"terms",
"selectStyle",
"parentStyle",
"injectJs",
"injectCss",
"fixerSelector",
"transStartHook",
"transEndHook",
"transRemoveHook",
].forEach((key) => {
if (!rule[key]?.trim()) {
rule[key] = globalRule[key];
}
});
[
"translator",
"fromLang",
"toLang",
"transOpen",
"transOnly",
"transTiming",
"transTag",
"transTitle",
"transSelected",
"detectRemote",
"fixerFunc",
].forEach((key) => {
if (rule[key] === undefined || rule[key] === GLOBAL_KEY) {
rule[key] = globalRule[key];
}
});
if (!rule.skipLangs || rule.skipLangs.length === 0) {
rule.skipLangs = globalRule.skipLangs;
}
if (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
* @param {*} rules
* @returns
*/
export const checkRules = (rules) => {
if (type(rules) === "string") {
rules = JSON.parse(rules);
}
if (type(rules) !== "array") {
throw new Error("data error");
}
const 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")
.filter(({ pattern }) => {
if (type(pattern) !== "string" || patternSet.has(pattern.trim())) {
return false;
}
patternSet.add(pattern.trim());
return true;
})
.map(
({
pattern,
selector,
keepSelector,
terms,
selectStyle,
parentStyle,
injectJs,
injectCss,
translator,
fromLang,
toLang,
textStyle,
transOpen,
bgColor,
textDiyStyle,
transOnly,
transTiming,
transTag,
transTitle,
transSelected,
detectRemote,
skipLangs,
fixerSelector,
fixerFunc,
transStartHook,
transEndHook,
transRemoveHook,
}) => ({
pattern: pattern.trim(),
selector: type(selector) === "string" ? selector : "",
keepSelector: type(keepSelector) === "string" ? keepSelector : "",
terms: type(terms) === "string" ? terms : "",
selectStyle: type(selectStyle) === "string" ? selectStyle : "",
parentStyle: type(parentStyle) === "string" ? parentStyle : "",
injectJs: type(injectJs) === "string" ? injectJs : "",
injectCss: type(injectCss) === "string" ? injectCss : "",
bgColor: type(bgColor) === "string" ? bgColor : "",
textDiyStyle: type(textDiyStyle) === "string" ? textDiyStyle : "",
translator: matchValue([GLOBAL_KEY, ...OPT_TRANS_ALL], translator),
fromLang: matchValue([GLOBAL_KEY, ...fromLangs], fromLang),
toLang: matchValue([GLOBAL_KEY, ...toLangs], toLang),
textStyle: matchValue([GLOBAL_KEY, ...OPT_STYLE_ALL], textStyle),
transOpen: matchValue([GLOBAL_KEY, "true", "false"], transOpen),
transOnly: matchValue([GLOBAL_KEY, "true", "false"], transOnly),
transTiming: matchValue([GLOBAL_KEY, ...OPT_TIMING_ALL], transTiming),
transTag: matchValue([GLOBAL_KEY, "span", "font"], transTag),
transTitle: matchValue([GLOBAL_KEY, "true", "false"], transTitle),
transSelected: matchValue([GLOBAL_KEY, "true", "false"], transSelected),
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),
})
);
return rules;
};
/**
* 保存或更新rule
* @param {*} newRule
*/
export const saveRule = async (newRule) => {
const rules = await getRulesWithDefault();
const rule = rules.find((item) => isMatch(newRule.pattern, item.pattern));
if (rule && rule.pattern !== GLOBAL_KEY) {
Object.assign(rule, { ...newRule, pattern: rule.pattern });
} else {
rules.unshift(newRule);
}
await setRules(rules);
trySyncRules();
};

112
src/libs/shortcut.js Normal file
View File

@@ -0,0 +1,112 @@
import { isSameSet } from "./utils";
/**
* 键盘快捷键监听
* @param {*} fn
* @param {*} target
* @param {*} timeout
* @returns
*/
export const shortcutListener = (fn, target = document, timeout = 3000) => {
const allkeys = new Set();
const curkeys = new Set();
let timer = null;
const handleKeydown = (e) => {
timer && clearTimeout(timer);
timer = setTimeout(() => {
allkeys.clear();
curkeys.clear();
clearTimeout(timer);
timer = null;
}, timeout);
if (e.code) {
allkeys.add(e.code);
curkeys.add(e.code);
fn([...curkeys], [...allkeys]);
}
};
const handleKeyup = (e) => {
curkeys.delete(e.code);
if (curkeys.size === 0) {
fn([...curkeys], [...allkeys]);
allkeys.clear();
}
};
target.addEventListener("keydown", handleKeydown, true);
target.addEventListener("keyup", handleKeyup, true);
return () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
target.removeEventListener("keydown", handleKeydown);
target.removeEventListener("keyup", handleKeyup);
};
};
/**
* 注册键盘快捷键
* @param {*} targetKeys
* @param {*} fn
* @param {*} target
* @returns
*/
export const shortcutRegister = (targetKeys = [], fn, target = document) => {
return shortcutListener((curkeys) => {
if (
targetKeys.length > 0 &&
isSameSet(new Set(targetKeys), new Set(curkeys))
) {
fn();
}
}, target);
};
/**
* 注册连续快捷键
* @param {*} targetKeys
* @param {*} fn
* @param {*} step
* @param {*} timeout
* @param {*} target
* @returns
*/
export const stepShortcutRegister = (
targetKeys = [],
fn,
step = 3,
timeout = 500,
target = document
) => {
let count = 0;
let pre = Date.now();
let timer;
return shortcutListener((curkeys, allkeys) => {
timer && clearTimeout(timer);
timer = setTimeout(() => {
clearTimeout(timer);
count = 0;
}, timeout);
if (targetKeys.length > 0 && curkeys.length === 0) {
const now = Date.now();
if (
(count === 0 || now - pre < timeout) &&
isSameSet(new Set(targetKeys), new Set(allkeys))
) {
count++;
if (count === step) {
count = 0;
fn();
}
} else {
count = 0;
}
pre = now;
}
}, target);
};

161
src/libs/storage.js Normal file
View File

@@ -0,0 +1,161 @@
import {
STOKEY_SETTING,
STOKEY_RULES,
STOKEY_WORDS,
STOKEY_FAB,
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";
async function set(key, val) {
if (isExt) {
await browser.storage.local.set({ [key]: val });
} else if (isGm) {
await (window.KISS_GM || GM).setValue(key, val);
} else {
window.localStorage.setItem(key, val);
}
}
async function get(key) {
if (isExt) {
const val = await browser.storage.local.get([key]);
return val[key];
} else if (isGm) {
const val = await (window.KISS_GM || GM).getValue(key);
return val;
}
return window.localStorage.getItem(key);
}
async function del(key) {
if (isExt) {
await browser.storage.local.remove([key]);
} else if (isGm) {
await (window.KISS_GM || GM).deleteValue(key);
} else {
window.localStorage.removeItem(key);
}
}
async function setObj(key, obj) {
await set(key, JSON.stringify(obj));
}
async function trySetObj(key, obj) {
if (!(await get(key))) {
await setObj(key, obj);
}
}
async function getObj(key) {
const val = await get(key);
return val && JSON.parse(val);
}
async function putObj(key, obj) {
const cur = (await getObj(key)) ?? {};
await setObj(key, { ...cur, ...obj });
}
/**
* 对storage的封装
*/
export const storage = {
get,
set,
del,
setObj,
trySetObj,
getObj,
putObj,
// onChanged,
};
/**
* 设置信息
*/
export const getSetting = () => getObj(STOKEY_SETTING);
export const getSettingWithDefault = async () => ({
...DEFAULT_SETTING,
...((await getSetting()) || {}),
});
export const setSetting = (val) => setObj(STOKEY_SETTING, val);
export const updateSetting = (obj) => putObj(STOKEY_SETTING, obj);
/**
* 规则列表
*/
export const getRules = () => getObj(STOKEY_RULES);
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 updateFab = (obj) => putObj(STOKEY_FAB, obj);
/**
* 数据同步
*/
export const getSync = () => getObj(STOKEY_SYNC);
export const getSyncWithDefault = async () => (await getSync()) || DEFAULT_SYNC;
export const updateSync = (obj) => putObj(STOKEY_SYNC, obj);
/**
* 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(err, "init default");
}
};

87
src/libs/subRules.js Normal file
View File

@@ -0,0 +1,87 @@
import { GLOBAL_KEY } from "../config";
import {
getSyncWithDefault,
updateSync,
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 updateSync({ 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(err, `sync subrule error: ${subrules.url}`);
}
}
};
/**
* 根据时间同步所有订阅规则
* @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 updateSync({ subRulesSyncAt: now });
}
} catch (err) {
kissLog(err, "try sync all subrules");
}
};
/**
* 从缓存或远程加载订阅规则
* @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 || [];
};

34
src/libs/svg.js Normal file
View File

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

197
src/libs/sync.js Normal file
View File

@@ -0,0 +1,197 @@
import {
APP_LCNAME,
KV_SETTING_KEY,
KV_RULES_KEY,
KV_WORDS_KEY,
KV_RULES_SHARE_KEY,
KV_SALT_SHARE,
OPT_SYNCTYPE_WEBDAV,
} from "../config";
import {
getSyncWithDefault,
updateSync,
getSettingWithDefault,
getRulesWithDefault,
getWordsWithDefault,
setSetting,
setRules,
setWords,
} from "./storage";
import { apiSyncData } from "../apis";
import { sha256, removeEndchar } from "./utils";
import { createClient, getPatcher } from "webdav";
import { fetchPatcher } from "./fetch";
import { kissLog } from "./log";
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);
};
const syncData = async (key, valueFn) => {
const {
syncType,
syncUrl,
syncUser,
syncKey,
syncMeta = {},
} = await getSyncWithDefault();
if (!syncUrl || !syncKey || (syncType === OPT_SYNCTYPE_WEBDAV && !syncUser)) {
return;
}
let { updateAt = 0, syncAt = 0 } = syncMeta[key] || {};
syncAt === 0 && (updateAt = 0);
const value = await valueFn();
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);
syncMeta[key] = {
updateAt: res.updateAt,
syncAt: Date.now(),
};
await updateSync({ syncMeta });
return { value: JSON.parse(res.value), isNew: res.updateAt > updateAt };
};
/**
* 同步设置
* @returns
*/
const syncSetting = async () => {
const res = await syncData(KV_SETTING_KEY, getSettingWithDefault);
if (res?.isNew) {
await setSetting(res.value);
}
};
export const trySyncSetting = async () => {
try {
await syncSetting();
} catch (err) {
kissLog(err, "sync setting");
}
};
/**
* 同步规则
* @returns
*/
const syncRules = async () => {
const res = await syncData(KV_RULES_KEY, getRulesWithDefault);
if (res?.isNew) {
await setRules(res.value);
}
};
export const trySyncRules = async () => {
try {
await syncRules();
} catch (err) {
kissLog(err, "sync user rules");
}
};
/**
* 同步词汇
* @returns
*/
const syncWords = async () => {
const res = await syncData(KV_WORDS_KEY, getWordsWithDefault);
if (res?.isNew) {
await setWords(res.value);
}
};
export const trySyncWords = async () => {
try {
await syncWords();
} catch (err) {
kissLog(err, "sync fav words");
}
};
/**
* 同步分享规则
* @param {*} param0
* @returns
*/
export const syncShareRules = async ({ rules, syncUrl, syncKey }) => {
const data = {
key: KV_RULES_SHARE_KEY,
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}/rules?psk=${psk}`;
return shareUrl;
};
/**
* 同步个人设置和规则
* @returns
*/
export const syncSettingAndRules = async () => {
await syncSetting();
await syncRules();
await syncWords();
};
export const trySyncSettingAndRules = async () => {
await trySyncSetting();
await trySyncRules();
await trySyncWords();
};

12
src/libs/touch.js Normal file
View File

@@ -0,0 +1,12 @@
export function touchTapListener(fn, touchsLength) {
const handleTouchend = (e) => {
if (e.touches.length === touchsLength) {
fn();
}
};
document.addEventListener("touchstart", handleTouchend);
return () => {
document.removeEventListener("touchstart", handleTouchend);
};
}

563
src/libs/translator.js Normal file
View File

@@ -0,0 +1,563 @@
import { createRoot } from "react-dom/client";
import {
APP_LCNAME,
TRANS_MIN_LENGTH,
TRANS_MAX_LENGTH,
MSG_TRANS_CURRULE,
MSG_INJECT_JS,
MSG_INJECT_CSS,
OPT_STYLE_DASHLINE,
OPT_STYLE_FUZZY,
SHADOW_KEY,
OPT_TIMING_PAGESCROLL,
OPT_TIMING_PAGEOPEN,
OPT_TIMING_MOUSEOVER,
DEFAULT_TRANS_APIS,
DEFAULT_FETCH_LIMIT,
DEFAULT_FETCH_INTERVAL,
} from "../config";
import Content from "../views/Content";
import { updateFetchPool, clearFetchPool } from "./fetch";
import { debounce, genEventName, getHtmlText } from "./utils";
import { runFixer } from "./webfix";
import { apiTranslate } from "../apis";
import { sendBgMsg } from "./msg";
import { isExt } from "./client";
import { injectInlineJs, injectInternalCss } from "./injector";
import { kissLog } from "./log";
import interpreter from "./interpreter";
/**
* 翻译类
*/
export class Translator {
_rule = {};
_setting = {};
_rootNodes = new Set();
_tranNodes = new Map();
_skipNodeNames = [
APP_LCNAME,
"style",
"svg",
"img",
"audio",
"video",
"textarea",
"input",
"button",
"select",
"option",
"head",
"script",
"iframe",
];
_eventName = genEventName();
_mouseoverNode = null;
_keepSelector = "";
_terms = [];
_docTitle = "";
// 显示
_interseObserver = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
observer.unobserve(entry.target);
this._render(entry.target);
}
});
},
{
threshold: 0.1,
}
);
// 变化
_mutaObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (
!this._skipNodeNames.includes(mutation.target.localName) &&
mutation.addedNodes.length > 0
) {
const nodes = Array.from(mutation.addedNodes).filter((node) => {
if (
this._skipNodeNames.includes(node.localName) ||
node.id === APP_LCNAME
) {
return false;
}
return true;
});
if (nodes.length > 0) {
// const rootNode = mutation.target.getRootNode();
// todo
this._reTranslate();
}
}
});
});
// 插入 shadowroot
_overrideAttachShadow = () => {
const _this = this;
const _attachShadow = HTMLElement.prototype.attachShadow;
HTMLElement.prototype.attachShadow = function () {
_this._reTranslate();
return _attachShadow.apply(this, arguments);
};
};
_updatePool(translator) {
if (!translator) {
return;
}
const {
fetchInterval = DEFAULT_FETCH_INTERVAL,
fetchLimit = DEFAULT_FETCH_LIMIT,
} = this._setting.transApis[translator] || {};
updateFetchPool(fetchInterval, fetchLimit);
}
constructor(rule, setting) {
this._overrideAttachShadow();
this._setting = setting;
this._rule = rule;
this._keepSelector = rule.keepSelector || "";
this._terms = (rule.terms || "")
.split(/\n|;/)
.map((item) => item.split(",").map((item) => item.trim()))
.filter(([term]) => Boolean(term));
this._updatePool(rule.translator);
if (rule.transOpen === "true") {
this._register();
}
}
get setting() {
return this._setting;
}
get eventName() {
return this._eventName;
}
get rule() {
// console.log("get rule", this._rule);
return this._rule;
}
set rule(rule) {
// console.log("set rule", rule);
this._rule = rule;
// 广播消息
const eventName = this._eventName;
window.dispatchEvent(
new CustomEvent(eventName, {
detail: {
action: MSG_TRANS_CURRULE,
args: rule,
},
})
);
}
updateRule = (obj) => {
this.rule = { ...this.rule, ...obj };
this._updatePool(obj.translator);
};
toggle = () => {
if (this.rule.transOpen === "true") {
this.rule = { ...this.rule, transOpen: "false" };
this._unRegister();
} else {
this.rule = { ...this.rule, transOpen: "true" };
this._register();
}
};
toggleStyle = () => {
const textStyle =
this.rule.textStyle === OPT_STYLE_FUZZY
? OPT_STYLE_DASHLINE
: OPT_STYLE_FUZZY;
this.rule = { ...this.rule, textStyle };
};
translateText = async (text) => {
const { translator, fromLang, toLang } = this._rule;
const apiSetting =
this._setting.transApis?.[translator] || DEFAULT_TRANS_APIS[translator];
const [trText] = await apiTranslate({
text,
translator,
fromLang,
toLang,
apiSetting,
});
return trText;
};
_querySelectorAll = (selector, node) => {
try {
return Array.from(node.querySelectorAll(selector));
} catch (err) {
kissLog(selector, "querySelectorAll err");
}
return [];
};
_queryFilter = (selector, rootNode) => {
return this._querySelectorAll(selector, rootNode).filter(
(node) => this._queryFilter(selector, node).length === 0
);
};
_queryShadowNodes = (selector, rootNode) => {
this._rootNodes.add(rootNode);
this._queryFilter(selector, rootNode).forEach((item) => {
if (!this._tranNodes.has(item)) {
this._tranNodes.set(item, "");
}
});
Array.from(rootNode.querySelectorAll("*"))
.map((item) => item.shadowRoot)
.filter(Boolean)
.forEach((item) => {
this._queryShadowNodes(selector, item);
});
};
_queryNodes = (rootNode = document) => {
// const childRoots = Array.from(rootNode.querySelectorAll("*"))
// .map((item) => item.shadowRoot)
// .filter(Boolean);
// const childNodes = childRoots.map((item) => this._queryNodes(item));
// const nodes = Array.from(rootNode.querySelectorAll(this.rule.selector));
// return nodes.concat(childNodes).flat();
this._rootNodes.add(rootNode);
this._rule.selector
.split(";")
.map((item) => item.trim())
.filter(Boolean)
.forEach((selector) => {
if (selector.includes(SHADOW_KEY)) {
const [outSelector, inSelector] = selector
.split(SHADOW_KEY)
.map((item) => item.trim());
if (outSelector && inSelector) {
const outNodes = this._querySelectorAll(outSelector, rootNode);
outNodes.forEach((outNode) => {
if (outNode.shadowRoot) {
// this._rootNodes.add(outNode.shadowRoot);
// this._queryFilter(inSelector, outNode.shadowRoot).forEach(
// (item) => {
// if (!this._tranNodes.has(item)) {
// this._tranNodes.set(item, "");
// }
// }
// );
this._queryShadowNodes(inSelector, outNode.shadowRoot);
}
});
}
} else {
this._queryFilter(selector, rootNode).forEach((item) => {
if (!this._tranNodes.has(item)) {
this._tranNodes.set(item, "");
}
});
}
});
};
_register = () => {
const { fromLang, toLang, injectJs, injectCss, fixerSelector, fixerFunc } =
this._rule;
if (fromLang === toLang) {
return;
}
// webfix
if (fixerSelector && fixerFunc !== "-") {
runFixer(fixerSelector, fixerFunc);
}
// 注入用户JS/CSS
if (isExt) {
injectJs && sendBgMsg(MSG_INJECT_JS, injectJs);
injectCss && sendBgMsg(MSG_INJECT_CSS, injectCss);
} else {
injectJs && injectInlineJs(injectJs);
injectCss && injectInternalCss(injectCss);
}
// 搜索节点
this._queryNodes();
this._rootNodes.forEach((node) => {
// 监听节点变化;
this._mutaObserver.observe(node, {
childList: true,
subtree: true,
// characterData: true,
});
});
if (
!this._rule.transTiming ||
this._rule.transTiming === OPT_TIMING_PAGESCROLL
) {
// 监听节点显示
this._tranNodes.forEach((_, node) => {
this._interseObserver.observe(node);
});
} else if (this._rule.transTiming === OPT_TIMING_PAGEOPEN) {
// 全文直接翻译
this._tranNodes.forEach((_, node) => {
this._render(node);
});
} else {
// 监听鼠标悬停
window.addEventListener("keydown", this._handleKeydown);
this._tranNodes.forEach((_, node) => {
node.addEventListener("mouseenter", this._handleMouseover);
node.addEventListener("mouseleave", this._handleMouseout);
});
}
// 翻译页面标题
if (this._rule.transTitle === "true" && !this._docTitle) {
const title = document.title;
this._docTitle = title;
this.translateText(title).then((trText) => {
document.title = `${trText} | ${title}`;
});
}
};
_handleMouseover = (e) => {
// console.log("mouseenter", e);
if (!this._tranNodes.has(e.target)) {
return;
}
const key = this._rule.transTiming.slice(3);
if (this._rule.transTiming === OPT_TIMING_MOUSEOVER || e[key]) {
e.target.removeEventListener("mouseenter", this._handleMouseover);
e.target.removeEventListener("mouseleave", this._handleMouseout);
this._render(e.target);
} else {
this._mouseoverNode = e.target;
}
};
_handleMouseout = (e) => {
// console.log("mouseleave", e);
if (!this._tranNodes.has(e.target)) {
return;
}
this._mouseoverNode = null;
};
_handleKeydown = (e) => {
// console.log("keydown", e);
const key = this._rule.transTiming.slice(3);
if (e[key] && this._mouseoverNode) {
this._mouseoverNode.removeEventListener(
"mouseenter",
this._handleMouseover
);
this._mouseoverNode.removeEventListener(
"mouseleave",
this._handleMouseout
);
const node = this._mouseoverNode;
this._render(node);
this._mouseoverNode = null;
}
};
_unRegister = () => {
// 恢复页面标题
if (this._docTitle) {
document.title = this._docTitle;
this._docTitle = "";
}
// 解除节点变化监听
this._mutaObserver.disconnect();
// 解除节点显示监听
// this._interseObserver.disconnect();
// 移除键盘监听
window.removeEventListener("keydown", this._handleKeydown);
const { transRemoveHook } = this._rule;
this._tranNodes.forEach((innerHTML, node) => {
if (
!this._rule.transTiming ||
this._rule.transTiming === OPT_TIMING_PAGESCROLL
) {
// 解除节点显示监听
this._interseObserver.unobserve(node);
} else if (this._rule.transTiming !== OPT_TIMING_PAGEOPEN) {
// 移除鼠标悬停监听
// node.style.pointerEvents = "none";
node.removeEventListener("mouseenter", this._handleMouseover);
node.removeEventListener("mouseleave", this._handleMouseout);
}
// 移除/恢复元素
if (innerHTML) {
if (this._rule.transOnly === "true") {
node.innerHTML = innerHTML;
} else {
node.querySelector(APP_LCNAME)?.remove();
}
// 钩子函数
if (transRemoveHook?.trim()) {
interpreter.run(`exports.transRemoveHook = ${transRemoveHook}`);
interpreter.exports.transRemoveHook(node);
}
}
});
// 移除用户JS/CSS
this._removeInjector();
// 清空节点集合
this._rootNodes.clear();
this._tranNodes.clear();
// 清空任务池
clearFetchPool();
};
_removeInjector = () => {
document
.querySelectorAll(`[data-source^="KISS-Calendar"]`)
?.forEach((el) => el.remove());
};
_reTranslate = debounce(() => {
if (this._rule.transOpen === "true") {
window.removeEventListener("keydown", this._handleKeydown);
this._mutaObserver.disconnect();
this._interseObserver.disconnect();
this._removeInjector();
this._register();
}
}, this._setting.transInterval);
_invalidLength = (q) =>
!q ||
q.length < (this._setting.minLength ?? TRANS_MIN_LENGTH) ||
q.length > (this._setting.maxLength ?? TRANS_MAX_LENGTH);
_render = (el) => {
let traEl = el.querySelector(APP_LCNAME);
// 已翻译
if (traEl) {
if (this._rule.transOnly === "true") {
return;
}
const preText = getHtmlText(this._tranNodes.get(el));
const curText = getHtmlText(el.innerHTML, APP_LCNAME);
if (preText === curText) {
return;
}
traEl.remove();
}
// 缓存已翻译元素
this._tranNodes.set(el, el.innerHTML);
let q = el.innerText.trim();
const keeps = [];
// 翻译开始钩子函数
const { transStartHook } = this._rule;
if (transStartHook?.trim()) {
interpreter.run(`exports.transStartHook = ${transStartHook}`);
interpreter.exports.transStartHook(el, q);
}
// 保留元素
const keepSelector = this._keepSelector.trim();
if (keepSelector) {
let text = "";
el.childNodes.forEach((child) => {
if (child.nodeType === 1 && child.matches(keepSelector)) {
if (child.nodeName === "IMG") {
child.style.cssText += `width: ${child.width}px;`;
child.style.cssText += `height: ${child.height}px;`;
}
text += `[${keeps.length}]`;
keeps.push(child.outerHTML);
} else if (child.nodeType === 1 || child.nodeType === 3) {
text += child.textContent;
}
});
if (keeps.length > 0) {
// textContent会保留些无用的换行符严重影响翻译质量
if (q.includes("\n")) {
q = text;
} else {
q = text.replaceAll("\n", " ");
}
}
}
// 太长或太短
if (this._invalidLength(q.replace(/\[(\d+)\]/g, "").trim())) {
return;
}
// 专业术语
if (this._terms.length > 0) {
for (const term of this._terms) {
const re = new RegExp(term[0], "g");
q = q.replace(re, (t) => {
const text = `[${keeps.length}]`;
keeps.push(`<i class="kiss-trem">${term[1] || t}</i>`);
return text;
});
}
}
// 附加样式
const { selectStyle, parentStyle } = this._rule;
el.style.cssText += selectStyle;
if (el.parentElement) {
el.parentElement.style.cssText += parentStyle;
}
// 插入译文节点
traEl = document.createElement(APP_LCNAME);
traEl.style.visibility = "visible";
// if (this._rule.transOnly === "true") {
// el.innerHTML = "";
// }
el.appendChild(traEl);
// 渲染译文节点
const root = createRoot(traEl);
root.render(<Content q={q} keeps={keeps} translator={this} $el={el} />);
};
}

269
src/libs/utils.js Normal file
View File

@@ -0,0 +1,269 @@
/**
* 限制数字大小
* @param {*} num
* @param {*} min
* @param {*} max
* @returns
*/
export const limitNumber = (num, min = 0, max = 100) => {
const number = parseInt(num);
if (Number.isNaN(number) || number < min) {
return min;
} else if (number > max) {
return max;
}
return number;
};
export const limitFloat = (num, min = 0, max = 100) => {
const number = parseFloat(num);
if (Number.isNaN(number) || number < min) {
return min;
} else if (number > max) {
return max;
}
return number;
};
/**
* 匹配是否为数组中的值
* @param {*} arr
* @param {*} val
* @returns
*/
export const matchValue = (arr, val) => {
if (arr.length === 0 || arr.includes(val)) {
return val;
}
return arr[0];
};
/**
* 等待
* @param {*} delay
* @returns
*/
export const sleep = (delay) =>
new Promise((resolve) => {
const timer = setTimeout(() => {
clearTimeout(timer);
resolve();
}, delay);
});
/**
* 防抖函数
* @param {*} func
* @param {*} delay
* @returns
*/
export const debounce = (func, delay = 200) => {
let timer = null;
return (...args) => {
timer && clearTimeout(timer);
timer = setTimeout(() => {
func(...args);
clearTimeout(timer);
timer = null;
}, delay);
};
};
/**
* 节流函数
* @param {*} func
* @param {*} delay
* @returns
*/
export const throttle = (func, delay = 200) => {
let timer = null;
let cache = null;
return (...args) => {
if (!timer) {
func(...args);
cache = null;
timer = setTimeout(() => {
if (cache) {
func(...cache);
cache = null;
}
clearTimeout(timer);
timer = null;
}, delay);
} else {
cache = args;
}
};
};
/**
* 判断字符串全是某个字符
* @param {*} s
* @param {*} c
* @param {*} i
* @returns
*/
export const isAllchar = (s, c, i = 0) => {
while (i < s.length) {
if (s[i] !== c) {
return false;
}
i++;
}
return true;
};
/**
* 字符串通配符(*)匹配
* @param {*} s
* @param {*} p
* @returns
*/
export const isMatch = (s, p) => {
if (s.length === 0 || p.length === 0) {
return false;
}
p = "*" + p + "*";
let [sIndex, pIndex] = [0, 0];
let [sRecord, pRecord] = [-1, -1];
while (sIndex < s.length && pRecord < p.length) {
if (p[pIndex] === "*") {
pIndex++;
[sRecord, pRecord] = [sIndex, pIndex];
} else if (s[sIndex] === p[pIndex]) {
sIndex++;
pIndex++;
} else if (sRecord + 1 < s.length) {
sRecord++;
[sIndex, pIndex] = [sRecord, pRecord];
} else {
return false;
}
}
if (p.length === pIndex) {
return true;
}
return isAllchar(p, "*", pIndex);
};
/**
* 类型检查
* @param {*} o
* @returns
*/
export const type = (o) => {
const s = Object.prototype.toString.call(o);
return s.match(/\[object (.*?)\]/)[1].toLowerCase();
};
/**
* sha256
* @param {*} text
* @returns
*/
export const sha256 = async (text, salt) => {
const data = new TextEncoder().encode(text + salt);
const digest = await crypto.subtle.digest({ name: "SHA-256" }, data);
return [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
};
/**
* 生成随机事件名称
* @returns
*/
export const genEventName = () => btoa(Math.random()).slice(3, 11);
/**
* 判断两个 Set 是否相同
* @param {*} a
* @param {*} b
* @returns
*/
export const isSameSet = (a, b) => {
const s = new Set([...a, ...b]);
return s.size === a.size && s.size === b.size;
};
/**
* 去掉字符串末尾某个字符
* @param {*} s
* @param {*} c
* @param {*} count
* @returns
*/
export const removeEndchar = (s, c, count = 1) => {
let i = s.length;
while (i > s.length - count && s[i - 1] === c) {
i--;
}
return s.slice(0, i);
};
/**
* 匹配字符串及语言标识
* @param {*} str
* @param {*} sign
* @returns
*/
export const matchInputStr = (str, sign) => {
switch (sign) {
case "//":
return str.match(/\/\/([\w-]+)\s+([^]+)/);
case "\\":
return str.match(/\\([\w-]+)\s+([^]+)/);
case "\\\\":
return str.match(/\\\\([\w-]+)\s+([^]+)/);
case ">":
return str.match(/>([\w-]+)\s+([^]+)/);
case ">>":
return str.match(/>>([\w-]+)\s+([^]+)/);
default:
}
return str.match(/\/([\w-]+)\s+([^]+)/);
};
/**
* 判断是否英文单词
* @param {*} str
* @returns
*/
export const isValidWord = (str) => {
const regex = /^[a-zA-Z-]+$/;
return regex.test(str);
};
/**
* blob转为base64
* @param {*} blob
* @returns
*/
export const blobToBase64 = (blob) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
};
/**
* 获取html内的文本
* @param {*} htmlStr
* @param {*} skipTag
* @returns
*/
export const getHtmlText = (htmlStr, skipTag = "") => {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlStr, "text/html");
if (skipTag) {
doc.querySelectorAll(skipTag).forEach((el) => el.remove());
}
return doc.body.innerText.trim();
};

158
src/libs/webfix.js Normal file
View File

@@ -0,0 +1,158 @@
/**
* 修复程序类型
*/
export const FIXER_NONE = "-";
export const FIXER_BR = "br";
export const FIXER_BN = "bn";
export const FIXER_BR_DIV = "brToDiv";
export const FIXER_BN_DIV = "bnToDiv";
export const FIXER_ALL = [
FIXER_NONE,
FIXER_BR,
FIXER_BN,
FIXER_BR_DIV,
FIXER_BN_DIV,
];
/**
* 修复过的标记
*/
const fixedSign = "kiss-fixed";
/**
* 采用 `br` 换行网站的修复函数
* 目标是将 `br` 替换成 `p`
* @param {*} node
* @returns
*/
function brFixer(node, tag = "p") {
if (node.hasAttribute(fixedSign)) {
return;
}
node.setAttribute(fixedSign, "true");
const gapTags = ["BR", "WBR"];
const newlineTags = [
"DIV",
"UL",
"OL",
"LI",
"H1",
"H2",
"H3",
"H4",
"H5",
"H6",
"P",
"HR",
"PRE",
"TABLE",
"BLOCKQUOTE",
];
let html = "";
node.childNodes.forEach(function (child, index) {
if (index === 0) {
html += `<${tag} class="kiss-p">`;
}
if (gapTags.indexOf(child.nodeName) !== -1) {
html += `</${tag}><${tag} class="kiss-p">`;
} else if (newlineTags.indexOf(child.nodeName) !== -1) {
html += `</${tag}>${child.outerHTML}<${tag} class="kiss-p">`;
} else if (child.outerHTML) {
html += child.outerHTML;
} else if (child.textContent) {
html += child.textContent;
}
if (index === node.childNodes.length - 1) {
html += `</${tag}>`;
}
});
node.innerHTML = html;
}
function brDivFixer(node) {
return brFixer(node, "div");
}
/**
* 目标是将 `\n` 替换成 `p`
* @param {*} node
* @returns
*/
function bnFixer(node, tag = "p") {
if (node.hasAttribute(fixedSign)) {
return;
}
node.setAttribute(fixedSign, "true");
node.innerHTML = node.innerHTML
.split("\n")
.map((item) => `<${tag} class="kiss-p">${item || "&nbsp;"}</${tag}>`)
.join("");
}
function bnDivFixer(node) {
return bnFixer(node, "div");
}
/**
* 查找、监听节点,并执行修复函数
* @param {*} selector
* @param {*} fixer
* @param {*} rootSelector
*/
function run(selector, fixer, rootSelector) {
const mutaObserver = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
mutation.addedNodes.forEach(function (addNode) {
if (addNode && addNode.querySelectorAll) {
addNode.querySelectorAll(selector).forEach(function (node) {
fixer(node);
});
}
});
});
});
let rootNodes = [document];
if (rootSelector) {
rootNodes = document.querySelectorAll(rootSelector);
}
rootNodes.forEach(function (rootNode) {
rootNode.querySelectorAll(selector).forEach(function (node) {
fixer(node);
});
mutaObserver.observe(rootNode, {
childList: true,
subtree: true,
});
});
}
/**
* 修复程序映射
*/
const fixerMap = {
[FIXER_BR]: brFixer,
[FIXER_BN]: bnFixer,
[FIXER_BR_DIV]: brDivFixer,
[FIXER_BN_DIV]: bnDivFixer,
};
/**
* 执行fixer
* @param {*} param0
*/
export function runFixer(selector, fixer = "-", rootSelector) {
try {
if (Object.keys(fixerMap).includes(fixer)) {
run(selector, fixerMap[fixer], rootSelector);
}
} catch (err) {
console.error(`[kiss-webfix run]: ${err.message}`);
}
}

10
src/options.js Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import Options from "./views/Options";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<Options />
</React.StrictMode>
);

16
src/popup.js Normal file
View File

@@ -0,0 +1,16 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { SettingProvider } from "./hooks/Setting";
import ThemeProvider from "./hooks/Theme";
import Popup from "./views/Popup";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<SettingProvider>
<ThemeProvider>
<Popup />
</ThemeProvider>
</SettingProvider>
</React.StrictMode>
);

28
src/rules.js Normal file
View File

@@ -0,0 +1,28 @@
import fs from "fs";
import path from "path";
import { BUILTIN_RULES } from "./config/rules";
(() => {
// rules
try {
const data = JSON.stringify(BUILTIN_RULES, null, 2);
const file = path.resolve(
__dirname,
"../build/web/kiss-translator-rules.json"
);
fs.writeFileSync(file, data);
console.info(`Built-in rules generated: ${file}`);
} catch (err) {
console.error(err);
}
// version
try {
var pjson = require("../package.json");
const file = path.resolve(__dirname, "../build/web/version.txt");
fs.writeFileSync(file, pjson.version);
console.info(`Version file generated: ${file}`);
} catch (err) {
console.error(err);
}
})();

3
src/userscript.js Normal file
View File

@@ -0,0 +1,3 @@
import { run } from "./common";
run(true);

View File

@@ -0,0 +1,180 @@
import { useEffect, useMemo, useState } from "react";
import { limitNumber } from "../../libs/utils";
import { isMobile } from "../../libs/mobile";
import { updateFab } from "../../libs/storage";
import { debounce } from "../../libs/utils";
import Paper from "@mui/material/Paper";
const getEdgePosition = ({
x: left,
y: top,
width,
height,
windowWidth,
windowHeight,
hover,
}) => {
const right = windowWidth - left - width;
const bottom = windowHeight - top - height;
const min = Math.min(left, top, right, bottom);
switch (min) {
case right:
left = hover ? windowWidth - width : windowWidth - width / 2;
break;
case left:
left = hover ? 0 : -width / 2;
break;
case bottom:
top = hover ? windowHeight - height : windowHeight - height / 2;
break;
default:
top = hover ? 0 : -height / 2;
}
return { x: left, y: top };
};
function DraggableWrapper({ children, usePaper, ...props }) {
if (usePaper) {
return (
<Paper {...props} elevation={4}>
{children}
</Paper>
);
}
return <div {...props}>{children}</div>;
}
export default function Draggable({
windowSize: { w: windowWidth, h: windowHeight },
width,
height,
left,
top,
show,
snapEdge,
onStart,
onMove,
handler,
children,
usePaper,
}) {
const [hover, setHover] = useState(false);
const [origin, setOrigin] = useState(null);
const [position, setPosition] = useState({ x: left, y: top });
const setFabPosition = useMemo(() => debounce(updateFab, 500), []);
const handlePointerDown = (e) => {
!isMobile && e.target.setPointerCapture(e.pointerId);
onStart && onStart();
const { x, y } = position;
const { clientX, clientY } = isMobile ? e.targetTouches[0] : e;
setOrigin({ x, y, clientX, clientY });
};
const handlePointerMove = (e) => {
onMove && onMove();
const { clientX, clientY } = isMobile ? e.targetTouches[0] : e;
if (origin) {
const dx = clientX - origin.clientX;
const dy = clientY - origin.clientY;
let x = origin.x + dx;
let y = origin.y + dy;
x = limitNumber(x, -width / 2, windowWidth - width / 2);
y = limitNumber(y, 0, windowHeight - height / 2);
setPosition({ x, y });
}
};
const handlePointerUp = (e) => {
e.stopPropagation();
setOrigin(null);
};
const handleClick = (e) => {
e.stopPropagation();
};
const handleMouseEnter = (e) => {
e.stopPropagation();
setHover(true);
};
const handleMouseLeave = (e) => {
e.stopPropagation();
setHover(false);
};
useEffect(() => {
if (!snapEdge || !!origin) {
return;
}
setPosition((pre) => {
const edgePosition = getEdgePosition({
...pre,
width,
height,
windowWidth,
windowHeight,
hover,
});
setFabPosition(edgePosition);
return edgePosition;
});
}, [
origin,
hover,
width,
height,
windowWidth,
windowHeight,
snapEdge,
setFabPosition,
]);
const opacity = useMemo(() => {
if (snapEdge) {
return hover || origin ? 1 : 0.2;
}
return origin ? 0.8 : 1;
}, [origin, snapEdge, hover]);
const touchProps = isMobile
? {
onTouchStart: handlePointerDown,
onTouchMove: handlePointerMove,
onTouchEnd: handlePointerUp,
}
: {
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
};
return (
<DraggableWrapper
usePaper={usePaper}
style={{
opacity,
position: "fixed",
left: position.x,
top: position.y,
zIndex: 2147483647,
display: show ? "block" : "none",
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
>
<div
style={{
touchAction: "none",
}}
{...touchProps}
>
{handler}
</div>
<div>{children}</div>
</DraggableWrapper>
);
}

234
src/views/Action/index.js Normal file
View File

@@ -0,0 +1,234 @@
import Fab from "@mui/material/Fab";
import TranslateIcon from "@mui/icons-material/Translate";
import ThemeProvider from "../../hooks/Theme";
import Draggable from "./Draggable";
import { useEffect, useState, useMemo, useCallback } from "react";
import { SettingProvider } from "../../hooks/Setting";
import Popup from "../Popup";
import { debounce } from "../../libs/utils";
import { isGm } from "../../libs/client";
import Header from "../Popup/Header";
import Box from "@mui/material/Box";
import Divider from "@mui/material/Divider";
import {
DEFAULT_SHORTCUTS,
OPT_SHORTCUT_TRANSLATE,
OPT_SHORTCUT_STYLE,
OPT_SHORTCUT_POPUP,
OPT_SHORTCUT_SETTING,
MSG_TRANS_TOGGLE,
MSG_TRANS_TOGGLE_STYLE,
} from "../../config";
import { shortcutRegister } from "../../libs/shortcut";
import { sendIframeMsg } from "../../libs/iframe";
import { kissLog } from "../../libs/log";
import { getI18n } from "../../hooks/I18n";
export default function Action({ translator, fab }) {
const fabWidth = 40;
const [showPopup, setShowPopup] = useState(false);
const [windowSize, setWindowSize] = useState({
w: window.innerWidth,
h: window.innerHeight,
});
const [moved, setMoved] = useState(false);
const handleWindowResize = useMemo(
() =>
debounce(() => {
setWindowSize({
w: window.innerWidth,
h: window.innerHeight,
});
}),
[]
);
const handleWindowClick = (e) => {
setShowPopup(false);
};
const handleStart = useCallback(() => {
setMoved(false);
}, []);
const handleMove = useCallback(() => {
setMoved(true);
}, []);
useEffect(() => {
if (!isGm) {
return;
}
// 注册快捷键
const shortcuts = translator.setting.shortcuts || DEFAULT_SHORTCUTS;
const clearShortcuts = [
shortcutRegister(shortcuts[OPT_SHORTCUT_TRANSLATE], () => {
translator.toggle();
sendIframeMsg(MSG_TRANS_TOGGLE);
setShowPopup(false);
}),
shortcutRegister(shortcuts[OPT_SHORTCUT_STYLE], () => {
translator.toggleStyle();
sendIframeMsg(MSG_TRANS_TOGGLE_STYLE);
setShowPopup(false);
}),
shortcutRegister(shortcuts[OPT_SHORTCUT_POPUP], () => {
setShowPopup((pre) => !pre);
}),
shortcutRegister(shortcuts[OPT_SHORTCUT_SETTING], () => {
window.open(process.env.REACT_APP_OPTIONSPAGE, "_blank");
}),
];
return () => {
clearShortcuts.forEach((fn) => {
fn();
});
};
}, [translator]);
useEffect(() => {
if (!isGm) {
return;
}
// 注册菜单
try {
const menuCommandIds = [];
const { contextMenuType, uiLang } = translator.setting;
contextMenuType !== 0 &&
menuCommandIds.push(
GM.registerMenuCommand(
getI18n(uiLang, "translate_switch"),
(event) => {
translator.toggle();
sendIframeMsg(MSG_TRANS_TOGGLE);
setShowPopup(false);
},
"Q"
),
GM.registerMenuCommand(
getI18n(uiLang, "toggle_style"),
(event) => {
translator.toggleStyle();
sendIframeMsg(MSG_TRANS_TOGGLE_STYLE);
setShowPopup(false);
},
"C"
),
GM.registerMenuCommand(
getI18n(uiLang, "open_menu"),
(event) => {
setShowPopup((pre) => !pre);
},
"K"
),
GM.registerMenuCommand(
getI18n(uiLang, "open_setting"),
(event) => {
window.open(process.env.REACT_APP_OPTIONSPAGE, "_blank");
},
"O"
)
);
return () => {
menuCommandIds.forEach((id) => {
GM.unregisterMenuCommand(id);
});
};
} catch (err) {
kissLog(err, "registerMenuCommand");
}
}, [translator]);
useEffect(() => {
window.addEventListener("resize", handleWindowResize);
return () => {
window.removeEventListener("resize", handleWindowResize);
};
}, [handleWindowResize]);
useEffect(() => {
window.addEventListener("click", handleWindowClick);
return () => {
window.removeEventListener("click", handleWindowClick);
};
}, []);
const popProps = useMemo(() => {
const width = Math.min(windowSize.w, 300);
const height = Math.min(windowSize.h, 442);
const left = (windowSize.w - width) / 2;
const top = (windowSize.h - height) / 2;
return {
windowSize,
width,
height,
left,
top,
};
}, [windowSize]);
const fabProps = {
windowSize,
width: fabWidth,
height: fabWidth,
left: fab.x ?? -fabWidth,
top: fab.y ?? windowSize.h / 2,
};
return (
<SettingProvider>
<ThemeProvider>
<Draggable
key="pop"
{...popProps}
show={showPopup}
onStart={handleStart}
onMove={handleMove}
usePaper
handler={
<Box style={{ cursor: "move" }}>
<Header setShowPopup={setShowPopup} />
<Divider />
</Box>
}
>
{showPopup && (
<Popup setShowPopup={setShowPopup} translator={translator} />
)}
</Draggable>
<Draggable
key="fab"
snapEdge
{...fabProps}
show={fab.isHide ? false : !showPopup}
onStart={handleStart}
onMove={handleMove}
handler={
<Fab
size="small"
color="primary"
onClick={(e) => {
if (!moved) {
setShowPopup((pre) => !pre);
}
}}
>
<TranslateIcon
sx={{
width: 24,
height: 24,
}}
/>
</Fab>
}
/>
</ThemeProvider>
</SettingProvider>
);
}

View File

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

188
src/views/Content/index.js Normal file
View File

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

View File

@@ -0,0 +1,20 @@
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import ReactMarkdown from "react-markdown";
import { useI18n, useI18nMd } from "../../hooks/I18n";
export default function About() {
const i18n = useI18n();
const [data, loading, error] = useI18nMd("about_md");
return (
<Box>
{loading ? (
<center>
<CircularProgress />
</center>
) : (
<ReactMarkdown children={error ? i18n("about_md_local") : data} />
)}
</Box>
);
}

474
src/views/Options/Apis.js Normal file
View File

@@ -0,0 +1,474 @@
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import LoadingButton from "@mui/lab/LoadingButton";
import MenuItem from "@mui/material/MenuItem";
import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
import {
OPT_TRANS_ALL,
OPT_TRANS_MICROSOFT,
OPT_TRANS_DEEPL,
OPT_TRANS_DEEPLX,
OPT_TRANS_DEEPLFREE,
OPT_TRANS_BAIDU,
OPT_TRANS_TENCENT,
OPT_TRANS_VOLCENGINE,
OPT_TRANS_OPENAI,
OPT_TRANS_OPENAI_2,
OPT_TRANS_OPENAI_3,
OPT_TRANS_GEMINI,
OPT_TRANS_GEMINI_2,
OPT_TRANS_CLAUDE,
OPT_TRANS_CLOUDFLAREAI,
OPT_TRANS_OLLAMA,
OPT_TRANS_OLLAMA_2,
OPT_TRANS_OLLAMA_3,
OPT_TRANS_CUSTOMIZE,
OPT_TRANS_CUSTOMIZE_2,
OPT_TRANS_CUSTOMIZE_3,
OPT_TRANS_CUSTOMIZE_4,
OPT_TRANS_CUSTOMIZE_5,
OPT_TRANS_NIUTRANS,
URL_NIUTRANS_REG,
DEFAULT_FETCH_LIMIT,
DEFAULT_FETCH_INTERVAL,
DEFAULT_HTTP_TIMEOUT,
} from "../../config";
import { useState } from "react";
import { useI18n } from "../../hooks/I18n";
import Typography from "@mui/material/Typography";
import Accordion from "@mui/material/Accordion";
import AccordionSummary from "@mui/material/AccordionSummary";
import AccordionDetails from "@mui/material/AccordionDetails";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import Alert from "@mui/material/Alert";
import { useAlert } from "../../hooks/Alert";
import { useApi } from "../../hooks/Api";
import { apiTranslate } from "../../apis";
import Box from "@mui/material/Box";
import Link from "@mui/material/Link";
import { limitNumber, limitFloat } from "../../libs/utils";
function TestButton({ translator, api }) {
const i18n = useI18n();
const alert = useAlert();
const [loading, setLoading] = useState(false);
const handleApiTest = async () => {
try {
setLoading(true);
const [text] = await apiTranslate({
translator,
text: "hello world",
fromLang: "en",
toLang: "zh-CN",
apiSetting: api,
useCache: false,
});
if (!text) {
throw new Error("empty result");
}
alert.success(i18n("test_success"));
} catch (err) {
// alert.error(`${i18n("test_failed")}: ${err.message}`);
let msg = err.message;
try {
msg = JSON.stringify(JSON.parse(err.message), null, 2);
} catch (err) {
// skip
}
alert.error(
<>
<div>{i18n("test_failed")}</div>
{msg === err.message ? (
<div
style={{
maxWidth: 400,
}}
>
{msg}
</div>
) : (
<pre
style={{
maxWidth: 400,
overflow: "auto",
}}
>
{msg}
</pre>
)}
</>
);
} finally {
setLoading(false);
}
};
return (
<LoadingButton
size="small"
variant="contained"
onClick={handleApiTest}
loading={loading}
>
{i18n("click_test")}
</LoadingButton>
);
}
function ApiFields({ translator, api, updateApi, resetApi }) {
const i18n = useI18n();
const {
url = "",
key = "",
model = "",
systemPrompt = "",
userPrompt = "",
think = false,
thinkIgnore = "",
fetchLimit = DEFAULT_FETCH_LIMIT,
fetchInterval = DEFAULT_FETCH_INTERVAL,
httpTimeout = DEFAULT_HTTP_TIMEOUT,
dictNo = "",
memoryNo = "",
reqHook = "",
resHook = "",
temperature = 0,
maxTokens = 256,
apiName = "",
isDisabled = false,
} = api;
const handleChange = (e) => {
let { name, value } = e.target;
switch (name) {
case "fetchLimit":
value = limitNumber(value, 1, 100);
break;
case "fetchInterval":
value = limitNumber(value, 0, 5000);
break;
case "httpTimeout":
value = limitNumber(value, 5000, 30000);
break;
case "temperature":
value = limitFloat(value, 0, 2);
break;
case "maxTokens":
value = limitNumber(value, 0, 2 ** 15);
break;
default:
}
updateApi({
[name]: value,
});
};
const builtinTranslators = [
OPT_TRANS_MICROSOFT,
OPT_TRANS_DEEPLFREE,
OPT_TRANS_BAIDU,
OPT_TRANS_TENCENT,
OPT_TRANS_VOLCENGINE,
];
const mulkeysTranslators = [
OPT_TRANS_DEEPL,
OPT_TRANS_OPENAI,
OPT_TRANS_OPENAI_2,
OPT_TRANS_OPENAI_3,
OPT_TRANS_GEMINI,
OPT_TRANS_GEMINI_2,
OPT_TRANS_CLAUDE,
OPT_TRANS_CLOUDFLAREAI,
OPT_TRANS_OLLAMA,
OPT_TRANS_OLLAMA_2,
OPT_TRANS_OLLAMA_3,
OPT_TRANS_NIUTRANS,
OPT_TRANS_CUSTOMIZE,
OPT_TRANS_CUSTOMIZE_2,
OPT_TRANS_CUSTOMIZE_3,
OPT_TRANS_CUSTOMIZE_4,
OPT_TRANS_CUSTOMIZE_5,
];
const keyHelper =
translator === OPT_TRANS_NIUTRANS ? (
<>
{i18n("mulkeys_help")}
<Link href={URL_NIUTRANS_REG} target="_blank">
{i18n("reg_niutrans")}
</Link>
</>
) : mulkeysTranslators.includes(translator) ? (
i18n("mulkeys_help")
) : (
""
);
return (
<Stack spacing={3}>
<TextField
size="small"
label={i18n("api_name")}
name="apiName"
value={apiName}
onChange={handleChange}
/>
{!builtinTranslators.includes(translator) && (
<>
<TextField
size="small"
label={"URL"}
name="url"
value={url}
onChange={handleChange}
multiline={translator === OPT_TRANS_DEEPLX}
maxRows={10}
helperText={
translator === OPT_TRANS_DEEPLX ? i18n("mulkeys_help") : ""
}
/>
<TextField
size="small"
label={"KEY"}
name="key"
value={key}
onChange={handleChange}
multiline={mulkeysTranslators.includes(translator)}
maxRows={10}
helperText={keyHelper}
/>
</>
)}
{(translator.startsWith(OPT_TRANS_OPENAI) ||
translator.startsWith(OPT_TRANS_OLLAMA) ||
translator === OPT_TRANS_CLAUDE ||
translator.startsWith(OPT_TRANS_GEMINI)) && (
<>
<TextField
size="small"
label={"MODEL"}
name="model"
value={model}
onChange={handleChange}
/>
<TextField
size="small"
label={"SYSTEM PROMPT"}
name="systemPrompt"
value={systemPrompt}
onChange={handleChange}
multiline
maxRows={10}
/>
<TextField
size="small"
label={"USER PROMPT"}
name="userPrompt"
value={userPrompt}
onChange={handleChange}
multiline
maxRows={10}
/>
</>
)}
{translator.startsWith(OPT_TRANS_OLLAMA) && (
<>
<TextField
select
size="small"
name="think"
value={think}
label={i18n("if_think")}
onChange={handleChange}
>
<MenuItem value={false}>{i18n("nothink")}</MenuItem>
<MenuItem value={true}>{i18n("think")}</MenuItem>
</TextField>
<TextField
size="small"
label={i18n("think_ignore")}
name="thinkIgnore"
value={thinkIgnore}
onChange={handleChange}
/>
</>
)}
{(translator.startsWith(OPT_TRANS_OPENAI) ||
translator === OPT_TRANS_CLAUDE ||
translator === OPT_TRANS_GEMINI ||
translator === OPT_TRANS_GEMINI_2) && (
<>
<TextField
size="small"
label={"Temperature"}
type="number"
name="temperature"
value={temperature}
onChange={handleChange}
/>
<TextField
size="small"
label={"Max Tokens"}
type="number"
name="maxTokens"
value={maxTokens}
onChange={handleChange}
/>
</>
)}
{translator === OPT_TRANS_NIUTRANS && (
<>
<TextField
size="small"
label={"DictNo"}
name="dictNo"
value={dictNo}
onChange={handleChange}
/>
<TextField
size="small"
label={"MemoryNo"}
name="memoryNo"
value={memoryNo}
onChange={handleChange}
/>
</>
)}
{translator.startsWith(OPT_TRANS_CUSTOMIZE) && (
<>
<TextField
size="small"
label={"Request Hook"}
name="reqHook"
value={reqHook}
onChange={handleChange}
multiline
maxRows={10}
/>
<TextField
size="small"
label={"Response Hook"}
name="resHook"
value={resHook}
onChange={handleChange}
multiline
maxRows={10}
/>
</>
)}
<TextField
size="small"
label={i18n("fetch_limit")}
type="number"
name="fetchLimit"
value={fetchLimit}
onChange={handleChange}
/>
<TextField
size="small"
label={i18n("fetch_interval")}
type="number"
name="fetchInterval"
value={fetchInterval}
onChange={handleChange}
/>
<TextField
size="small"
label={i18n("http_timeout")}
type="number"
name="httpTimeout"
defaultValue={httpTimeout}
onChange={handleChange}
/>
<FormControlLabel
control={
<Switch
size="small"
name="isDisabled"
checked={isDisabled}
onChange={() => {
updateApi({ isDisabled: !isDisabled });
}}
/>
}
label={i18n("is_disabled")}
/>
<Stack direction="row" spacing={2}>
<TestButton translator={translator} api={api} />
<Button
size="small"
variant="outlined"
onClick={() => {
resetApi();
}}
>
{i18n("restore_default")}
</Button>
</Stack>
{translator.startsWith(OPT_TRANS_CUSTOMIZE) && (
<pre>{i18n("custom_api_help")}</pre>
)}
</Stack>
);
}
function ApiAccordion({ translator }) {
const [expanded, setExpanded] = useState(false);
const { api, updateApi, resetApi } = useApi(translator);
const handleChange = (e) => {
setExpanded((pre) => !pre);
};
return (
<Accordion expanded={expanded} onChange={handleChange}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography>
{api.apiName ? `${translator} (${api.apiName})` : translator}
</Typography>
</AccordionSummary>
<AccordionDetails>
{expanded && (
<ApiFields
translator={translator}
api={api}
updateApi={updateApi}
resetApi={resetApi}
/>
)}
</AccordionDetails>
</Accordion>
);
}
export default function Apis() {
const i18n = useI18n();
return (
<Box>
<Stack spacing={3}>
<Alert severity="info">{i18n("about_api")}</Alert>
<Box>
{OPT_TRANS_ALL.map((translator) => (
<ApiAccordion key={translator} translator={translator} />
))}
</Box>
</Stack>
</Box>
);
}

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