commit 564192f678c4458b32d00eba04239c503ea7aee7 Author: natsufrank Date: Tue Jun 10 15:11:11 2025 +0800 copy diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..c06fda3 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,13 @@ +# These are supported funding model platforms + +github: [windingwind] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: windingwind +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +custom: ["https://paypal.me/windingwind?country.x=C2&locale.x=zh_XC", ] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..6076309 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,40 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[Bug]Complete the Title Here" +labels: bug +assignees: '' + +--- + +**Before Issue/提问之前** +1. See #6 and search the [Issues Page](https://github.com/windingwind/zotero-pdf-translate/issues) to make sure this is not a duplicated issue; +2. If this is a bug with Translate, i.e. no result or error, make sure you can access the translate service and the secret is currect; + +**Issues not in the template format will be ignored/不按模板格式或不完整填写模板内容的提问将不会被回复。如果你看不懂英文可以用中文提问,但请有礼貌地详细描述你的问题。如果不详细说明通过什么步骤可以复现问题,具体表现是什么,你正在使用哪个版本的插件,我没办法帮到你。不要浪费大家的时间!!!** + +**Edit the template below and delete the section above/必须准确详细地填写编辑以下提问模版,并删除上面的内容,否则不会被回复** + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Environment** + - OS: Windows/macOS/Linux + - Zotero Version(Help->About Zotero) + - Addon Version(Tools->Add-ons) + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..69d775f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,27 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[Feature]Complete the Title Here" +labels: enhancement +assignees: '' + +--- + +**Before Issue/提问之前** +1. See #6 and search the [Issues Page](https://github.com/windingwind/zotero-pdf-translate/issues) to make sure this is not a duplicated issue; + +**Issues not in the template format will be ignored/不按模板格式或不完整填写模板内容的提问将不会被回复。如果你看不懂英文可以用中文提问,但请有礼貌地详细描述你的问题,不要浪费大家的时间!!!** + +**Edit the template below and delete the section above/必须准确详细地填写编辑以下提问模版,并删除上面的内容,否则不会被回复** + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3a3cce5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "npm" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/issuebot.yml b/.github/workflows/issuebot.yml new file mode 100644 index 0000000..e7e620f --- /dev/null +++ b/.github/workflows/issuebot.yml @@ -0,0 +1,23 @@ +name: Close inactive issues +on: + schedule: + - cron: "30 1 * * *" + +jobs: + close-issues: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@v5 + with: + days-before-issue-stale: 30 + days-before-issue-close: 7 + stale-issue-label: "stale" + stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." + close-issue-message: "This issue was closed because it has been inactive for 7 days since being marked as stale." + exempt-issue-labels: "help wanted" + days-before-pr-stale: -1 + days-before-pr-close: -1 + repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2d96bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +**/builds +node_modules +package-lock.json +zotero-cmd.json \ No newline at end of file diff --git a/.release-it.json b/.release-it.json new file mode 100644 index 0000000..2b66daf --- /dev/null +++ b/.release-it.json @@ -0,0 +1,13 @@ +{ + "npm": { + "publish": false + }, + "github": { + "release": true, + "assets": ["builds/*.xpi"] + }, + "hooks": { + "after:bump": "npm run build", + "after:release": "echo Successfully released ${name} v${version} to ${repo.repository}." + } +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..b5a4c7c --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,29 @@ +{ + // 使用 IntelliSense 了解相关属性。 + // 悬停以查看现有属性的描述。 + // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Restart-Z6", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "restart-dev-z6"] + }, + { + "type": "node", + "request": "launch", + "name": "Restart-Z7", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "restart-dev-z7"] + }, + { + "type": "node", + "request": "launch", + "name": "Restart in Prod Mode", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "restart-prod"] + } + ] +} diff --git a/.vscode/toolkit.code-snippets b/.vscode/toolkit.code-snippets new file mode 100644 index 0000000..ab0b6da --- /dev/null +++ b/.vscode/toolkit.code-snippets @@ -0,0 +1,45 @@ +{ + "appendElement - full": { + "scope": "javascript,typescript", + "prefix": "appendElement", + "body": [ + "appendElement({", + "\ttag: '${1:div}',", + "\tid: '${2:id}',", + "\tnamespace: '${3:html}',", + "\tclassList: ['${4:class}'],", + "\tstyles: {${5:style}: '$6'},", + "\tproperties: {},", + "\tattributes: {},", + "\t[{ '${7:onload}', (e: Event) => $8, ${9:false} }],", + "\tcheckExistanceParent: ${10:HTMLElement},", + "\tignoreIfExists: ${11:true},", + "\tskipIfExists: ${12:true},", + "\tremoveIfExists: ${13:true},", + "\tcustomCheck: (doc: Document, options: ElementOptions) => ${14:true},", + "\tchildren: [$15]", + "}, ${16:container});" + ] + }, + "appendElement - minimum": { + "scope": "javascript,typescript", + "prefix": "appendElement", + "body": "appendElement({ tag: '$1' }, $2);" + }, + "register Notifier": { + "scope": "javascript,typescript", + "prefix": "registerObserver", + "body": [ + "registerObserver({", + "\t notify: (", + "\t\tevent: _ZoteroTypes.Notifier.Event,", + "\t\ttype: _ZoteroTypes.Notifier.Type,", + "\t\tids: string[],", + "\t\textraData: _ZoteroTypes.anyObj", + "\t) => {", + "\t\t$0", + "\t}", + "});" + ] + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dbbe355 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are 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. + + 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. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + 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 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 work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero 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 Affero 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 Affero 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 Affero 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. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + 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 AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1747617 --- /dev/null +++ b/README.md @@ -0,0 +1,239 @@ +# ![PDFTranslate](addon/chrome/content/icons/favicon.png)Zotero PDF Translate + +[![Using Zotero Plugin Template](https://img.shields.io/badge/Using-Zotero%20Plugin%20Template-blue?style=flat-square&logo=github)](https://github.com/windingwind/zotero-plugin-template) + +This is an add-on for [Zotero](https://www.zotero.org/)'s built-in PDF reader. +Translate PDFs, annotations, notes, and item titles automatically. + +[中文文档](https://zotero.yuque.com/books/share/4443494c-c698-4e08-9d1e-ed253390346d) + +![](imgs/translate.gif) + +# Quick Start Guide + +## Install + +### From local file + +- Download the latest release (.xpi file) from the [Latest Release Page](https://github.com/windingwind/zotero-pdf-translate/releases/latest) + _Note_ If you're using Firefox as your browser, right-click the `.xpi` and select "Save As.." +- In Zotero click `Tools` in the top menu bar and then click `Addons` +- Go to the Extensions page and then click the gear icon in the top right. +- Select `Install Add-on from file`. +- Browse to where you downloaded the `.xpi` file and select it. +- Restart Zotero, by clicking `restart now` in the extensions list where the + Zotero PDF Translate plugin is now listed. + +### From remote link + +- In Zotero click `Tools` in the top menu bar and then click `Addons`. +- Drag [Latest Release](https://github.com/windingwind/zotero-pdf-translate/releases/latest/download/zotero-pdf-translate.xpi) and drop it in the Zotero UI. +- Click `install now`. +- Restart Zotero, by clicking `restart now` in the extensions list where the + Zotero PDF Translate plugin is now listed. + +## Usage + +Once you have the plugin installed simply, open any PDF in your collections. + +- Select some text, the translations are shown on the popup and the right sidebar(v0.2.0); Hold `Alt/Option` to concat selections. + ![](imgs/en2zh.png) + +- Highlight some text, the translations are added to the annotation comment(v0.3.0); Modify & retranslate the annotation text in the sidebar and click the `Update Annotation` to modify the annotation text and translation(v0.6.6); +- Add selected text along with translation to note(v0.4.0); _Only works when a note editor is active._ + ![](imgs/addnote.png) +- Translate item titles with right-click menu or shortcut `Ctrl+T`(v0.6.0). +- Translate item abstract with right-click menu(v0.8.0). Thanks @iShareStuff +- Standalone translation window available(v0.7.0). View & compare translations from multiply engines in one window! + ![](imgs/standalone.png) +- Dictionary for single word translation(v0.7.1). +- SentenceBySentence Translation(v1.1.0). After a translation, press `shift`+`P` and select `Translate Sentences`. _Only for en2zh and en2en now_. Thanks @MuiseDestiny + +### Q&A + +**Q** I want to translate manually. +**A** Go to `Edit->Preferences->PDF Translate->General`, uncheck the `Automatic Translation`. Click the `translate` button on the popup or sidebar to translate. + +**Q** I want a translate shortcut. +**A** +Press shortcut `Ctrl+T` after you selected some text. If you are in the collection view, the titles' translation will show/hide. + +**Q** I want to concat different seletions and translate them together. +**A** Press `Alt/Option` when selecting text in PDF. + +**Q** Not the language I want. +**A** The default target language is the same as your Zotero language. Go to `Edit->Preferences->PDF Translate->General` and change the language settings. + +**Q** Translation not correct or report an error. +**A** See [Language Settings](#general-language-settings) and #6. Make sure you use the right secret. + +**Q** I want to change the font size. +**A** Go to `Edit->Preferences->PDF Translate->Advanced` and set the font size. + +## Settings + +### General + +- Enable Translation, default `true` +- Automatic Translation, default `true` +- Enable Dictionary: single word will be translated using dictionary-engine instead of translate engine, default `true` +- Enable Popup: Show results in a right-click popup or only in the sidebar, default `true` +- Automatic Annotation Translation: Save annotation's translation as comment, default `true` +- Show 'Add to Note(With Translation)' in Popup: default `true` + > Unvisible if no active note editor opened. + - Replace Source Text: Use translation to replace the source text when adding to note, default `false` + +### Service + +The default engine is Google Translate. Currently, we support: +| Translate Engine | Require Secret | Supported Languages | +| ---------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Google Translate | No | [100+](https://translate.google.com/about/languages/) | +| Google Translate(API) | No | Use `translate.googleapis.com` | +| CNKI | No | https://dict.cnki.net | +| Youdao Translate | No | [100+?](https://ai.youdao.com/DOCSIRMA/html/%E8%87%AA%E7%84%B6%E8%AF%AD%E8%A8%80%E7%BF%BB%E8%AF%91/API%E6%96%87%E6%A1%A3/%E6%96%87%E6%9C%AC%E7%BF%BB%E8%AF%91%E6%9C%8D%E5%8A%A1/%E6%96%87%E6%9C%AC%E7%BF%BB%E8%AF%91%E6%9C%8D%E5%8A%A1-API%E6%96%87%E6%A1%A3.html) | +| Youdao Zhiyun | Yes | [100+](https://ai.youdao.com/DOCSIRMA/html/%E8%87%AA%E7%84%B6%E8%AF%AD%E8%A8%80%E7%BF%BB%E8%AF%91/API%E6%96%87%E6%A1%A3/%E6%96%87%E6%9C%AC%E7%BF%BB%E8%AF%91%E6%9C%8D%E5%8A%A1/%E6%96%87%E6%9C%AC%E7%BF%BB%E8%AF%91%E6%9C%8D%E5%8A%A1-API%E6%96%87%E6%A1%A3.html) | +| Niu Translate(Trial) | No | [100+](https://niutrans.com/documents/contents/trans_text#accessMode) **UNSTABLE** | +| Niu Translate | Yes | [100+](https://niutrans.com/documents/contents/trans_text#accessMode) | +| Microsoft Translate | Yes(free 2M) | [200+](https://docs.microsoft.com/en-us/azure/cognitive-services/translator/language-support) | +| LingoCloud(Caiyun) Translate | Yes | [zh, en, ja, es, fr, ru](https://open.caiyunapp.com/LingoCloud_API_in_5_minutes) | +| DeepL Translate | Yes(free 500k) | [100+](https://www.deepl.com/pro?cta=header-prices/#developer) | +| Baidu Translate | Yes(free-QPS1/free-2M) | [200+](https://fanyi-api.baidu.com/product/11) | +| Baidu Field | Yes(free-QPS1/free-2M) | [en-zh](https://fanyi-api.baidu.com/product/12) | +| Tencent Translate | Yes(QPS5, free-5M) | [15](https://cloud.tencent.com/document/product/551/7372) | +| GPT(OpenAI) | Yes(free-$18) | [Based on the gpt-3.5-turbo model](https://openai.com/pricing#chat) | + +> If the engine you want is not yet supported, please post an issue. + +**Microsoft Translate** +Apply [here](https://docs.microsoft.com/en-us/azure/cognitive-services/translator/quickstart-translator?tabs=csharp). Copy your secret and paste it into the settings. +The secret format is `MY_SECRET`. + +> See [this issue](https://github.com/windingwind/zotero-pdf-translate/issues/3#issuecomment-1064688597) for detailed steps to set up the Microsoft Translate. + +**DeepL Translate** +Apply [here](https://www.deepl.com/pro?cta=header-prices/#developer). + +**Youdao Zhiyun Translate 有道智云** +Apply [here](https://ai.youdao.com/login.s). +The secret format is `MY_APPID#MY_SECRET#MY_VOCABID(optional)`. + +> About `VOCABID` +> 登录控制台,选择文本翻译服务,点击右侧的术语表,选择新建,填写表名称和语言方向,添加需要的术语表,然后获取对应词表 id 即可。 + +> [Official Document](https://ai.youdao.com/DOCSIRMA/html/%E8%87%AA%E7%84%B6%E8%AF%AD%E8%A8%80%E7%BF%BB%E8%AF%91/API%E6%96%87%E6%A1%A3/%E6%96%87%E6%9C%AC%E7%BF%BB%E8%AF%91%E6%9C%8D%E5%8A%A1/%E6%96%87%E6%9C%AC%E7%BF%BB%E8%AF%91%E6%9C%8D%E5%8A%A1-API%E6%96%87%E6%A1%A3.html) + +**Niu Translate** +Apply [here](https://niutrans.com/NiuTransAuthCenter/login). +The secret format is `MY_APIKEY#dictNo(optional)#memoryNo(optional)`. + +> [Chinese Document](https://doc.tern.1c7.me/zh/folder/setting/#%E5%B0%8F%E7%89%9B) + +**Baidu Translate** +Apply [here](https://fanyi-api.baidu.com/product/11). +The secret format is `MY_APPID#MY_KEY#ACTION(optional, see https://api.fanyi.baidu.com/doc/21, default 0)`(split with '#'). + +**Baidu Field Translate 百度垂直领域翻译** +Apply [here](https://fanyi-api.baidu.com/product/12). +The secret format is `MY_APPID#MY_KEY#DOMAIN_CODE`(split with '#'). + +| Domain Code | 领域 | 语言方向 | +| ----------- | ------------ | ----------- | +| electronics | 电子科技领域 | 中文-->英语 | +| finance | 金融财经领域 | 中文-->英语 | +| finance | 金融财经领域 | 英语-->中文 | +| mechanics | 水利机械领域 | 中文-->英语 | +| medicine | 生物医药领域 | 中文-->英语 | +| medicine | 生物医药领域 | 英语-->中文 | +| novel | 网络文学领域 | 中文-->英语 | + +> [Chinese Document](https://doc.tern.1c7.me/zh/folder/setting/#%E8%85%BE%E8%AE%AF%E4%BA%91) + +**Tencent Translate** +Apply [here](https://cloud.tencent.com/product/tmt). +The secret format is `secretId#SecretKey#Region(optional, default ap-shanghai)#ProjectId(optional, default 0)`(split with '#'). + +> [Chinese Document](https://doc.tern.1c7.me/zh/folder/setting/#%E8%85%BE%E8%AE%AF%E4%BA%91) + +**OpenL Translate** +Apply [here](https://my.openl.club/). +The secret format is `service1,service2,...#apikey`(split with '#'; split service codes with ','). + +Supported service codes are: `deepl,youdao,tencent,aliyun,baidu,caiyun,wechat,sogou,azure,ibm,aws,google`, See [Service Code](https://docs.openl.club/#/API/format?id=%e7%bf%bb%e8%af%91%e6%9c%8d%e5%8a%a1%e4%bb%a3%e7%a0%81%e5%90%8d) + +> [Chinese Document](https://docs.openl.club/#/) + +**GPT** +Apply [here](https://platform.openai.com/signup). +The secret format is `sk-#SecretKey`(split with '#'). + +> [Chinese Document](https://gist.github.com/GrayXu/f1b72353b4b0493d51d47f0f7498b67b) + +### User Interface + +- `Font Size`: The font size of result text, default `12` +- `Line Height`: The line height of result text, default `1.5` +- `SideBar: Show xxx`: Show or hide sidebar elements, default `true` +- `SideBar: Reverse Raw/Result`: Reverse the order of Raw/Result in the sidebar if `true`, default `false` +- `Popup: Remember Size`: Remember size of popup if `true`, else automatically adjust the size, default `false` + +### Advanced + +- Disable Automatic Translation when File Language is(split with ','): If you want to disable automatic translation in `zh` and `ja` files, set `zh,ja`. + +## Development & Contributing + +This addon is built based on the [Zotero Plugin Template](https://github.com/windingwind/zotero-plugin-template). See the setup and debug details there. + +To startup, run + +```bash +git clone https://github.com/windingwind/zotero-pdf-translate.git +cd zotero-pdf-translate +npm install +npm run build +``` + +The plugin is built to `./builds/*.xpi`. + +### Contributing + +**Add new translate service** + +1. Add service config to `src/utils/config.ts` > `SERVICES`; +2. Add translation task processor under `src/modules/services/${serviceId}.ts` with the same format with other services. The export function set the translation result to `data.result` if runs successfully and throw an error if fails; +3. Import the task processor function in `src/modules/services.ts`. +4. Add locale string `service.${serviceId}` in `addon/chrome/locale/${lang}/addon.properties`. +5. Build and test. + +**Extra options for translate service** + +If the service requires extra options, the minimal implement would be putting them in the `secret` input in the prefs window, like the existing services does. + +If there are complex options, please bind a callback in `src/utils/translate.ts > secretStatusButtonData` which create a highly customizable dialog window with `ztoolkit.Dialog`. See the example of NiuTrans login here: https://github.com/windingwind/zotero-pdf-translate/blob/main/src/utils/niuTransLogin.ts + +## Disclaimer + +Use this code under AGPL. No warranties are provided. Keep the laws of your locality in mind! + +## My Other Zotero Addons + +- [zotero-better-notes](https://github.com/windingwind/zotero-better-notes): Everything about note management. All in Zotero. +- [zotero-pdf-preview](https://github.com/windingwind/zotero-tag): PDF preview for Zotero +- [zotero-tag](https://github.com/windingwind/zotero-tag): Automatically tag items/Batch tagging + +## Sponsor Me + +I'm windingwind, an active Zotero(https://www.zotero.org) plugin developer. Devoting to making reading papers easier. + +Sponsor me to buy a cup of coffee. I spend more than 24 hours every week coding, debugging, and replying to issues in my plugin repositories. The plugins are open-source and totally free. + +If you sponsor more than $10 a month, you can list your name/logo here and have priority for feature requests/bug fixes! + +## Sponsors + +Thanks +[peachgirl100](https://github.com/peachgirl100) +and other anonymous sponsors! + +If you want to leave your name here, please email me or leave a message with the donation. diff --git a/addon/bootstrap.js b/addon/bootstrap.js new file mode 100644 index 0000000..cbe5f15 --- /dev/null +++ b/addon/bootstrap.js @@ -0,0 +1,148 @@ +/** + * Most of this code is from Zotero team's official Make It Red example[1] + * or the Zotero 7 documentation[2]. + * [1] https://github.com/zotero/make-it-red + * [2] https://www.zotero.org/support/dev/zotero_7_for_developers + */ + +if (typeof Zotero == "undefined") { + var Zotero; +} + +var chromeHandle; + +// In Zotero 6, bootstrap methods are called before Zotero is initialized, and using include.js +// to get the Zotero XPCOM service would risk breaking Zotero startup. Instead, wait for the main +// Zotero window to open and get the Zotero object from there. +// +// In Zotero 7, bootstrap methods are not called until Zotero is initialized, and the 'Zotero' is +// automatically made available. +async function waitForZotero() { + if (typeof Zotero != "undefined") { + await Zotero.initializationPromise; + } + + var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + var windows = Services.wm.getEnumerator("navigator:browser"); + var found = false; + while (windows.hasMoreElements()) { + let win = windows.getNext(); + if (win.Zotero) { + Zotero = win.Zotero; + found = true; + break; + } + } + if (!found) { + await new Promise((resolve) => { + var listener = { + onOpenWindow: function (aWindow) { + // Wait for the window to finish loading + let domWindow = aWindow + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowInternal || Ci.nsIDOMWindow); + domWindow.addEventListener( + "load", + function () { + domWindow.removeEventListener("load", arguments.callee, false); + if (domWindow.Zotero) { + Services.wm.removeListener(listener); + Zotero = domWindow.Zotero; + resolve(); + } + }, + false + ); + }, + }; + Services.wm.addListener(listener); + }); + } + await Zotero.initializationPromise; +} + +function install(data, reason) {} + +async function startup({ id, version, resourceURI, rootURI }, reason) { + await waitForZotero(); + + // String 'rootURI' introduced in Zotero 7 + if (!rootURI) { + rootURI = resourceURI.spec; + } + + if (Zotero.platformMajorVersion >= 102) { + var aomStartup = Components.classes[ + "@mozilla.org/addons/addon-manager-startup;1" + ].getService(Components.interfaces.amIAddonManagerStartup); + var manifestURI = Services.io.newURI(rootURI + "manifest.json"); + chromeHandle = aomStartup.registerChrome(manifestURI, [ + ["content", "__addonRef__", rootURI + "chrome/content/"], + ["locale", "__addonRef__", "en-US", rootURI + "chrome/locale/en-US/"], + ["locale", "__addonRef__", "zh-CN", rootURI + "chrome/locale/zh-CN/"], + ]); + } else { + setDefaultPrefs(rootURI); + } + + // Global variables for plugin code + const ctx = { + rootURI, + }; + ctx._globalThis = ctx; + + Services.scriptloader.loadSubScript( + `${rootURI}/chrome/content/scripts/index.js`, + ctx, + "utf8" + ); +} + +function shutdown({ id, version, resourceURI, rootURI }, reason) { + if (reason === APP_SHUTDOWN) { + return; + } + if (typeof Zotero === "undefined") { + Zotero = Components.classes["@zotero.org/Zotero;1"].getService( + Components.interfaces.nsISupports + ).wrappedJSObject; + } + Zotero.__addonInstance__.hooks.onShutdown(); + + Cc["@mozilla.org/intl/stringbundle;1"] + .getService(Components.interfaces.nsIStringBundleService) + .flushBundles(); + + Cu.unload(`${rootURI}/chrome/content/scripts/index.js`); + + if (chromeHandle) { + chromeHandle.destruct(); + chromeHandle = null; + } +} + +function uninstall(data, reason) {} + +// Loads default preferences from defaults/preferences/prefs.js in Zotero 6 +function setDefaultPrefs(rootURI) { + var branch = Services.prefs.getDefaultBranch(""); + var obj = { + pref(pref, value) { + switch (typeof value) { + case "boolean": + branch.setBoolPref(pref, value); + break; + case "string": + branch.setStringPref(pref, value); + break; + case "number": + branch.setIntPref(pref, value); + break; + default: + Zotero.logError(`Invalid type '${typeof value}' for pref '${pref}'`); + } + }, + }; + Zotero.getMainWindow().console.log(rootURI + "prefs.js"); + Services.scriptloader.loadSubScript(rootURI + "prefs.js", obj); +} diff --git a/addon/chrome.manifest b/addon/chrome.manifest new file mode 100644 index 0000000..713e1b0 --- /dev/null +++ b/addon/chrome.manifest @@ -0,0 +1,3 @@ +content __addonRef__ chrome/content/ +locale __addonRef__ en-US chrome/locale/en-US/ +locale __addonRef__ zh-CN chrome/locale/zh-CN/ diff --git a/addon/chrome/content/icons/favicon.png b/addon/chrome/content/icons/favicon.png new file mode 100644 index 0000000..8a57ded Binary files /dev/null and b/addon/chrome/content/icons/favicon.png differ diff --git a/addon/chrome/content/icons/favicon@0.5x.png b/addon/chrome/content/icons/favicon@0.5x.png new file mode 100644 index 0000000..30e8037 Binary files /dev/null and b/addon/chrome/content/icons/favicon@0.5x.png differ diff --git a/addon/chrome/content/preferences.xhtml b/addon/chrome/content/preferences.xhtml new file mode 100644 index 0000000..a526033 --- /dev/null +++ b/addon/chrome/content/preferences.xhtml @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addon/chrome/content/standalone.xhtml b/addon/chrome/content/standalone.xhtml new file mode 100644 index 0000000..b912a03 --- /dev/null +++ b/addon/chrome/content/standalone.xhtml @@ -0,0 +1,28 @@ + + + + + + + + + + + + #zoteropdftranslate-standalone-panel-openwindow { display: none; } + + + + diff --git a/addon/chrome/locale/en-US/addon.properties b/addon/chrome/locale/en-US/addon.properties new file mode 100644 index 0000000..f847b55 --- /dev/null +++ b/addon/chrome/locale/en-US/addon.properties @@ -0,0 +1,95 @@ +readerpanel.label=Translate +readerpanel.translate.button.label=Translate +readerpanel.auto.description.label=Auto-Trans: +readerpanel.auto.selection.label=Selection +readerpanel.auto.annotation.label=Annotation +readerpanel.concat.description.label=Selection: +readerpanel.concat.enable.label=Concat Mode +readerpanel.concat.clear.label=Clear +readerpanel.copy.description.label=Copy: +readerpanel.copy.raw.label=Raw +readerpanel.copy.result.label=Result +readerpanel.copy.both.label=Both +readerpanel.openwindow.open.label=Open Standalone Panel +readerpanel.extra.addservice.label=Add Result Source +readerpanel.extra.removeservice.label=Remove +readerpanel.extra.resize.label=Reset Width +readerpanel.extra.pin.label=📌Pin +readerpanel.extra.pinned.label=📍Unpin + +service.googleapi=Google(API) +service.google=Google +service.cnki=CNKI +service.youdao=Youdao +service.youdaozhiyun=Youdao Zhiyun🗝️ +service.niutranspro=Niu Trans🗝️ +service.microsoft=Microsoft🗝️ +service.caiyun=Caiyun🗝️ +service.deeplfree=DeepL(Free Plan)🗝️ +service.deeplpro=DeepL(Pro Plan)🗝️ +service.deeplcustom=DeepL(Custom)🗝️ +service.deeplx=DeepLx +service.baidu=Baidu🗝️ +service.baidufield=Baidu Field🗝️ +service.openl=OpenL🗝️ +service.tencent=Tencent🗝️ +service.xftrans=Xftrans🗝️ +service.gpt=GPT🗝️ +service.haici=Haici +service.bingdict=Bing Dict(en↔zh)🔊 +service.haicidict=Haici Dict(en↔zh)🔊 +service.collinsdict=Collins Dict(en↔zh)🔊 +service.youdaodict=Haici Dict(en↔zh) +service.freedictionaryapi=FreeDictionaryAPI(en↔en) +service.webliodict=weblio(en↔ja) +service.errorPrefix=[Request Error]\n\nEngine not available, invalid secret, or request too fast.\nUse another translation engine or post the issue here: \n https://github.com/windingwind/zotero-pdf-translate/issues \n\nThe message below is not Zotero or the PDF Translate addon, but from + +service.niutranspro.secret.pass=More... +service.niutranspro.secret.fail=Login +service.niutranspro.dialog.title=NiuTrans Account +service.niutranspro.dialog.username=Username +service.niutranspro.dialog.password=Password +service.niutranspro.dialog.signup=Sign up +service.niutranspro.dialog.forget=Forget +service.niutranspro.dialog.dictLib=Dict Lib +service.niutranspro.dialog.memoryLib=Memory Lib +service.niutranspro.dialog.tip0=Please go to the +service.niutranspro.dialog.tip1=Niutrans Cloud Platform +service.niutranspro.dialog.tip2=to add the term dictionary library +service.niutranspro.dialog.signin=Sign In +service.niutranspro.dialog.refresh=Refresh +service.niutranspro.dialog.signout=Sign Out +service.niutranspro.dialog.close=Close + +service.deeplcustom.secret.pass=Docs +service.deeplcustom.secret.fail=Docs + +service.gpt.secret.pass=Config +service.gpt.secret.fail=Config +service.gpt.dialog.title=GPT Config +service.gpt.dialog.url=API +service.gpt.dialog.models=Model +service.gpt.dialog.temperature=Temp +service.gpt.dialog.status=Status +service.gpt.dialog.help=help +service.gpt.dialog.save=save +service.gpt.dialog.close=close +service.gpt.dialog.status.load=loading +service.gpt.dialog.status.available=available +service.gpt.dialog.status.timeout=timeout +service.gpt.dialog.status.invalid=secret invalid +service.gpt.dialog.status.unexpect=fail + +readerpopup.translate.label=Translate + +pref.title=Translate + +itemmenu.translateTitle.label=Translate Title +itemmenu.switchTitleMode.label=Switch Original Title/Translation +itemmenu.translateAbstract.label=Translate Abstract +itemmenu.more.label=More Translation Options... +itemmenu.retranslateTitle.label=Retranslate Title +itemmenu.retranslateAbstract.label=Retranslate Abstract + +field.titleTranslation=Title Translation +field.abstractTranslation=Abstract Translation diff --git a/addon/chrome/locale/en-US/overlay.dtd b/addon/chrome/locale/en-US/overlay.dtd new file mode 100644 index 0000000..7f63246 --- /dev/null +++ b/addon/chrome/locale/en-US/overlay.dtd @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addon/chrome/locale/zh-CN/addon.properties b/addon/chrome/locale/zh-CN/addon.properties new file mode 100644 index 0000000..08e13aa --- /dev/null +++ b/addon/chrome/locale/zh-CN/addon.properties @@ -0,0 +1,95 @@ +readerpanel.label=翻译 +readerpanel.translate.button.label=翻译 +readerpanel.auto.description.label=自动翻译: +readerpanel.auto.selection.label=选择内容 +readerpanel.auto.annotation.label=批注 +readerpanel.concat.description.label=选择内容: +readerpanel.concat.enable.label=拼接模式 +readerpanel.concat.clear.label=清空 +readerpanel.copy.description.label=复制: +readerpanel.copy.raw.label=源文本 +readerpanel.copy.result.label=结果 +readerpanel.copy.both.label=两者 +readerpanel.openwindow.open.label=打开独立窗口 +readerpanel.extra.addservice.label=添加结果来源 +readerpanel.extra.removeservice.label=移除 +readerpanel.extra.resize.label=重置宽度 +readerpanel.extra.pin.label=📌置顶 +readerpanel.extra.pinned.label=📍取消置顶 + +service.googleapi=Google(API) +service.google=Google +service.cnki=CNKI +service.youdao=有道 +service.youdaozhiyun=有道智云🗝️ +service.niutranspro=小牛🗝️ +service.microsoft=微软🗝️ +service.caiyun=彩云🗝️ +service.deeplfree=DeepL(免费订阅)🗝️ +service.deeplpro=DeepL(Pro订阅)🗝️ +service.deeplcustom=DeepL(自定义)🗝️ +service.deeplx=DeepLx +service.baidu=百度🗝️ +service.baidufield=百度垂直领域🗝️ +service.openl=OpenL🗝️ +service.tencent=腾讯🗝️ +service.xftrans=讯飞🗝️ +service.gpt=GPT🗝️ +service.haici=海词 +service.bingdict=必应词典(en↔zh)🔊 +service.haicidict=海词词典(en↔zh)🔊 +service.collinsdict=科林斯词典(en↔zh)🔊 +service.youdaodict=有道词典(en↔zh) +service.freedictionaryapi=FreeDictionaryAPI(en↔en) +service.webliodict=Weblio Dict(en↔ja) +service.errorPrefix=[请求错误]\n\n此翻译引擎不可用,可能是密钥错误,也可能是请求过快。\n可以尝试其他翻译引擎,或者来此查看相关回答:\nhttps://zotero.yuque.com/staff-gkhviy/pdf-trans/age09f \n\n请注意,这些错误与 Zotero 和本翻译插件无关,由该翻译服务引起: + +service.niutranspro.secret.pass=更多... +service.niutranspro.secret.fail=登录 +service.niutranspro.dialog.title=小牛翻译账户 +service.niutranspro.dialog.username=用户名 +service.niutranspro.dialog.password=密码 +service.niutranspro.dialog.signup=注册 +service.niutranspro.dialog.forget=忘记密码 +service.niutranspro.dialog.dictLib=术语词典 +service.niutranspro.dialog.memoryLib=翻译记忆 +service.niutranspro.dialog.tip0=请到 +service.niutranspro.dialog.tip1=小牛翻译云平台 +service.niutranspro.dialog.tip2=进行添加术语词典库 +service.niutranspro.dialog.signin=登录 +service.niutranspro.dialog.refresh=刷新 +service.niutranspro.dialog.signout=退出登录 +service.niutranspro.dialog.close=关闭 + +service.deeplcustom.secret.pass=文档 +service.deeplcustom.secret.fail=文档 + +service.gpt.secret.pass=配置 +service.gpt.secret.fail=配置 +service.gpt.dialog.title=GPT 配置项 +service.gpt.dialog.url=接口 +service.gpt.dialog.models=模型 +service.gpt.dialog.temperature=温度 +service.gpt.dialog.status=状态 +service.gpt.dialog.help=帮助 +service.gpt.dialog.save=保存 +service.gpt.dialog.close=关闭 +service.gpt.dialog.status.load=加载中 +service.gpt.dialog.status.available=可用 +service.gpt.dialog.status.timeout=请求超时 +service.gpt.dialog.status.invalid=密钥不可用 +service.gpt.dialog.status.unexpect=服务不可用 + +readerpopup.translate.label=翻译 + +pref.title=翻译 + +itemmenu.translateTitle.label=翻译标题 +itemmenu.switchTitleMode.label=切换原标题/翻译 +itemmenu.translateAbstract.label=翻译摘要 +itemmenu.more.label=更多翻译选项... +itemmenu.retranslateTitle.label=重新翻译标题 +itemmenu.retranslateAbstract.label=重新翻译摘要 + +field.titleTranslation=标题翻译 +field.abstractTranslation=摘要翻译 diff --git a/addon/chrome/locale/zh-CN/overlay.dtd b/addon/chrome/locale/zh-CN/overlay.dtd new file mode 100644 index 0000000..639854d --- /dev/null +++ b/addon/chrome/locale/zh-CN/overlay.dtd @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addon/install.rdf b/addon/install.rdf new file mode 100644 index 0000000..3b73c4f --- /dev/null +++ b/addon/install.rdf @@ -0,0 +1,35 @@ + + + + + + zotero@chnm.gmu.edu + 5.0 + * + + + + + juris-m@juris-m.github.io + 5.0 + * + + + + diff --git a/addon/manifest.json b/addon/manifest.json new file mode 100644 index 0000000..19ce90f --- /dev/null +++ b/addon/manifest.json @@ -0,0 +1,19 @@ +{ + "manifest_version": 2, + "name": "__addonName__", + "version": "__buildVersion__", + "description": "__description__", + "author": "__author__", + "icons": { + "48": "chrome/content/icons/favicon@0.5x.png", + "96": "chrome/content/icons/favicon.png" + }, + "applications": { + "zotero": { + "id": "__addonID__", + "update_url": "__updaterdf__", + "strict_min_version": "6.0.*", + "strict_max_version": "6.999" + } + } +} diff --git a/addon/prefs.js b/addon/prefs.js new file mode 100644 index 0000000..14f1a1d --- /dev/null +++ b/addon/prefs.js @@ -0,0 +1,42 @@ +pref("__prefsPrefix__.enableAuto", true); +pref("__prefsPrefix__.enableDict", true); +pref("__prefsPrefix__.enablePopup", true); +pref("__prefsPrefix__.enableComment", true); +pref("__prefsPrefix__.annotationTranslationPosition", "comment"); +pref("__prefsPrefix__.enableNote", true); +pref("__prefsPrefix__.enableNoteReplaceMode", false); +pref("__prefsPrefix__.translateSource", ""); +pref("__prefsPrefix__.dictSource", ""); +pref("__prefsPrefix__.sourceLanguage", "en-US"); +pref("__prefsPrefix__.targetLanguage", ""); +pref("__prefsPrefix__.fontSize", "12"); +pref("__prefsPrefix__.lineHeight", "1.5"); +pref("__prefsPrefix__.splitChar", "\ud83d\udd24"); +pref("__prefsPrefix__.autoFocus", true); +pref("__prefsPrefix__.rawResultOrder", false); +pref("__prefsPrefix__.showSidebarEngine", true); +pref("__prefsPrefix__.showSidebarSettings", true); +pref("__prefsPrefix__.showSidebarConcat", true); +pref("__prefsPrefix__.showSidebarLanguage", true); +pref("__prefsPrefix__.showSidebarRaw", true); +pref("__prefsPrefix__.showSidebarCopy", true); +pref("__prefsPrefix__.showItemBoxTitleTranslation", true); +pref("__prefsPrefix__.showItemBoxAbstractTranslation", true); +pref("__prefsPrefix__.keepWindowTop", false); +pref("__prefsPrefix__.keepPopupSize", false); +pref("__prefsPrefix__.popupWidth", 105); +pref("__prefsPrefix__.popupHeight", 30); +pref("__prefsPrefix__.niutransUsername", ""); +pref("__prefsPrefix__.niutransPassword", ""); +pref("__prefsPrefix__.niutransDictNo", ""); +pref("__prefsPrefix__.niutransMemoryNo", ""); +pref("__prefsPrefix__.niutransDictLibList", "[]"); +pref("__prefsPrefix__.niutransMemoryLibList", "[]"); +pref("__prefsPrefix__.autoPlay", false); +pref("__prefsPrefix__.showPlayBtn", true); +pref("__prefsPrefix__.disabledLanguages", ""); +pref("__prefsPrefix__.extraEngines", ""); +pref("__prefsPrefix__.titleColumnMode", "raw"); +pref("__prefsPrefix__.gptUrl", "https://api.openai.com/v1/chat/completions"); +pref("__prefsPrefix__.gptModel", "gpt-3.5-turbo"); +pref("__prefsPrefix__.gptTemperature", "1.0"); diff --git a/package.json b/package.json new file mode 100644 index 0000000..980ddf4 --- /dev/null +++ b/package.json @@ -0,0 +1,57 @@ +{ + "name": "zotero-pdf-translate", + "version": "1.0.25", + "description": "PDF translation for Zotero built-in PDF reader.", + "config": { + "addonName": "Zotero PDF Translate", + "addonID": "zoteropdftranslate@euclpts.com", + "addonRef": "zoteropdftranslate", + "prefsPrefix": "extensions.zotero.ZoteroPDFTranslate", + "addonInstance": "PDFTranslate", + "releasepage": "https://github.com/windingwind/zotero-pdf-translate/releases/latest/download/zotero-pdf-translate.xpi", + "updaterdf": "https://raw.githubusercontent.com/windingwind/zotero-pdf-translate/main/update.json" + }, + "main": "src/index.ts", + "scripts": { + "build-dev": "cross-env NODE_ENV=development node scripts/build.js", + "build-prod": "cross-env NODE_ENV=production node scripts/build.js", + "build": "concurrently -c auto npm:build-prod npm:tsc", + "tsc": "tsc --noEmit", + "start-z6": "node scripts/start.js --z 6", + "start-z7": "node scripts/start.js --z 7", + "start": "node scripts/start.js", + "stop": "node scripts/stop.js", + "restart-dev-z6": "npm run build-dev && npm run stop && npm run start-z6", + "restart-dev-z7": "npm run build-dev && npm run stop && npm run start-z7", + "restart-prod": "npm run build-prod && npm run stop && npm run start", + "restart": "npm run restart-dev", + "release": "release-it", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/windingwind/zotero-pdf-translate.git" + }, + "author": "windingwind", + "license": "AGPL-3.0-or-later", + "bugs": { + "url": "https://github.com/windingwind/zotero-pdf-translate/issues" + }, + "homepage": "https://github.com/windingwind/zotero-pdf-translate#readme", + "dependencies": { + "jsencrypt": "^3.3.2", + "zotero-plugin-toolkit": "^2.1.3" + }, + "devDependencies": { + "@types/node": "^18.16.1", + "compressing": "^1.9.0", + "concurrently": "^8.0.1", + "cross-env": "^7.0.3", + "esbuild": "^0.17.18", + "minimist": "^1.2.8", + "release-it": "^15.10.1", + "replace-in-file": "^6.3.5", + "typescript": "^5.0.4", + "zotero-types": "^1.0.13" + } +} diff --git a/scripts/build.js b/scripts/build.js new file mode 100644 index 0000000..20101e7 --- /dev/null +++ b/scripts/build.js @@ -0,0 +1,179 @@ +const esbuild = require("esbuild"); +const compressing = require("compressing"); +const path = require("path"); +const fs = require("fs"); +const process = require("process"); +const replace = require("replace-in-file"); +const { + name, + author, + description, + homepage, + version, + config, +} = require("../package.json"); + +function copyFileSync(source, target) { + var targetFile = target; + + // If target is a directory, a new file with the same name will be created + if (fs.existsSync(target)) { + if (fs.lstatSync(target).isDirectory()) { + targetFile = path.join(target, path.basename(source)); + } + } + + fs.writeFileSync(targetFile, fs.readFileSync(source)); +} + +function copyFolderRecursiveSync(source, target) { + var files = []; + + // Check if folder needs to be created or integrated + var targetFolder = path.join(target, path.basename(source)); + if (!fs.existsSync(targetFolder)) { + fs.mkdirSync(targetFolder); + } + + // Copy + if (fs.lstatSync(source).isDirectory()) { + files = fs.readdirSync(source); + files.forEach(function (file) { + var curSource = path.join(source, file); + if (fs.lstatSync(curSource).isDirectory()) { + copyFolderRecursiveSync(curSource, targetFolder); + } else { + copyFileSync(curSource, targetFolder); + } + }); + } +} + +function clearFolder(target) { + if (fs.existsSync(target)) { + fs.rmSync(target, { recursive: true, force: true }); + } + + fs.mkdirSync(target, { recursive: true }); +} + +function dateFormat(fmt, date) { + let ret; + const opt = { + "Y+": date.getFullYear().toString(), + "m+": (date.getMonth() + 1).toString(), + "d+": date.getDate().toString(), + "H+": date.getHours().toString(), + "M+": date.getMinutes().toString(), + "S+": date.getSeconds().toString(), + }; + for (let k in opt) { + ret = new RegExp("(" + k + ")").exec(fmt); + if (ret) { + fmt = fmt.replace( + ret[1], + ret[1].length == 1 ? opt[k] : opt[k].padStart(ret[1].length, "0") + ); + } + } + return fmt; +} + +async function main() { + const t = new Date(); + const buildTime = dateFormat("YYYY-mm-dd HH:MM:SS", t); + const buildDir = "builds"; + + console.log( + `[Build] BUILD_DIR=${buildDir}, VERSION=${version}, BUILD_TIME=${buildTime}, ENV=${[ + process.env.NODE_ENV, + ]}` + ); + + clearFolder(buildDir); + + copyFolderRecursiveSync("addon", buildDir); + + copyFileSync("update-template.json", "update.json"); + copyFileSync("update-template.rdf", "update.rdf"); + + await esbuild + .build({ + entryPoints: ["src/index.ts"], + define: { + __env__: `"${process.env.NODE_ENV}"`, + }, + bundle: true, + target: "firefox60", + outfile: path.join(buildDir, "addon/chrome/content/scripts/index.js"), + // Don't turn minify on + // minify: true, + }) + .catch(() => process.exit(1)); + + console.log("[Build] Run esbuild OK"); + + const replaceFrom = [ + /__author__/g, + /__description__/g, + /__homepage__/g, + /__buildVersion__/g, + /__buildTime__/g, + ]; + + const replaceTo = [author, description, homepage, version, buildTime]; + + replaceFrom.push( + ...Object.keys(config).map((k) => new RegExp(`__${k}__`, "g")) + ); + replaceTo.push(...Object.values(config)); + + const optionsAddon = { + files: [ + path.join(buildDir, "**/*.rdf"), + path.join(buildDir, "**/*.dtd"), + path.join(buildDir, "**/*.xul"), + path.join(buildDir, "**/*.xhtml"), + path.join(buildDir, "**/*.json"), + path.join(buildDir, "addon/prefs.js"), + path.join(buildDir, "addon/chrome.manifest"), + path.join(buildDir, "addon/manifest.json"), + path.join(buildDir, "addon/bootstrap.js"), + "update.json", + "update.rdf", + ], + from: replaceFrom, + to: replaceTo, + countMatches: true, + }; + + _ = replace.sync(optionsAddon); + console.log( + "[Build] Run replace in ", + _.filter((f) => f.hasChanged).map( + (f) => `${f.file} : ${f.numReplacements} / ${f.numMatches}` + ) + ); + + console.log("[Build] Replace OK"); + + console.log("[Build] Addon prepare OK"); + + compressing.zip.compressDir( + path.join(buildDir, "addon"), + path.join(buildDir, `${name}.xpi`), + { + ignoreBase: true, + } + ); + + console.log("[Build] Addon pack OK"); + console.log( + `[Build] Finished in ${(new Date().getTime() - t.getTime()) / 1000} s.` + ); +} + +main().catch((err) => { + console.log(err); + process.exit(1); +}); diff --git a/scripts/start.js b/scripts/start.js new file mode 100644 index 0000000..2485412 --- /dev/null +++ b/scripts/start.js @@ -0,0 +1,25 @@ +const { execSync } = require("child_process"); +const { exit } = require("process"); +const { exec } = require("./zotero-cmd.json"); + +// Run node start.js -h for help +const args = require("minimist")(process.argv.slice(2)); + +if (args.help || args.h) { + console.log("Start Zotero Args:"); + console.log( + "--zotero(-z): Zotero exec key in zotero-cmd.json. Default the first one." + ); + console.log("--profile(-p): Zotero profile name."); + exit(0); +} + +const zoteroPath = exec[args.zotero || args.z || Object.keys(exec)[0]]; +const profile = args.profile || args.p; + +const startZotero = `${zoteroPath} --debugger --purgecaches ${ + profile ? `-p ${profile}` : "" +}`; + +execSync(startZotero); +exit(0); diff --git a/scripts/stop.js b/scripts/stop.js new file mode 100644 index 0000000..fa0a2b4 --- /dev/null +++ b/scripts/stop.js @@ -0,0 +1,10 @@ +const { execSync } = require("child_process"); +const { killZoteroWindows, killZoteroUnix } = require("./zotero-cmd.json"); + +try { + if (process.platform === "win32") { + execSync(killZoteroWindows); + } else { + execSync(killZoteroUnix); + } +} catch (e) {} diff --git a/scripts/zotero-cmd-default.json b/scripts/zotero-cmd-default.json new file mode 100644 index 0000000..9bb8011 --- /dev/null +++ b/scripts/zotero-cmd-default.json @@ -0,0 +1,9 @@ +{ + "usage": "Copy and rename this file to zotero-cmd.json. Edit the cmd.", + "killZoteroWindows": "taskkill /f /im zotero.exe", + "killZoteroUnix": "kill -9 $(ps -x | grep zotero)", + "exec": { + "6": "/path/to/zotero6.exe", + "7": "/path/to/zotero7.exe" + } +} \ No newline at end of file diff --git a/src/addon.ts b/src/addon.ts new file mode 100644 index 0000000..d586b0b --- /dev/null +++ b/src/addon.ts @@ -0,0 +1,141 @@ +import api from "./api"; +import hooks from "./hooks"; +import { TranslateTask } from "./utils/translate"; +import { TranslationServices } from "./modules/services"; +import { config } from "../package.json"; + +class Addon { + public data: { + alive: boolean; + // Env type, see build.js + env: "development" | "production"; + ztoolkit: ZToolkit; + locale: { + stringBundle: any; + }; + prefs: { + window: Window | null; + }; + panel: { + tabOptionId: string; + activePanels: HTMLElement[]; + windowPanel: Window | null; + }; + popup: { + currentPopup: HTMLDivElement | null; + }; + translate: { + concatKey: boolean; + concatCheckbox: boolean; + queue: TranslateTask[]; + maximumQueueLength: number; + batchTaskDelay: number; + services: TranslationServices; + }; + }; + // Lifecycle hooks + public hooks: typeof hooks; + // APIs + public api: typeof api; + + constructor() { + this.data = { + alive: true, + env: __env__, + ztoolkit: new ZToolkit(), + locale: { stringBundle: null }, + prefs: { window: null }, + panel: { tabOptionId: "", activePanels: [], windowPanel: null }, + popup: { currentPopup: null }, + translate: { + concatKey: false, + concatCheckbox: false, + queue: [], + maximumQueueLength: 100, + batchTaskDelay: 1000, + services: new TranslationServices(), + }, + }; + this.hooks = hooks; + this.api = api; + } +} + +/** + * Alternatively, import toolkit modules you use to minify the plugin size. + * + * Steps to replace the default `ztoolkit: ZoteroToolkit` with your `ztoolkit: MyToolkit`: + * + * 1. Uncomment this file's line 30: `ztoolkit: new MyToolkit(),` + * and comment line 31: `ztoolkit: new ZoteroToolkit(),`. + * 2. Uncomment this file's line 10: `ztoolkit: MyToolkit;` in this file + * and comment line 11: `ztoolkit: ZoteroToolkit;`. + * 3. Uncomment `./typing/global.d.ts` line 12: `declare const ztoolkit: import("../src/addon").MyToolkit;` + * and comment line 13: `declare const ztoolkit: import("zotero-plugin-toolkit").ZoteroToolkit;`. + * + * You can now add the modules under the `MyToolkit` class. + */ + +import { BasicTool, unregister } from "zotero-plugin-toolkit/dist/basic"; +import { ToolkitGlobal } from "zotero-plugin-toolkit/dist/managers/toolkitGlobal"; +import { UITool } from "zotero-plugin-toolkit/dist/tools/ui"; +import { ShortcutManager } from "zotero-plugin-toolkit/dist/managers/shortcut"; +import { MenuManager } from "zotero-plugin-toolkit/dist/managers/menu"; +import { PreferencePaneManager } from "zotero-plugin-toolkit/dist/managers/preferencePane"; +import { ReaderTabPanelManager } from "zotero-plugin-toolkit/dist/managers/readerTabPanel"; +import { ReaderInstanceManager } from "zotero-plugin-toolkit/dist/managers/readerInstance"; +import { PromptManager } from "zotero-plugin-toolkit/dist/managers/prompt"; +import { ProgressWindowHelper } from "zotero-plugin-toolkit/dist/helpers/progressWindow"; +import { ClipboardHelper } from "zotero-plugin-toolkit/dist/helpers/clipboard"; +import { ReaderTool } from "zotero-plugin-toolkit/dist/tools/reader"; +import { ExtraFieldTool } from "zotero-plugin-toolkit/dist/tools/extraField"; +import { ItemTreeManager } from "zotero-plugin-toolkit/dist/managers/itemTree"; +import { ItemBoxManager } from "zotero-plugin-toolkit/dist/managers/itemBox"; +import { DialogHelper } from "zotero-plugin-toolkit/dist/helpers/dialog"; + +export class ZToolkit extends BasicTool { + Global: typeof ToolkitGlobal; + UI: UITool; + Reader: ReaderTool; + ExtraField: ExtraFieldTool; + Shortcut: ShortcutManager; + Menu: MenuManager; + ItemTree: ItemTreeManager; + ItemBox: ItemBoxManager; + Prompt: PromptManager; + PreferencePane: PreferencePaneManager; + ReaderTabPanel: ReaderTabPanelManager; + ReaderInstance: ReaderInstanceManager; + Dialog: typeof DialogHelper; + ProgressWindow: typeof ProgressWindowHelper; + Clipboard: typeof ClipboardHelper; + + constructor() { + super(); + this.Global = ToolkitGlobal; + this.UI = new UITool(this); + this.Reader = new ReaderTool(this); + this.ExtraField = new ExtraFieldTool(this); + this.Shortcut = new ShortcutManager(this); + this.Menu = new MenuManager(this); + this.ItemTree = new ItemTreeManager(this); + this.ItemBox = new ItemBoxManager(this); + this.PreferencePane = new PreferencePaneManager(this); + this.ReaderTabPanel = new ReaderTabPanelManager(this); + this.ReaderInstance = new ReaderInstanceManager(this); + this.Prompt = new PromptManager(this); + this.Dialog = DialogHelper; + this.ProgressWindow = ProgressWindowHelper; + this.ProgressWindow.setIconURI( + "default", + `chrome://${config.addonRef}/content/icons/favicon.png` + ); + this.Clipboard = ClipboardHelper; + } + + unregisterAll() { + unregister(this); + } +} + +export default Addon; diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..b076d73 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,30 @@ +import { getPref } from "./utils/prefs"; +import { TranslateTask } from "./utils/translate"; + +/** + * To plugin developers: Please use this API to translate your custom text. + * + * @param raw raw text for translation. + * @param service service id. See src/utils/config.ts > SERVICES + * @returns TranslateTask object. + */ +async function translate(raw: string, service?: string) { + const data: TranslateTask = { + id: `${Zotero.Utilities.randomString()}-${new Date().getTime()}`, + type: "custom", + raw, + result: "", + audio: [], + service: service || (getPref("translateSource") as string), + candidateServices: [], + itemId: -1, + status: "waiting", + extraTasks: [], + }; + await addon.data.translate.services.runTranslationTask(data, { + noDisplay: true, + }); + return data; +} + +export default { translate }; diff --git a/src/hooks.ts b/src/hooks.ts new file mode 100644 index 0000000..6634f88 --- /dev/null +++ b/src/hooks.ts @@ -0,0 +1,220 @@ +import { initLocale } from "./utils/locale"; +import { + registerPrefsScripts, + registerPrefsWindow, +} from "./modules/preferenceWindow"; +import { + registerReaderTabPanel, + updateReaderTabPanels, +} from "./modules/tabpanel"; +import { buildReaderPopup, updateReaderPopup } from "./modules/popup"; +import { registerNotify } from "./modules/notify"; +import { + checkReaderAnnotationButton, + registerReaderInitializer, + unregisterReaderInitializer, +} from "./modules/reader"; +import { getPref, setPref } from "./utils/prefs"; +import { + addTranslateAnnotationTask, + addTranslateTask, + addTranslateTitleTask, + getLastTranslateTask, + TranslateTask, +} from "./utils/translate"; +import { setDefaultPrefSettings } from "./modules/defaultSettings"; +import Addon from "./addon"; +import { registerMenu } from "./modules/menu"; +import { + registerExtraColumns, + registerTitleRenderer, +} from "./modules/itemTree"; +import { registerShortcuts } from "./modules/shortcuts"; +import { config } from "../package.json"; +import { registerItemBoxExtraRows } from "./modules/itemBox"; +import { registerPrompt } from "./modules/prompt"; + +async function onStartup() { + await Promise.all([ + Zotero.initializationPromise, + Zotero.unlockPromise, + Zotero.uiReadyPromise, + ]); + initLocale(); + + setDefaultPrefSettings(); + + registerNotify(["item"]); + registerReaderTabPanel(); + registerReaderInitializer(); + registerPrefsWindow(); + registerMenu(); + await registerExtraColumns(); + await registerItemBoxExtraRows(); + registerTitleRenderer(); + registerShortcuts(); + registerPrompt(); +} + +function onShutdown(): void { + ztoolkit.unregisterAll(); + unregisterReaderInitializer(); + // Remove addon object + addon.data.alive = false; + delete Zotero[config.addonInstance]; +} + +/** + * This function is just an example of dispatcher for Notify events. + * Any operations should be placed in a function to keep this function clear. + */ +function onNotify( + event: string, + type: string, + ids: Array, + extraData: { [key: string]: any } +) { + if (event === "add" && type === "item") { + const annotationItems = Zotero.Items.get(ids as number[]).filter((item) => + item.isAnnotation() + ); + if (annotationItems.length === 0) { + return; + } + checkReaderAnnotationButton(annotationItems); + if (getPref("enableComment")) { + addon.hooks.onTranslateInBatch( + annotationItems + .map((item) => addTranslateAnnotationTask(item.id)) + .filter((task) => task) as TranslateTask[], + { noDisplay: true } + ); + } + } else { + return; + } +} + +function onPrefsLoad(event: Event) { + registerPrefsScripts((event.target as any).ownerGlobal); +} + +function onShortcuts(type: string) { + switch (type) { + case "library": + { + addon.hooks.onSwitchTitleColumnDisplay(); + addon.hooks.onTranslateInBatch( + ZoteroPane.getSelectedItems(true) + .map((id) => addTranslateTitleTask(id, true)) + .filter((task) => task) as TranslateTask[], + { noDisplay: true } + ); + } + break; + case "reader": + { + addon.hooks.onTranslate(undefined, { + noCheckZoteroItemLanguage: true, + }); + } + break; + default: + break; + } +} + +async function onTranslate(): Promise; +async function onTranslate( + options: Parameters< + Addon["data"]["translate"]["services"]["runTranslationTask"] + >["1"] +): Promise; +async function onTranslate( + task: TranslateTask | undefined, + options?: Parameters< + Addon["data"]["translate"]["services"]["runTranslationTask"] + >["1"] +): Promise; +async function onTranslate(...data: any) { + let task = undefined; + let options = {}; + if (data.length === 1) { + if (data[0].raw) { + task = data[0]; + } else { + options = data[0]; + } + } else if (data.length === 2) { + task = data[0]; + options = data[1]; + } + await addon.data.translate.services.runTranslationTask(task, options); +} + +async function onTranslateInBatch( + tasks: TranslateTask[], + options: Parameters< + Addon["data"]["translate"]["services"]["runTranslationTask"] + >["1"] = {} +) { + for (const task of tasks) { + await addon.hooks.onTranslate(task, options); + await Zotero.Promise.delay(addon.data.translate.batchTaskDelay); + } +} + +function onReaderTextSelection(readerInstance: _ZoteroTypes.ReaderInstance) { + const selection = ztoolkit.Reader.getSelectedText(readerInstance); + const task = getLastTranslateTask(); + if (task?.raw === selection) { + addon.hooks.onReaderPopupBuild(readerInstance); + addon.hooks.onReaderPopupRefresh(); + return; + } + addTranslateTask(selection, readerInstance.itemID); + addon.hooks.onReaderPopupBuild(readerInstance); + addon.hooks.onReaderPopupRefresh(); + if (getPref("enableAuto")) { + addon.hooks.onTranslate(); + } +} + +function onReaderPopupBuild(readerInstance: _ZoteroTypes.ReaderInstance) { + buildReaderPopup(readerInstance); +} + +function onReaderPopupRefresh() { + updateReaderPopup(); +} + +function onReaderTabPanelRefresh() { + updateReaderTabPanels(); +} + +function onSwitchTitleColumnDisplay() { + setPref( + "titleColumnMode", + getPref("titleColumnMode") === "raw" ? "result" : "raw" + ); + ztoolkit.ItemTree.refresh(); +} + +// Add your hooks here. For element click, etc. +// Keep in mind hooks only do dispatch. Don't add code that does real jobs in hooks. +// Otherwise the code would be hard to read and maintain. + +export default { + onStartup, + onShutdown, + onNotify, + onPrefsLoad, + onShortcuts, + onTranslate, + onTranslateInBatch, + onReaderTextSelection, + onReaderPopupBuild, + onReaderPopupRefresh, + onReaderTabPanelRefresh, + onSwitchTitleColumnDisplay, +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..0a91857 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,28 @@ +import { BasicTool } from "zotero-plugin-toolkit/dist/basic"; +import Addon from "./addon"; +import { config } from "../package.json"; + +const basicTool = new BasicTool(); + +if (!basicTool.getGlobal("Zotero")[config.addonInstance]) { + // Set global variables + _globalThis.Zotero = basicTool.getGlobal("Zotero"); + _globalThis.ZoteroPane = basicTool.getGlobal("ZoteroPane"); + _globalThis.Zotero_Tabs = basicTool.getGlobal("Zotero_Tabs"); + _globalThis.ZoteroContextPane = basicTool.getGlobal("ZoteroContextPane"); + _globalThis.window = basicTool.getGlobal("window"); + _globalThis.document = basicTool.getGlobal("document"); + _globalThis.crypto = basicTool.getGlobal("crypto"); + _globalThis.TextEncoder = basicTool.getGlobal("TextEncoder"); + _globalThis.addon = new Addon(); + _globalThis.ztoolkit = addon.data.ztoolkit; + ztoolkit.basicOptions.log.prefix = `[${config.addonName}]`; + ztoolkit.basicOptions.log.disableConsole = addon.data.env === "production"; + ztoolkit.UI.basicOptions.ui.enableElementJSONLog = + addon.data.env === "development"; + // Set the default false and enable manually + ztoolkit.UI.basicOptions.ui.enableElementRecord = false; + Zotero[config.addonInstance] = addon; + // Trigger addon hook for initialization + addon.hooks.onStartup(); +} diff --git a/src/modules/defaultSettings.ts b/src/modules/defaultSettings.ts new file mode 100644 index 0000000..c487fec --- /dev/null +++ b/src/modules/defaultSettings.ts @@ -0,0 +1,67 @@ +import { getService, SERVICES } from "../utils/config"; +import { clearPref, getPref, setPref } from "../utils/prefs"; +import { setServiceSecret } from "../utils/translate"; + +export function setDefaultPrefSettings() { + const isZhCN = Zotero.locale === "zh-CN"; + const servicesIds = SERVICES.map((service) => service.id); + if (!servicesIds.includes((getPref("translateSource") as string) || "")) { + // Google Translate is not accessible in China mainland + setPref("translateSource", isZhCN ? "cnki" : "googleapi"); + } + if (!servicesIds.includes((getPref("dictSource") as string) || "")) { + setPref("dictSource", isZhCN ? "bingdict" : "freedictionaryapi"); + } + + if (!getPref("targetLanguage")) { + setPref("targetLanguage", Zotero.locale); + } + + const secrets = JSON.parse((getPref("secretObj") as string) || "{}"); + for (const serviceId of servicesIds) { + if (typeof secrets[serviceId] === "undefined") { + secrets[serviceId] = getService(serviceId).defaultSecret || ""; + } + } + setPref("secretObj", JSON.stringify(secrets)); + + if (isZhCN && !getPref("disabledLanguages")) { + setPref("disabledLanguages", "zh,中文,中文;"); + } + + const extraServices = getPref("extraEngines") as string; + if (extraServices.startsWith(",")) { + setPref("extraEngines", extraServices.slice(1)); + } + + // For NiuTrans login. niutransLog is deprecated. + const niutransApiKey = getPref("niutransApikey") as string; + if (niutransApiKey) { + setServiceSecret("niutranspro", niutransApiKey); + clearPref("niutransApikey"); + } + if (getPref("translateSource") === "niutransLog") { + setPref("translateSource", "niutranspro"); + } + try { + const oldDict = JSON.parse( + (getPref("niutransDictLibList") as string) || "{}" + ); + if (oldDict?.dlist) { + setPref("niutransDictLibList", JSON.stringify(oldDict.dlist)); + } else { + setPref("niutransDictLibList", "[]"); + } + const oldMemory = JSON.parse( + (getPref("niutransMemoryLibList") as string) || "{}" + ); + if (oldMemory?.mlist) { + setPref("niutransMemoryLibList", JSON.stringify(oldMemory?.mlist)); + } else { + setPref("niutransMemoryLibList", "[]"); + } + } catch (e) { + setPref("niutransDictLibList", "[]"); + setPref("niutransMemoryLibList", "[]"); + } +} diff --git a/src/modules/itemBox.ts b/src/modules/itemBox.ts new file mode 100644 index 0000000..a4d4fdb --- /dev/null +++ b/src/modules/itemBox.ts @@ -0,0 +1,42 @@ +import { getString } from "../utils/locale"; +import { getPref } from "../utils/prefs"; + +export async function registerItemBoxExtraRows() { + if (getPref("showItemBoxTitleTranslation") !== false) { + await ztoolkit.ItemBox.register( + "titleTranslation", + getString("field.titleTranslation"), + // getField hook is registered in itemTree.ts + undefined, + { + editable: false, + setFieldHook: (field, value, loadIn, item, original) => { + ztoolkit.ExtraField.setExtraField(item, field, value); + return true; + }, + index: 2, + multiline: true, + } + ); + } + + if (getPref("showItemBoxAbstractTranslation") !== false) { + await ztoolkit.ItemBox.register( + "abstractTranslation", + getString("field.abstractTranslation"), + (field, unformatted, includeBaseMapped, item, original) => { + return ztoolkit.ExtraField.getExtraField(item, field) || ""; + }, + { + editable: false, + setFieldHook: (field, value, loadIn, item, original) => { + ztoolkit.ExtraField.setExtraField(item, field, value); + return true; + }, + index: 3, + multiline: true, + collapsible: true, + } + ); + } +} diff --git a/src/modules/itemTree.ts b/src/modules/itemTree.ts new file mode 100644 index 0000000..01eee14 --- /dev/null +++ b/src/modules/itemTree.ts @@ -0,0 +1,33 @@ +import { getString } from "../utils/locale"; +import { getPref } from "../utils/prefs"; + +export async function registerExtraColumns() { + await ztoolkit.ItemTree.register( + "titleTranslation", + getString("field.titleTranslation"), + ( + field: string, + unformatted: boolean, + includeBaseMapped: boolean, + item: Zotero.Item + ) => { + return ztoolkit.ExtraField.getExtraField(item, field) || ""; + } + ); +} + +export function registerTitleRenderer() { + ztoolkit.ItemTree.addRenderCellHook( + "title", + (index: number, data: string, column: any, original: Function) => { + if (getPref("titleColumnMode") === "result") { + const item = (ZoteroPane.itemsView.getRow(index) as any) + .ref as Zotero.Item; + data = + ztoolkit.ExtraField.getExtraField(item, "titleTranslation") || data; + } + const span = original(index, data, column) as HTMLSpanElement; + return span; + } + ).then(() => ztoolkit.ItemTree.refresh()); +} diff --git a/src/modules/menu.ts b/src/modules/menu.ts new file mode 100644 index 0000000..6712ca1 --- /dev/null +++ b/src/modules/menu.ts @@ -0,0 +1,81 @@ +import { config } from "../../package.json"; +import { getString } from "../utils/locale"; +import { + addTranslateAbstractTask, + addTranslateTitleTask, + TranslateTask, +} from "../utils/translate"; + +export function registerMenu() { + const menuIcon = `chrome://${config.addonRef}/content/icons/favicon@0.5x.png`; + ztoolkit.Menu.register("item", { + tag: "menuseparator", + }); + ztoolkit.Menu.register("item", { + tag: "menuitem", + label: getString("itemmenu.translateTitle.label"), + commandListener: (ev) => { + addon.hooks.onTranslateInBatch( + ZoteroPane.getSelectedItems(true) + .map((id) => addTranslateTitleTask(id, true)) + .filter((task) => task) as TranslateTask[], + { noDisplay: true } + ); + }, + icon: menuIcon, + }); + ztoolkit.Menu.register("item", { + tag: "menuitem", + label: getString("itemmenu.translateAbstract.label"), + commandListener: (ev) => { + addon.hooks.onTranslateInBatch( + ZoteroPane.getSelectedItems(true) + .map((id) => addTranslateAbstractTask(id, true)) + .filter((task) => task) as TranslateTask[], + { noDisplay: true } + ); + }, + icon: menuIcon, + }); + ztoolkit.Menu.register("item", { + tag: "menuitem", + label: `${getString("itemmenu.switchTitleMode.label")}(${getString( + "ctrl" + )} + T)`, + commandListener: (ev) => { + addon.hooks.onSwitchTitleColumnDisplay(); + }, + icon: menuIcon, + }); + ztoolkit.Menu.register("item", { + tag: "menu", + label: getString("itemmenu.more.label"), + children: [ + { + tag: "menuitem", + label: getString("itemmenu.retranslateTitle.label"), + commandListener: (ev) => { + addon.hooks.onTranslateInBatch( + ZoteroPane.getSelectedItems(true) + .map((id) => addTranslateTitleTask(id, false)) + .filter((task) => task) as TranslateTask[], + { noDisplay: true } + ); + }, + }, + { + tag: "menuitem", + label: getString("itemmenu.retranslateAbstract.label"), + commandListener: (ev) => { + addon.hooks.onTranslateInBatch( + ZoteroPane.getSelectedItems(true) + .map((id) => addTranslateAbstractTask(id, false)) + .filter((task) => task) as TranslateTask[], + { noDisplay: true } + ); + }, + }, + ], + icon: menuIcon, + }); +} diff --git a/src/modules/notify.ts b/src/modules/notify.ts new file mode 100644 index 0000000..6685ec4 --- /dev/null +++ b/src/modules/notify.ts @@ -0,0 +1,27 @@ +export function registerNotify(types: _ZoteroTypes.Notifier.Type[]) { + const callback = { + notify: async (...data: Parameters<_ZoteroTypes.Notifier.Notify>) => { + if (!addon?.data.alive) { + unregisterNotify(notifyID); + return; + } + addon.hooks.onNotify(...data); + }, + }; + + // Register the callback in Zotero as an item observer + const notifyID = Zotero.Notifier.registerObserver(callback, types); + + // Unregister callback when the window closes (important to avoid a memory leak) + window.addEventListener( + "unload", + (e: Event) => { + unregisterNotify(notifyID); + }, + false + ); +} + +function unregisterNotify(notifyID: string) { + Zotero.Notifier.unregisterObserver(notifyID); +} diff --git a/src/modules/popup.ts b/src/modules/popup.ts new file mode 100644 index 0000000..fe36854 --- /dev/null +++ b/src/modules/popup.ts @@ -0,0 +1,422 @@ +import { SVGIcon } from "../utils/config"; +import { config } from "../../package.json"; +import { getString } from "../utils/locale"; +import { getPref, setPref } from "../utils/prefs"; +import { addTranslateTask, getLastTranslateTask } from "../utils/translate"; +import { slice } from "../utils/str"; + +export function updateReaderPopup() { + const popup = addon.data.popup.currentPopup; + if (!popup) { + return; + } + const enablePopup = getPref("enablePopup"); + const hidePopupTextarea = getPref("enableHidePopupTextarea") as boolean; + Array.from(popup.querySelectorAll(`.${config.addonRef}-readerpopup`)).forEach( + (elem) => ((elem as HTMLElement).hidden = !enablePopup) + ); + if (!enablePopup) { + return; + } + const task = getLastTranslateTask(); + if (!task) { + return; + } + popup.setAttribute("translate-task-id", task.id); + const idPrefix = popup?.getAttribute(`${config.addonRef}-prefix`); + const makeId = (type: string) => `${idPrefix}-${type}`; + + const audiobox = popup?.querySelector( + `#${makeId("audiobox")}` + ) as HTMLDivElement; + const translateButton = popup?.querySelector( + `#${makeId("translate")}` + ) as HTMLDivElement; + const textarea = popup?.querySelector( + `#${makeId("text")}` + ) as HTMLTextAreaElement; + const addToNoteButton = popup?.querySelector( + `#${makeId("addtonote")}` + ) as HTMLDivElement; + if (task.audio.length > 0 && getPref("showPlayBtn")) { + audiobox.innerHTML = ""; + ztoolkit.UI.appendElement( + { + tag: "fragment", + children: task.audio.map((audioData) => ({ + tag: "button", + namespace: "html", + classList: ["toolbarButton"], + attributes: { + tabindex: "-1", + title: audioData.text, + }, + properties: { + innerHTML: `🔊 ${audioData.text}`, + onclick: () => { + new (ztoolkit.getGlobal("Audio"))(audioData.url).play(); + }, + }, + styles: { whiteSpace: "nowrap", flexGrow: "1" }, + })), + }, + audiobox + ); + } + translateButton.hidden = task.status !== "waiting"; + textarea.hidden = hidePopupTextarea || task.status === "waiting"; + textarea.value = task.result || task.raw; + textarea.style.fontSize = `${getPref("fontSize")}px`; + textarea.style.lineHeight = `${ + Number(getPref("lineHeight")) * Number(getPref("fontSize")) + }px`; + addToNoteButton.hidden = !Boolean(ZoteroContextPane.getActiveEditor()); + updatePopupSize(popup, textarea); +} + +export function buildReaderPopup(readerInstance: _ZoteroTypes.ReaderInstance) { + const popup = readerInstance._iframeWindow?.document.querySelector( + "#selection-menu" + ) as HTMLDivElement; + if (!popup) { + return; + } + addon.data.popup.currentPopup = popup; + popup.style.height = "-moz-fit-content"; + popup.setAttribute( + `${config.addonRef}-prefix`, + `${config.addonRef}-${readerInstance._instanceID}` + ); + + const colors = popup.querySelector(".colors") as HTMLDivElement; + colors.style.width = "100%"; + colors.style.justifyContent = "space-evenly"; + + const keepSize = getPref("keepPopupSize") as boolean; + + const makeId = (type: string) => + `${config.addonRef}-${readerInstance._instanceID}-${type}`; + + const onTextAreaCopy = getOnTextAreaCopy(popup, makeId("text")); + const hidePopupTextarea = getPref("enableHidePopupTextarea") as boolean; + + ztoolkit.UI.appendElement( + { + tag: "fragment", + children: [ + { + tag: "div", + id: makeId("audiobox"), + classList: [`${config.addonRef}-readerpopup`], + styles: { + display: "flex", + width: "calc(100% - 4px)", + marginLeft: "2px", + justifyContent: "space-evenly", + }, + ignoreIfExists: true, + }, + { + tag: "div", + id: makeId("translate"), + classList: ["wide-button", `${config.addonRef}-readerpopup`], + properties: { + innerHTML: `${SVGIcon}${getString("readerpopup.translate.label")}`, + hidden: getPref("enableAuto"), + }, + listeners: [ + { + type: "mouseup", + listener: (ev: Event) => { + addon.hooks.onTranslate({ noCheckZoteroItemLanguage: true }); + const button = ev.target as HTMLDivElement; + button.hidden = true; + ( + button.ownerDocument.querySelector( + `#${makeId("text")}` + ) as HTMLTextAreaElement + ).hidden = hidePopupTextarea; + }, + }, + ], + ignoreIfExists: true, + }, + { + tag: "textarea", + id: makeId("text"), + attributes: { + rows: "3", + columns: "10", + }, + classList: [ + `${config.addonRef}-popup-textarea`, + `${config.addonRef}-readerpopup`, + ], + styles: { + fontSize: `${getPref("fontSize")}px`, + fontFamily: "inherit", + lineHeight: `${ + Number(getPref("lineHeight")) * Number(getPref("fontSize")) + }px`, + width: keepSize ? `${getPref("popupWidth")}px` : "-moz-available", + height: `${Math.max( + keepSize ? Number(getPref("popupHeight")) : 30 + )}px`, + marginLeft: "2px", + // @ts-ignore + scrollbarWidth: "none", + }, + properties: { + onpointerup: (e: Event) => e.stopPropagation(), + ondragstart: (e: Event) => e.stopPropagation(), + spellcheck: false, + value: ztoolkit.Reader.getSelectedText(readerInstance), + }, + ignoreIfExists: true, + listeners: [ + { + type: "mousedown", + listener: (_ev) => { + _ev.target?.addEventListener( + "mousemove", + onTextAreaResize as (ev: Event) => void + ); + }, + }, + { + type: "mouseup", + listener: (_ev) => { + _ev.target?.removeEventListener( + "mousemove", + onTextAreaResize as (ev: Event) => void + ); + const textarea = popup.querySelector( + `#${makeId("text")}` + ) as HTMLTextAreaElement; + if (popup.scrollWidth > textarea.offsetWidth + 4) { + textarea.style.width = `${popup.scrollWidth - 4}px`; + } + }, + }, + { + type: "mouseenter", + listener: (_ev) => { + _ev.target?.addEventListener( + "keydown", + onTextAreaCopy as (ev: Event) => void + ); + const head = + readerInstance._iframe.contentWindow.document.querySelector( + "head" + ); + ztoolkit.UI.appendElement( + { + tag: "style", + id: makeId("style"), + properties: { + innerHTML: `.${config.addonRef}-popup-textarea::-moz-selection {background: #7fbbea;}`, + }, + skipIfExists: true, + }, + head + ); + }, + }, + { + type: "mouseleave", + listener: (_ev) => { + _ev.target?.removeEventListener( + "keydown", + onTextAreaCopy as (ev: Event) => void + ); + const head = + readerInstance._iframe.contentWindow.document.querySelector( + "head" + ); + ztoolkit.UI.appendElement( + { + tag: "style", + id: makeId("style"), + properties: { + innerHTML: `.${config.addonRef}-popup-textarea::-moz-selection {background: #bfbfbf;}`, + }, + skipIfExists: true, + }, + head + ); + }, + }, + { + type: "dblclick", + listener: (_ev) => { + const textarea = popup.querySelector( + `#${makeId("text")}` + ) as HTMLTextAreaElement; + textarea.selectionStart = 0; + textarea.selectionEnd = textarea.value.length; + const text = textarea.value.slice( + textarea.selectionStart, + textarea.selectionEnd + ); + new ztoolkit.Clipboard().addText(text, "text/unicode").copy(); + new ztoolkit.ProgressWindow("Copied to Clipboard") + .createLine({ + text: slice(text, 50), + progress: 100, + type: "default", + }) + .show(); + }, + }, + ], + }, + { + tag: "div", + id: makeId("addtonote"), + classList: ["wide-button", `${config.addonRef}-readerpopup`], + properties: { + innerHTML: `${SVGIcon}${Zotero.getString("pdfReader.addToNote")}`, + }, + ignoreIfExists: true, + listeners: [ + { + type: "mouseup", + listener: async (ev) => { + const noteEditor = + ZoteroContextPane && ZoteroContextPane.getActiveEditor(); + if (!noteEditor) { + return; + } + const editorInstance = noteEditor.getCurrentInstance(); + if (!editorInstance) { + return; + } + const selection = + ztoolkit.Reader.getSelectedText(readerInstance); + const task = addTranslateTask( + selection, + readerInstance.itemID, + "text" + ); + if (!task) { + return; + } + await addon.hooks.onTranslate(task, { + noCheckZoteroItemLanguage: true, + noDisplay: true, + }); + if (task.status !== "success") { + return; + } + const replaceMode = getPref("enableNoteReplaceMode") as boolean; + const { html } = + Zotero.EditorInstanceUtilities.serializeAnnotations([ + { + type: "highlight", + text: replaceMode ? task.result : task.raw, + comment: replaceMode ? "" : task.result, + attachmentItemID: task.itemId, + pageLabel: + // @ts-ignore + readerInstance._iframeWindow.wrappedJSObject.extractor + .pageLabelsCache[readerInstance.state.pageIndex], + position: { + rects: [], + }, + }, + ]); + editorInstance._postMessage({ + action: "insertHTML", + pos: null, + html, + }); + }, + }, + ], + }, + ], + }, + popup + ); +} + +function onTextAreaResize(ev: MouseEvent) { + if (getPref("keepPopupSize")) { + const textarea = ev.target as HTMLTextAreaElement; + setPref("popupWidth", textarea.offsetWidth); + setPref("popupHeight", textarea.offsetHeight); + } +} + +function getOnTextAreaCopy(selectionMenu: HTMLElement, targetId: string) { + return (ev: KeyboardEvent) => { + const textarea = selectionMenu.querySelector( + `#${targetId}` + ) as HTMLTextAreaElement; + const isMod = ev.ctrlKey || ev.metaKey; + if (ev.key === "c" && isMod) { + ztoolkit.getGlobal("setTimeout")(() => { + new ztoolkit.Clipboard() + .addText( + textarea.value.slice( + textarea.selectionStart, + textarea.selectionEnd + ), + "text/unicode" + ) + .copy(); + }, 10); + ev.stopPropagation(); + } else if (ev.key === "a" && isMod) { + textarea.selectionStart = 0; + textarea.selectionEnd = textarea.value.length; + ev.stopPropagation(); + } else if (ev.key === "x" && isMod) { + new ztoolkit.Clipboard() + .addText( + textarea.value.slice(textarea.selectionStart, textarea.selectionEnd), + "text/unicode" + ) + .copy(); + textarea.value = `${textarea.value.slice( + 0, + textarea.selectionStart + )}${textarea.value.slice(textarea.selectionEnd)}`; + ev.stopPropagation(); + } + }; +} + +function updatePopupSize( + selectionMenu: HTMLDivElement, + textarea: HTMLTextAreaElement, + resetSize: boolean = true +): void { + const keepSize = getPref("keepPopupSize") as boolean; + if (keepSize) { + return; + } + if (resetSize) { + textarea.style.width = "-moz-available"; + textarea.style.height = "30px"; + } + const viewer = selectionMenu.ownerDocument.querySelector( + "#viewer" + ) as HTMLDivElement; + // Get current H & W + let textHeight = textarea.scrollHeight; + let textWidth = textarea.scrollWidth; + const newWidth = textWidth + 20; + // Check until H/W<0.75 and don't overflow viewer border + if ( + textHeight / textWidth > 0.75 && + selectionMenu.offsetLeft + newWidth < viewer.offsetWidth + ) { + // Update width + textarea.style.width = `${newWidth}px`; + updatePopupSize(selectionMenu, textarea, false); + return; + } + // Update height + textarea.style.height = `${textHeight + 3}px`; +} diff --git a/src/modules/preferenceWindow.ts b/src/modules/preferenceWindow.ts new file mode 100644 index 0000000..ddf17bf --- /dev/null +++ b/src/modules/preferenceWindow.ts @@ -0,0 +1,438 @@ +import { config } from "../../package.json"; +import { getService, LANG_CODE, SERVICES } from "../utils/config"; +import { getString } from "../utils/locale"; +import { getPref, setPref } from "../utils/prefs"; +import { + validateServiceSecret, + secretStatusButtonData, + setServiceSecret, +} from "../utils/translate"; + +export function registerPrefsWindow() { + ztoolkit.PreferencePane.register({ + pluginID: config.addonID, + src: rootURI + "chrome/content/preferences.xhtml", + label: getString("pref.title"), + image: `chrome://${config.addonRef}/content/icons/favicon.png`, + extraDTD: [`chrome://${config.addonRef}/locale/overlay.dtd`], + defaultXUL: true, + }); +} + +export function registerPrefsScripts(_window: Window) { + // This function is called when the prefs window is opened + addon.data.prefs.window = _window; + buildPrefsPane(); + updatePrefsPaneDefault(); +} + +function buildPrefsPane() { + const doc = addon.data.prefs.window?.document; + if (!doc) { + return; + } + // menus + ztoolkit.UI.replaceElement( + { + tag: "menulist", + id: makeId("sentenceServices"), + attributes: { + value: getPref("translateSource") as string, + native: "true", + }, + listeners: [ + { + type: "command", + listener: (e: Event) => { + onPrefsEvents("setSentenceService"); + }, + }, + ], + children: [ + { + tag: "menupopup", + children: SERVICES.filter( + (service) => service.type === "sentence" + ).map((service) => ({ + tag: "menuitem", + attributes: { + label: getString(`service.${service.id}`), + value: service.id, + }, + })), + }, + ], + }, + doc.querySelector(`#${makeId("sentenceServices-placeholder")}`)! + ); + + ztoolkit.UI.replaceElement( + { + tag: "menulist", + id: makeId("wordServices"), + attributes: { + value: getPref("dictSource") as string, + native: "true", + }, + classList: ["use-word-service"], + listeners: [ + { + type: "command", + listener: (e: Event) => { + onPrefsEvents("setWordService"); + }, + }, + ], + children: [ + { + tag: "menupopup", + children: SERVICES.filter((service) => service.type === "word").map( + (service) => ({ + tag: "menuitem", + attributes: { + label: getString(`service.${service.id}`), + value: service.id, + }, + }) + ), + }, + ], + }, + doc.querySelector(`#${makeId("wordServices-placeholder")}`)! + ); + + ztoolkit.UI.replaceElement( + { + tag: "menulist", + id: makeId("langfrom"), + attributes: { + value: getPref("sourceLanguage") as string, + native: "true", + }, + listeners: [ + { + type: "command", + listener: (e: Event) => { + onPrefsEvents("setSourceLanguage"); + }, + }, + ], + styles: { + maxWidth: "250px", + }, + children: [ + { + tag: "menupopup", + children: LANG_CODE.map((lang) => ({ + tag: "menuitem", + attributes: { + label: lang.name, + value: lang.code, + }, + })), + }, + ], + }, + doc.querySelector(`#${makeId("langfrom-placeholder")}`)! + ); + + ztoolkit.UI.replaceElement( + { + tag: "menulist", + id: makeId("langto"), + attributes: { + value: getPref("targetLanguage") as string, + native: "true", + }, + listeners: [ + { + type: "command", + listener: (e: Event) => { + onPrefsEvents("setTargetLanguage"); + }, + }, + ], + styles: { + maxWidth: "250px", + }, + children: [ + { + tag: "menupopup", + children: LANG_CODE.map((lang) => ({ + tag: "menuitem", + attributes: { + label: lang.name, + value: lang.code, + }, + })), + }, + ], + }, + doc.querySelector(`#${makeId("langto-placeholder")}`)! + ); + + doc + .querySelector(`#${makeId("enableAuto")}`) + ?.addEventListener("command", (e: Event) => { + onPrefsEvents("setAutoTranslateSelection"); + }); + + doc + .querySelector(`#${makeId("enableComment")}`) + ?.addEventListener("command", (e: Event) => { + onPrefsEvents("setAutoTranslateAnnotation"); + }); + + doc + .querySelector(`#${makeId("enablePopup")}`) + ?.addEventListener("command", (e: Event) => { + onPrefsEvents("setEnablePopup"); + }); + + doc + .querySelector(`#${makeId("enableAddToNote")}`) + ?.addEventListener("command", (e: Event) => { + onPrefsEvents("setEnableAddToNote"); + }); + + doc + .querySelector(`#${makeId("useWordService")}`) + ?.addEventListener("command", (e: Event) => { + onPrefsEvents("setUseWordService"); + }); + + doc + .querySelector(`#${makeId("sentenceServicesSecret")}`) + ?.addEventListener("input", (e: Event) => { + onPrefsEvents("updateSentenceSecret"); + }); + + doc + .querySelector(`#${makeId("wordServicesSecret")}`) + ?.addEventListener("input", (e: Event) => { + onPrefsEvents("updateWordSecret"); + }); + + doc + .querySelector(`#${makeId("fontSize")}`) + ?.addEventListener("input", (e: Event) => { + onPrefsEvents("updateFontSize"); + }); + + doc + .querySelector(`#${makeId("lineHeight")}`) + ?.addEventListener("input", (e: Event) => { + onPrefsEvents("updatelineHeight"); + }); +} + +function updatePrefsPaneDefault() { + onPrefsEvents("setAutoTranslateAnnotation", false); + onPrefsEvents("setEnablePopup", false); + onPrefsEvents("setUseWordService", false); + onPrefsEvents("setSentenceSecret", false); + onPrefsEvents("setWordSecret", false); +} + +function onPrefsEvents(type: string, fromElement: boolean = true) { + const doc = addon.data.prefs.window?.document; + if (!doc) { + return; + } + + const setDisabled = (className: string, disabled: boolean) => { + doc + .querySelectorAll(`.${className}`) + .forEach( + (elem) => ((elem as XUL.Element & XUL.IDisabled).disabled = disabled) + ); + }; + switch (type) { + case "setAutoTranslateSelection": + addon.hooks.onReaderTabPanelRefresh(); + break; + case "setAutoTranslateAnnotation": + { + let elemValue = fromElement + ? (doc.querySelector(`#${makeId("enableComment")}`) as XUL.Checkbox) + .checked + : (getPref("enableComment") as boolean); + const hidden = !elemValue; + setDisabled("auto-annotation", hidden); + addon.hooks.onReaderTabPanelRefresh(); + } + break; + case "setEnablePopup": + { + let elemValue = fromElement + ? (doc.querySelector(`#${makeId("enablePopup")}`) as XUL.Checkbox) + .checked + : (getPref("enablePopup") as boolean); + const hidden = !elemValue; + setDisabled("enable-popup", hidden); + if (!hidden) { + onPrefsEvents("setEnableAddToNote", fromElement); + } + } + break; + case "setEnableAddToNote": + { + let elemValue = fromElement + ? (doc.querySelector(`#${makeId("enableAddToNote")}`) as XUL.Checkbox) + .checked + : (getPref("enableNote") as boolean); + const hidden = !elemValue; + setDisabled("enable-popup-addtonote", hidden); + } + break; + case "setUseWordService": + { + let elemValue = fromElement + ? (doc.querySelector(`#${makeId("useWordService")}`) as XUL.Checkbox) + .checked + : (getPref("enableDict") as boolean); + const hidden = !elemValue; + setDisabled("use-word-service", hidden); + } + break; + case "setSentenceService": + { + setPref( + "translateSource", + ( + doc.querySelector(`#${makeId("sentenceServices")}`) as XUL.MenuList + ).getAttribute("value")! + ); + onPrefsEvents("setSentenceSecret", fromElement); + addon.hooks.onReaderTabPanelRefresh(); + } + break; + case "updateSentenceSecret": + { + setServiceSecret( + getPref("translateSource") as string, + ( + doc.querySelector( + `#${makeId("sentenceServicesSecret")}` + ) as HTMLInputElement + ).value + ); + } + break; + case "setSentenceSecret": + { + const serviceId = getPref("translateSource") as string; + const secretCheckResult = validateServiceSecret( + serviceId, + (validateResult) => { + if (fromElement && !validateResult.status) { + addon.data.prefs.window?.alert( + `You see this because the translation service ${serviceId} requires SECRET, which is NOT correctly set.\n\nDetails:\n${validateResult.info}` + ); + } + } + ); + ( + doc.querySelector( + `#${makeId("sentenceServicesSecret")}` + ) as HTMLInputElement + ).value = secretCheckResult.secret; + // Update secret status button + const statusButtonData = secretStatusButtonData[serviceId]; + const statusButton = doc.querySelector( + `#${makeId("sentenceServicesStatus")}` + ) as XUL.Button; + if (statusButtonData) { + statusButton.hidden = false; + statusButton.label = getString( + statusButtonData.labels[secretCheckResult.status ? "pass" : "fail"] + ); + statusButton.onclick = (ev) => { + statusButtonData.callback(secretCheckResult.status); + }; + } else { + statusButton.hidden = true; + } + } + break; + case "setWordService": + { + setPref( + "dictSource", + ( + doc.querySelector(`#${makeId("wordServices")}`) as XUL.MenuList + ).getAttribute("value")! + ); + onPrefsEvents("setWordSecret", fromElement); + } + break; + case "updateWordSecret": + { + setServiceSecret( + getPref("dictSource") as string, + ( + doc.querySelector( + `#${makeId("wordServicesSecret")}` + ) as HTMLInputElement + ).value + ); + } + break; + case "setWordSecret": + { + const serviceId = getPref("dictSource") as string; + const secretCheckResult = validateServiceSecret( + serviceId, + (validateResult) => { + if (fromElement && !validateResult.status) { + addon.data.prefs.window?.alert( + `You see this because the translation service ${serviceId} requires SECRET, which is NOT correctly set.\n\nDetails:\n${validateResult.info}` + ); + } + } + ); + ( + doc.querySelector( + `#${makeId("wordServicesSecret")}` + ) as HTMLInputElement + ).value = secretCheckResult.secret; + } + break; + case "setSourceLanguage": + { + setPref( + "sourceLanguage", + ( + doc.querySelector(`#${makeId("langfrom")}`) as XUL.MenuList + ).getAttribute("value")! + ); + addon.hooks.onReaderTabPanelRefresh(); + } + break; + case "setTargetLanguage": + { + setPref( + "targetLanguage", + ( + doc.querySelector(`#${makeId("langto")}`) as XUL.MenuList + ).getAttribute("value")! + ); + addon.hooks.onReaderTabPanelRefresh(); + } + break; + case "updateFontSize": + addon.hooks.onReaderPopupRefresh(); + addon.hooks.onReaderTabPanelRefresh(); + break; + case "updatelineHeight": + addon.hooks.onReaderPopupRefresh(); + addon.hooks.onReaderTabPanelRefresh(); + break; + default: + return; + } +} + +function makeId(type: string) { + return `${config.addonRef}-${type}`; +} diff --git a/src/modules/prompt.ts b/src/modules/prompt.ts new file mode 100644 index 0000000..4767888 --- /dev/null +++ b/src/modules/prompt.ts @@ -0,0 +1,227 @@ +import { config } from "../../package.json"; + +export function registerPrompt() { + let getSelection = () => { + return ztoolkit.Reader.getSelectedText( + Zotero.Reader.getByTabID(Zotero_Tabs.selectedID) + ); + } + ztoolkit.Prompt.register([{ + name: "Translate Sentences", + label: config.addonInstance, + when: () => { + const selection = getSelection(); + const sl = Zotero.Prefs.get("ZoteroPDFTranslate.sourceLanguage") as string + const tl = Zotero.Prefs.get("ZoteroPDFTranslate.targetLanguage") as string + return selection.length > 0 && Zotero?.PDFTranslate && sl.startsWith("en") && tl.startsWith("zh") + }, + callback: async (prompt) => { + const selection = getSelection(); + const queue = Zotero.PDFTranslate.data.translate.queue + let task = queue.find((task: any) => task.raw == selection && task.result.length > 0) + task = null + if (!task) { + prompt.showTip("Loading...") + task = await Zotero.PDFTranslate.api.translate(selection) + Zotero.PDFTranslate.data.translate.queue.push(task) + // @ts-ignore + prompt.exit() + } + prompt.inputNode.placeholder = task.service + const rawText = task.raw, resultText = task.result; + let addSentences = (node: HTMLElement, text: string, dividers: string[]) => { + let i = 0 + let sentences: string[] = [] + let sentence = "" + // https://www.npmjs.com/package/sentence-extractor?activeTab=explore + const abbrs = ["a.m.", "p.m.", "vol.", "inc.", "jr.", "dr.", "tex.", "co.", "prof.", "rev.", "revd.", "hon.", "v.s.", "i.e.", "ie.", + "eg.", "e.g.", "al.", "st.", "ph.d.", "capt.", "mr.", "mrs.", "ms.", "fig."] + let getWord = (i: number) => { + let before, after; + before = text.slice(0, i).match(/[\.a-zA-Z]+$/) + after = text.slice(i + 1).match(/^[\.a-zA-Z]+/) + let word = ([before, ["."], after].filter(i => i) as string[][]) + .map((i: string[]) => i[0]).join("") + return word + } + let isAbbr = (i: number) => { + const word = getWord(i).toLowerCase().replace(/\s+/g, " ") + return abbrs.find((abbr: string) => { + abbr = abbr.toLowerCase() + return word == abbr + }) + } + let isPotentialAbbr = (i: number) => { + const word = getWord(i) + let parts = word.split(".").filter(i => i) + return parts.length > 2 && parts.every(part => part.length <= 2) + } + while (i < text.length) { + let char = text[i] + sentence += char + if (dividers.indexOf(char) != -1) { + if (char == ".") { + if ( + (i + 1 < text.length && text[i + 1] != " ") || + ( + (isAbbr(i) || isPotentialAbbr(i)) + ) + ) { + i += 1 + continue + } + } + const blank = " " + i += 1 + while (text[i] == blank) { + sentence += blank + i += 1 + } + sentences.push(sentence) + sentence = "" + continue + } + i += 1 + } + for (let i = 0; i < sentences.length; i++) { + const span = ztoolkit.UI.appendElement( + { + tag: "span", + id: `sentence-${i}`, + properties: { + innerText: sentences[i] + }, + styles: { + borderRadius: "3px" + }, + listeners: [ + { + type: "mousemove", + listener: () => { + const highlightColor = "#fee972"; + + let twinNode = [...container.querySelectorAll(".text-container")] + .find(e => e != node) as HTMLDivElement; + + node.querySelectorAll("span").forEach(e => e.style.backgroundColor = "") + span.style.backgroundColor = highlightColor + + twinNode?.querySelectorAll("span").forEach(e => e.style.backgroundColor = ""); + + const twinSpan = twinNode.querySelector(`span[id=sentence-${i}]`) as HTMLSpanElement + + twinSpan.style.backgroundColor = highlightColor; + + const twinNodeContainer = twinNode.parentNode as HTMLDivElement; + const nodeContainer = node.parentNode as HTMLDivElement; + if (direction == "column" && twinNode.classList.contains("result")) { + twinNodeContainer.scrollTo(0, twinSpan.offsetTop - twinNodeContainer.offsetHeight * .5 - nodeContainer.offsetHeight); + } else { + twinNodeContainer.scrollTo(0, twinSpan.offsetTop - twinNodeContainer.offsetHeight * .5); + } + } + } + ] + }, + node + ) + } + } + const container = prompt.createCommandsContainer() as HTMLDivElement + // TODO: prefs: direction + const directions = ["row", "column"] + const direction = directions[1] + container.setAttribute("style", ` + display: flex; + flex-direction: ${direction}; + padding: .5em 1em; + margin-left: 0px; + width: 100%; + height: 25em; + `) + const subContainers: HTMLDivElement[] = []; + [ + ["raw", rawText, [".", "?", "!"]], + ["result", resultText, ["?", "!", "!", "。", "?"]] + ].forEach((args: any[]) => { + let [className, text, dividers] = args; + const subContainer = ztoolkit.UI.createElement(document, "div", { + styles: { + padding: ".5em", + border: "1px solid #eee", + overflowY: "auto", + minWidth: "10em", + minHeight: "5em", + height: "100%", + width: "100%", + textAlign: "justify", + }, + children: [ + { + tag: "div", + classList: [className, "text-container"], + styles: { + fontSize: "1em", + lineHeight: "1.5em", + marginBottom: ".5em" + }, + } + ] + }); + addSentences(subContainer.querySelector(".text-container")!, text, dividers); + subContainers.push(subContainer); + }) + + const size = 5 + const resizer = ztoolkit.UI.createElement(document, "div", { + styles: { + height: (direction == "row" ? "100%" : `${size}px`), + width: (direction == "column" ? "100%" : `${size}px`), + backgroundColor: "#f0f0f0", + cursor: direction == "column" ? "ns-resize" : "ew-resize", + }, + }) + let y = 0, x = 0; + let h = 0, w = 0; + const rect = container.getBoundingClientRect(); + const H = rect.height; + const W = rect.width; + const mouseDownHandler = function (e: MouseEvent) { + // hide + subContainers.forEach(div => { + div.querySelectorAll("span").forEach((e: HTMLSpanElement) => e.style.display = "none") + }) + y = e.clientY; + x = e.clientX; + const rect = subContainers[1].getBoundingClientRect() + h = rect.height; + w = rect.width; + document.addEventListener('mousemove', mouseMoveHandler); + document.addEventListener('mouseup', mouseUpHandler); + }; + const mouseMoveHandler = function (e: MouseEvent) { + const dy = e.clientY - y; + const dx = e.clientX - x; + if (direction == "column") { + subContainers[1].style.height = `${h - dy}px`; + subContainers[0].style.height = `${H - (h - dy) - size}px`; + } + if (direction == "row") { + subContainers[1].style.width = `${w - dx}px`; + subContainers[0].style.width = `${W - (w - dx) - size}px`; + } + }; + const mouseUpHandler = function () { + // show + subContainers.forEach(div => { + div.querySelectorAll("span").forEach((e: HTMLSpanElement) => e.style.display = "") + }) + document.removeEventListener('mousemove', mouseMoveHandler); + document.removeEventListener('mouseup', mouseUpHandler); + }; + resizer.addEventListener('mousedown', mouseDownHandler); + + container.append(subContainers[0], resizer, subContainers[1]) + } + }]) +} diff --git a/src/modules/reader.ts b/src/modules/reader.ts new file mode 100644 index 0000000..d8b9ff8 --- /dev/null +++ b/src/modules/reader.ts @@ -0,0 +1,184 @@ +import { config } from "../../package.json"; +import { SVGIcon } from "../utils/config"; +import { addTranslateAnnotationTask } from "../utils/translate"; + +export function registerReaderInitializer() { + ztoolkit.ReaderInstance.register( + "initialized", + `${config.addonRef}-selection`, + initializeReaderSelectionEvent + ); + ztoolkit.ReaderInstance.register( + "initialized", + `${config.addonRef}-annotationButtons`, + initializeReaderAnnotationButton + ); + // Force re-initialize + Zotero.Reader._readers.forEach((r) => { + initializeReaderSelectionEvent(r); + initializeReaderAnnotationButton(r); + }); +} + +export function unregisterReaderInitializer() { + Zotero.Reader._readers.forEach((r) => { + unInitializeReaderAnnotationButton(r); + unInitializeReaderSelectionEvent(r); + }); +} + +export async function checkReaderAnnotationButton(items: Zotero.Item[]) { + const hitSet = new Set(); + let t = 0; + const period = 100; + const wait = 5000; + while (items.length > hitSet.size && t < wait) { + for (const instance of Zotero.Reader._readers) { + const hitItems = await initializeReaderAnnotationButton(instance); + hitItems.forEach((item) => hitSet.add(item.id)); + } + await Zotero.Promise.delay(period); + t += period; + } +} + +async function initializeReaderSelectionEvent( + instance: _ZoteroTypes.ReaderInstance +) { + await instance._initPromise; + await instance._waitForReader(); + if (instance._pdftranslateInitialized) { + return; + } + instance._pdftranslateInitialized = true; + function selectionCallback(ev: MouseEvent) { + // Work around to only allow event from iframe#viewer + const target = ev.target as Element; + if (!target?.ownerDocument?.querySelector("#viewer")?.contains(target)) { + return false; + } + // Callback when the selected content is not null + if (!ztoolkit.Reader.getSelectedText(instance)) { + return false; + } + addon.data.translate.concatKey = ev.altKey; + addon.hooks.onReaderTextSelection(instance); + } + instance._iframeWindow?.addEventListener("pointerup", selectionCallback); + instance._pdftranslateSelectionCallback = selectionCallback; +} + +async function unInitializeReaderSelectionEvent( + instance: _ZoteroTypes.ReaderInstance +): Promise { + await instance._initPromise; + await instance._waitForReader(); + if (!instance._pdftranslateInitialized) { + return; + } + instance._iframeWindow?.removeEventListener( + "pointerup", + instance._pdftranslateSelectionCallback + ); + instance._pdftranslateInitialized = false; +} + +async function initializeReaderAnnotationButton( + instance: _ZoteroTypes.ReaderInstance +): Promise { + if (!instance) { + return []; + } + await instance._initPromise; + await instance._waitForReader(); + const _document = instance._iframeWindow?.document; + if (!_document) { + return []; + } + const hitItems: Zotero.Item[] = []; + for (const moreButton of _document.querySelectorAll(".more")) { + if (moreButton.getAttribute("_pdftranslateInitialized") === "true") { + continue; + } + moreButton.setAttribute("_pdftranslateInitialized", "true"); + + let annotationWrapper = moreButton; + while (!annotationWrapper.getAttribute("data-sidebar-annotation-id")) { + annotationWrapper = annotationWrapper.parentElement!; + } + const itemKey = + annotationWrapper.getAttribute("data-sidebar-annotation-id") || ""; + if (!instance.itemID) { + continue; + } + const libraryID = Zotero.Items.get(instance.itemID).libraryID; + const annotationItem = (await Zotero.Items.getByLibraryAndKeyAsync( + libraryID, + itemKey + )) as Zotero.Item; + + if (!annotationItem) { + continue; + } + + hitItems.push(annotationItem); + + ztoolkit.UI.insertElementBefore( + { + tag: "div", + classList: ["icon"], + properties: { + innerHTML: SVGIcon, + }, + listeners: [ + { + type: "click", + listener: (e) => { + const task = addTranslateAnnotationTask(annotationItem.id); + addon.hooks.onTranslate(task, { + noCheckZoteroItemLanguage: true, + }); + e.preventDefault(); + }, + }, + { + type: "mouseover", + listener: (e) => { + (e.target as HTMLElement).style.backgroundColor = "#F0F0F0"; + }, + }, + { + type: "mouseout", + listener: (e) => { + (e.target as HTMLElement).style.removeProperty( + "background-color" + ); + }, + }, + ], + enableElementRecord: true, + }, + moreButton + ); + } + return hitItems; +} + +async function unInitializeReaderAnnotationButton( + instance: _ZoteroTypes.ReaderInstance +): Promise { + if (!instance) { + return; + } + await instance._initPromise; + await instance._waitForReader(); + const _document = instance._iframeWindow?.document; + if (!_document) { + return; + } + for (const moreButton of _document.querySelectorAll(".more")) { + if (moreButton.getAttribute("_pdftranslateInitialized") === "true") { + moreButton.removeAttribute("_pdftranslateInitialized"); + } + } +} diff --git a/src/modules/services.ts b/src/modules/services.ts new file mode 100644 index 0000000..7b53500 --- /dev/null +++ b/src/modules/services.ts @@ -0,0 +1,228 @@ +import { getPref } from "../utils/prefs"; +import { + getLastTranslateTask, + TranslateTask, + TranslateTaskRunner, +} from "../utils/translate"; + +export class TranslationServices { + [key: string]: TranslateTaskRunner | Function; + constructor() { + import("./services/baidu").then( + (e) => (this.baidu = new TranslateTaskRunner(e.default)) + ); + import("./services/baidufield").then( + (e) => (this.baidufield = new TranslateTaskRunner(e.default)) + ); + import("./services/bingdict").then( + (e) => (this.bingdict = new TranslateTaskRunner(e.default)) + ); + import("./services/caiyun").then( + (e) => (this.caiyun = new TranslateTaskRunner(e.default)) + ); + import("./services/cnki").then( + (e) => (this.cnki = new TranslateTaskRunner(e.default)) + ); + import("./services/collinsdict").then( + (e) => (this.collinsdict = new TranslateTaskRunner(e.default)) + ); + import("./services/deepl").then((e) => { + this.deeplfree = new TranslateTaskRunner(e.deeplfree); + this.deeplpro = new TranslateTaskRunner(e.deeplpro); + }); + import("./services/deeplx").then((e) => { + this.deeplx = new TranslateTaskRunner(e.default); + }); + import("./services/deeplcustom").then( + (e) => (this.deeplcustom = new TranslateTaskRunner(e.default)) + ); + import("./services/freedictionaryapi").then( + (e) => (this.freedictionaryapi = new TranslateTaskRunner(e.default)) + ); + import("./services/google").then((e) => { + this.googleapi = new TranslateTaskRunner(e.googleapi); + this.google = new TranslateTaskRunner(e.google); + }); + import("./services/haici").then( + (e) => (this.haici = new TranslateTaskRunner(e.default)) + ); + import("./services/haicidict").then( + (e) => (this.haicidict = new TranslateTaskRunner(e.default)) + ); + import("./services/microsoft").then( + (e) => (this.microsoft = new TranslateTaskRunner(e.default)) + ); + import("./services/niutrans").then( + (e) => (this.niutranspro = new TranslateTaskRunner(e.default)) + ); + import("./services/openl").then( + (e) => (this.openl = new TranslateTaskRunner(e.default)) + ); + import("./services/tencent").then( + (e) => (this.tencent = new TranslateTaskRunner(e.default)) + ); + import("./services/webliodict").then( + (e) => (this.webliodict = new TranslateTaskRunner(e.default)) + ); + import("./services/xftrans").then( + (e) => (this.xftrans = new TranslateTaskRunner(e.default)) + ); + import("./services/gpt").then( + (e) => (this.gpt = new TranslateTaskRunner(e.gptTranslate)) + ); + import("./services/youdao").then( + (e) => (this.youdao = new TranslateTaskRunner(e.default)) + ); + import("./services/youdaodict").then( + (e) => (this.youdaodict = new TranslateTaskRunner(e.default)) + ); + import("./services/youdaozhiyun").then( + (e) => (this.youdaozhiyun = new TranslateTaskRunner(e.default)) + ); + } + + public async runTranslationTask( + task?: TranslateTask, + options: { + noCheckZoteroItemLanguage?: boolean; + noDisplay?: boolean; + } = {} + ): Promise { + ztoolkit.log("runTranslationTask", options); + task = task || getLastTranslateTask(); + if (!task || !task.raw) { + ztoolkit.log("skipped empty"); + return false; + } + task.status = "processing" as TranslateTask["status"]; + // Check whether item language is in disabled languages list + let disabledByItemLanguage = false; + if (!options.noCheckZoteroItemLanguage && task.itemId) { + const item = Zotero.Items.getTopLevel([Zotero.Items.get(task.itemId)])[0]; + if (item) { + const itemLanguage = ( + (item.getField("language") as string) || "" + ).split("-")[0]; + const disabledLanguages = ( + getPref("disabledLanguages") as string + ).split(","); + disabledByItemLanguage = + disabledLanguages.length > 0 && + disabledLanguages.includes(itemLanguage); + } + } + if (disabledByItemLanguage) { + ztoolkit.log("disabledByItemLanguage"); + return false; + } + // Remove possible translation results (for annotations). + const splitChar = task.raw.includes(getPref("splitChar") as string) + ? "" + : getPref("splitChar"); + // /🔤[^🔤]*🔤/g + const regex = + splitChar === "" + ? "" + : new RegExp(`${splitChar}[^${splitChar}]*${splitChar}`, "g"); + task.raw = task.raw.replace(regex, ""); + task.result = ""; + // Display raw + if (!options.noDisplay) { + addon.hooks.onReaderPopupRefresh(); + addon.hooks.onReaderTabPanelRefresh(); + } + // Get task runner + const runner = this[task.service] as TranslateTaskRunner; + if (!runner) { + task.result = `${task.service} is not implemented.`; + task.status = "fail"; + return false; + } + // Run task + await runner.run(task); + // Run extra tasks. Do not wait. + if (task.extraTasks?.length) { + Promise.all( + task.extraTasks.map((extraTask) => { + return this.runTranslationTask(extraTask, { + noCheckZoteroItemLanguage: options.noCheckZoteroItemLanguage, + noDisplay: true, + }); + }) + ).then(() => { + addon.hooks.onReaderTabPanelRefresh(); + }); + } + // Try candidate services if current run fails + if (task.status === "fail" && task.candidateServices.length > 0) { + task.service = task.candidateServices.shift()!; + task.status = "waiting"; + return await this.runTranslationTask(task, options); + } else { + // Display result + if (!options.noDisplay) { + addon.hooks.onReaderPopupRefresh(); + addon.hooks.onReaderTabPanelRefresh(); + } + } + const success = task.status === "success"; + const item = Zotero.Items.get(task.itemId!); + // Data storage for corresponding types + if (success) { + switch (task.type) { + case "annotation": + { + if (item) { + const savePosition = getPref("annotationTranslationPosition") as + | "comment" + | "body"; + const currentText = ( + (savePosition === "comment" + ? item.annotationComment + : item.annotationText) || "" + ).replace(regex, ""); + let text = `${ + currentText[currentText.length - 1] === "\n" ? "" : "\n" + }${splitChar}${task.result}${splitChar}\n`; + text = splitChar === "" ? text : `${currentText}${text}`; + item[ + savePosition === "comment" + ? "annotationComment" + : "annotationText" + ] = text; + item.saveTx(); + } + } + break; + case "title": + { + if (item) { + ztoolkit.ExtraField.setExtraField( + item, + "titleTranslation", + task.result + ); + item.saveTx(); + } + } + break; + case "abstract": + { + if (item) { + ztoolkit.ExtraField.setExtraField( + item, + "abstractTranslation", + // A dirty workaround to make it collapsible on Zotero 6 + ztoolkit.isZotero7() ? task.result : " " + task.result + ); + item.saveTx(); + } + } + break; + default: + break; + } + } + return success; + } +} diff --git a/src/modules/services/baidu.ts b/src/modules/services/baidu.ts new file mode 100644 index 0000000..d553384 --- /dev/null +++ b/src/modules/services/baidu.ts @@ -0,0 +1,42 @@ +import { TranslateTaskProcessor } from "../../utils/translate"; + +export default async function (data) { + const params = data.secret.split("#"); + let appid = params[0]; + let key = params[1]; + let action = "0"; + if (params.length >= 3) { + action = params[2]; + } + let salt = new Date().getTime(); + let sign = Zotero.Utilities.Internal.md5( + appid + data.raw + salt + key, + false + ); + `from=${data.langfrom.split("-")[0]}&to=${data.langto.split("-")[0]}`; + + // Request + const xhr = await Zotero.HTTP.request( + "GET", + `http://api.fanyi.baidu.com/api/trans/vip/translate?q=${encodeURIComponent( + data.raw + )}&appid=${appid}&from=${data.langfrom.split("-")[0]}&to=${ + data.langto.split("-")[0] + }&salt=${salt}&sign=${sign}&action=${action}`, + { + responseType: "json", + } + ); + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + // Parse + if (xhr.response.error_code) { + throw `Service error: ${xhr.response.error_code}:${xhr.response.error_msg}`; + } + let tgt = ""; + for (let i = 0; i < xhr.response.trans_result.length; i++) { + tgt += xhr.response.trans_result[i].dst; + } + data.result = tgt; +}; diff --git a/src/modules/services/baidufield.ts b/src/modules/services/baidufield.ts new file mode 100644 index 0000000..7e19ca2 --- /dev/null +++ b/src/modules/services/baidufield.ts @@ -0,0 +1,37 @@ +import { TranslateTaskProcessor } from "../../utils/translate"; + +export default async function (data) { + const params = data.secret.split("#"); + let appid = params[0]; + let key = params[1]; + let domain = params[2]; + let salt = new Date().getTime(); + let sign = Zotero.Utilities.Internal.md5( + appid + data.raw + salt + domain + key, + false + ); + `from=${data.langfrom.split("-")[0]}&to=${data.langto.split("-")[0]}`; + const xhr = await Zotero.HTTP.request( + "GET", + `http://api.fanyi.baidu.com/api/trans/vip/fieldtranslate?q=${encodeURIComponent( + data.raw + )}&appid=${appid}&from=${data.langfrom.split("-")[0]}&to=${ + data.langto.split("-")[0] + }&domain=${domain}&salt=${salt}&sign=${sign}`, + { + responseType: "json", + } + ); + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + // Parse + if (xhr.response.error_code) { + throw `Service error: ${xhr.response.error_code}:${xhr.response.error_msg}`; + } + let tgt = ""; + for (let i = 0; i < xhr.response.trans_result.length; i++) { + tgt += xhr.response.trans_result[i].dst; + } + data.result = tgt; +}; diff --git a/src/modules/services/bingdict.ts b/src/modules/services/bingdict.ts new file mode 100644 index 0000000..0be3626 --- /dev/null +++ b/src/modules/services/bingdict.ts @@ -0,0 +1,37 @@ +import { TranslateTaskProcessor } from "../../utils/translate"; + +export default async function (data) { + const xhr = await Zotero.HTTP.request( + "GET", + `https://cn.bing.com/dict/search?q=${encodeURIComponent(data.raw)}/`, + { responseType: "text" } + ); + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + + let res = xhr.response; + const doc = ztoolkit.getDOMParser().parseFromString(res, "text/html"); + const mp3s = Array.from(doc.querySelectorAll(".hd_area .bigaud")); + const phoneticText = doc.querySelectorAll(".hd_area .b_primtxt"); + data.audio = mp3s.map((a: Element, i: number) => ({ + text: phoneticText[i].innerHTML.replace(" ", " "), + url: (a.getAttribute("onclick")?.match(/https?:\/\/\S+\.mp3/g) || [""])[0], + })); + + try { + res = res.match(//gm)[0]; + } catch (e) { + throw "Parse error"; + } + let tgt = ""; + for (let line of res.split(",").slice(3)) { + if (line.indexOf("网络释义") > -1) { + tgt += line.slice(0, line.lastIndexOf(";")); + } else { + tgt += line + "\n"; + } + } + tgt = tgt.replace(/" \/>/g, ""); + data.result = tgt; +}; diff --git a/src/modules/services/caiyun.ts b/src/modules/services/caiyun.ts new file mode 100644 index 0000000..252fd93 --- /dev/null +++ b/src/modules/services/caiyun.ts @@ -0,0 +1,26 @@ +import { TranslateTaskProcessor } from "../../utils/translate"; + +export default async function (data) { + let param = `${data.langfrom.split("-")[0]}2${data.langto.split("-")[0]}`; + const xhr = await Zotero.HTTP.request( + "POST", + "http://api.interpreter.caiyunai.com/v1/translator", + { + headers: { + "content-type": "application/json", + "x-authorization": `token ${data.secret}`, + }, + body: JSON.stringify({ + source: [data.raw], + trans_type: param, + request_id: new Date().valueOf() / 10000, + detect: true, + }), + responseType: "json", + } + ); + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + data.result = xhr.response.target[0]; +}; diff --git a/src/modules/services/cnki.ts b/src/modules/services/cnki.ts new file mode 100644 index 0000000..afe97f9 --- /dev/null +++ b/src/modules/services/cnki.ts @@ -0,0 +1,108 @@ +import { aesEcbEncrypt, base64 } from "../../utils/crypto"; +import { getPref, setPref } from "../../utils/prefs"; +import { TranslateTaskProcessor } from "../../utils/translate"; + +export default async function (data) { + if (data.raw.length > 1000) { + new ztoolkit.ProgressWindow("PDF Translate") + .createLine({ + text: `Maximam text length is 1000, ${data.raw.length} selected. Will only translate first 1000 characters.`, + }) + .show(); + data.raw = data.raw.slice(0, 1000); + } + + const xhr = await Zotero.HTTP.request( + "POST", + "https://dict.cnki.net/fyzs-front-api/translate/literaltranslation", + { + headers: { + "Content-Type": "application/json;charset=UTF-8", + Token: await getToken(), + }, + body: JSON.stringify({ + words: await getWord(data.raw), + translateType: null, + }), + responseType: "json", + } + ); + + if (xhr.response.data?.isInputVerificationCode) { + throw "Your access is temporarily banned by the CNKI service. Please goto https://dict.cnki.net/, translate manually and pass human verification."; + } + + // if (retry && xhr.response.data?.isInputVerificationCode) { + // // Monitor verification + // await Zotero.HTTP.request( + // "GET", + // "https://dict.cnki.net/fyzs-front-api/captchaImage", + // { + // headers: { + // "Content-Type": "application/json;charset=UTF-8", + // Token: await getToken(), + // }, + // } + // ); + // await Zotero.HTTP.request( + // "POST", + // "https://dict.cnki.net/fyzs-front-api/translate/addVerificationCodeTimes", + // { + // headers: { + // "Content-Type": "application/json;charset=UTF-8", + // Token: await getToken(), + // }, + // } + // ); + // await getToken(true); + // // Call translation again + // return await cnki.call(this, text, false); + // // throw "CNKI requires verification. Please verify manually in popup or open dict.cnki.net in browser."; + // } + let tgt = xhr.response.data?.mResult; + tgt = tgt.replace('(查看名企职位领高薪offer!--->智联招聘https://dict.cnki.net/ad.html)', ''); + data.result = tgt; +}; + +async function getToken(forceRefresh: boolean = false) { + let token = ""; + // Just in case the update fails + let doRefresh = true; + try { + const tokenObj = JSON.parse(getPref("cnkiToken") as string); + if ( + !forceRefresh && + tokenObj?.token && + new Date().getTime() - tokenObj.t < 300 * 1000 + ) { + token = tokenObj.token; + doRefresh = false; + } + } catch (e) {} + if (doRefresh) { + const xhr = await Zotero.HTTP.request( + "GET", + "https://dict.cnki.net/fyzs-front-api/getToken", + { + responseType: "json", + } + ); + if (xhr && xhr.response && xhr.response.code === 200) { + token = xhr.response.token; + setPref( + "cnkiToken", + JSON.stringify({ + t: new Date().getTime(), + token: xhr.response.data, + }) + ); + } + } + return token; +} + +async function getWord(text: string) { + const encrtypted = await aesEcbEncrypt(text, "4e87183cfd3a45fe"); + const base64str = base64(encrtypted); + return base64str.replace(/\//g, "_").replace(/\+/g, "-"); +} diff --git a/src/modules/services/collinsdict.ts b/src/modules/services/collinsdict.ts new file mode 100644 index 0000000..f9bb7a6 --- /dev/null +++ b/src/modules/services/collinsdict.ts @@ -0,0 +1,41 @@ +import { TranslateTaskProcessor } from "../../utils/translate"; + +export default async function (data) { + const xhr = await Zotero.HTTP.request( + "GET", + "https://www.collinsdictionary.com/zh/dictionary/english-chinese/" + + encodeURIComponent(data.raw), + { responseType: "text" } + ); + + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + + if (xhr.responseURL.includes("?q=")) { + throw "No result found error"; + } + + const doc = ztoolkit + .getDOMParser() + .parseFromString(xhr.response, "text/html"); + Array.prototype.forEach.call(doc.querySelectorAll("script"), (e) => + e.remove() + ); + + const phoneticElements = Array.from( + doc.querySelectorAll(".type-") + ) as HTMLElement[]; + data.audio = phoneticElements.map((e) => ({ + text: e.innerText.trim(), + url: e.querySelector("a")?.getAttribute("data-src-mp3") || "", + })); + // script in innerText + const explanationText: string = Array.prototype.map + .call(doc.querySelectorAll(".hom"), (e: HTMLSpanElement) => + e.innerText.replace(/ /g, " ").replace(/[0-9]\./g, "\n$&") + ) + .join(""); + + data.result = explanationText; +}; diff --git a/src/modules/services/deepl.ts b/src/modules/services/deepl.ts new file mode 100644 index 0000000..cd5e9a6 --- /dev/null +++ b/src/modules/services/deepl.ts @@ -0,0 +1,25 @@ +import { TranslateTask, TranslateTaskProcessor } from "../../utils/translate"; + +export const deeplfree = async function (data) { + return await deepl("https://api-free.deepl.com/v2/translate", data); +}; + +export const deeplpro = async function (data) { + return await deepl("https://api.deepl.com/v2/translate", data); +}; + +async function deepl(url: string, data: Required) { + const reqBody = `auth_key=${data.secret}&text=${encodeURIComponent( + data.raw + )}&source_lang=${data.langfrom + .split("-")[0] + .toUpperCase()}&target_lang=${data.langto.split("-")[0].toUpperCase()}`; + const xhr = await Zotero.HTTP.request("POST", url, { + responseType: "json", + body: reqBody, + }); + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + data.result = xhr.response.translations[0].text; +} diff --git a/src/modules/services/deeplcustom.ts b/src/modules/services/deeplcustom.ts new file mode 100644 index 0000000..ddd54c3 --- /dev/null +++ b/src/modules/services/deeplcustom.ts @@ -0,0 +1,23 @@ +import { TranslateTaskProcessor } from "../../utils/translate"; + +export default async function (data) { + const url = data.secret; + const reqBody = JSON.stringify( + { + text: data.raw, + source_lang: data.langfrom.split("-")[0].toUpperCase(), + target_lang: data.langto.split("-")[0].toUpperCase() + } + ); + const xhr = await Zotero.HTTP.request("POST", url, { + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + responseType: "json", + body: reqBody, + }); + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + data.result = xhr.response.data; +} diff --git a/src/modules/services/deeplx.ts b/src/modules/services/deeplx.ts new file mode 100644 index 0000000..296517e --- /dev/null +++ b/src/modules/services/deeplx.ts @@ -0,0 +1,70 @@ +import { TranslateTaskProcessor } from "../../utils/translate"; +export default async function (data) { + const id = 1000*(Math.floor(Math.random() * 99999) + 8300000)+1; + const url = "https://www2.deepl.com/jsonrpc"; + const t = data.raw; + var ICounts = 0; + var ts = Date.now(); + for (var i = 0; i < t.length; i++) { + if (t[i] == "i") { + ICounts++; + } + } + if (ICounts != 0) { + ICounts++; + ts = ts - ts%ICounts + ICounts + } else { + return + } + var reqBody = JSON.stringify({ + jsonrpc: "2.0", + method: "LMT_handle_texts", + id: id, + params: { + texts: [ + { + text: t, + requestAlternatives: 3, + }, + ], + splitting: "newlines", + lang: { + source_lang_user_selected: data.langfrom + .split("-")[0] + .toUpperCase(), + target_lang: data.langto.split("-")[0].toUpperCase(), + }, + timestamp: ts, + commonJobParams: { + wasSpoken: false, + transcribe_as: "", + }, + }, + }); + if ((id+5)%29 == 0 || (id+3)%13 == 0) { + reqBody = reqBody.replace('"method":"', '"method" : "'); + } else { + reqBody = reqBody.replace('"method":"', '"method": "'); + } + const xhr = await Zotero.HTTP.request("POST", url, { + headers: { + "Content-Type": "application/json; charset=utf-8", + "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.6.0 iOS 16.3.0 (iPhone13,2)", + "x-app-build": "353933", + "x-app-version": "2.6", + "Connection": "keep-alive", + }, + responseType: "json", + body: reqBody, + }); + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + data.result = xhr.response.result.texts[0].text; +}; \ No newline at end of file diff --git a/src/modules/services/freedictionaryapi.ts b/src/modules/services/freedictionaryapi.ts new file mode 100644 index 0000000..40cc2d2 --- /dev/null +++ b/src/modules/services/freedictionaryapi.ts @@ -0,0 +1,40 @@ +import { TranslateTaskProcessor } from "../../utils/translate"; + +export default async function (data) { + const xhr = await Zotero.HTTP.request( + "GET", + `https://api.dictionaryapi.dev/api/v2/entries/en/${data.raw}`, + { + headers: { + Accept: "application/json", + }, + responseType: "json", + } + ); + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + + const res = xhr.response[0]; + let tgt = ""; + if (res.phonetics) { + tgt += res.phonetics.map((p: any) => p.text).join(","); + tgt += "\n"; + } + if (res.meanings) { + tgt += res.meanings + .map( + (m: any) => + `[${m.partOfSpeech}] ${m.definitions + .map( + (d: any) => + `${d.definition}\n${ + d.example ? `\t[example] ${d.example}` : "" + }` + ) + .join("")}` + ) + .join("----\n"); + } + data.result = tgt; +}; diff --git a/src/modules/services/google.ts b/src/modules/services/google.ts new file mode 100644 index 0000000..17ea8b6 --- /dev/null +++ b/src/modules/services/google.ts @@ -0,0 +1,85 @@ +import { TranslateTask, TranslateTaskProcessor } from "../../utils/translate"; + +export const googleapi = async function (data) { + return await _google("https://translate.googleapis.com", data); +}; +export const google = async function (data) { + return await _google("https://translate.google.com", data); +}; + +async function _google(url: string, data: Required) { + function TL(a: any) { + var k = ""; + var b = 406644; + var b1 = 3293161072; + + var jd = "."; + var $b = "+-a^+6"; + var Zb = "+-3^+b+-f"; + + for (var e = [], f = 0, g = 0; g < a.length; g++) { + var m = a.charCodeAt(g); + 128 > m + ? (e[f++] = m) + : (2048 > m + ? (e[f++] = (m >> 6) | 192) + : (55296 == (m & 64512) && + g + 1 < a.length && + 56320 == (a.charCodeAt(g + 1) & 64512) + ? ((m = + 65536 + ((m & 1023) << 10) + (a.charCodeAt(++g) & 1023)), + (e[f++] = (m >> 18) | 240), + (e[f++] = ((m >> 12) & 63) | 128)) + : (e[f++] = (m >> 12) | 224), + (e[f++] = ((m >> 6) & 63) | 128)), + (e[f++] = (m & 63) | 128)); + } + a = b; + for (f = 0; f < e.length; f++) (a += e[f]), (a = RL(a, $b)); + a = RL(a, Zb); + a ^= b1 || 0; + 0 > a && (a = (a & 2147483647) + 2147483648); + a %= 1e6; + return a.toString() + jd + (a ^ b); + } + + function RL(a: any, b: any) { + var t = "a"; + var Yb = "+"; + for (var c = 0; c < b.length - 2; c += 3) { + var d = b.charAt(c + 2), + // @ts-ignore + d = d >= t ? d.charCodeAt(0) - 87 : Number(d), + // @ts-ignore + d = b.charAt(c + 1) == Yb ? a >>> d : a << d; + a = b.charAt(c) == Yb ? (a + d) & 4294967295 : a ^ d; + } + return a; + } + + let param = `sl=${data.langfrom}&tl=${data.langto}`; + + const xhr = await Zotero.HTTP.request( + "GET", + `${ + data.secret ? data.secret : url + }/translate_a/single?client=gtx&${param}&hl=zh-CN&dt=at&dt=bd&dt=ex&dt=ld&dt=md&dt=qca&dt=rw&dt=rm&dt=ss&dt=t&source=bh&ssel=0&tsel=0&kc=1&tk=${TL( + data.raw + )}&q=${encodeURIComponent(data.raw)}`, + { responseType: "json" } + ); + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + + let tgt = ""; + for (let i = 0; i < xhr.response[0].length; i++) { + if (!xhr.response[0][i]) { + continue; + } + if (xhr.response[0][i] && xhr.response[0][i][0]) { + tgt += xhr.response[0][i][0]; + } + } + data.result = tgt; +} diff --git a/src/modules/services/gpt.ts b/src/modules/services/gpt.ts new file mode 100644 index 0000000..7a03c78 --- /dev/null +++ b/src/modules/services/gpt.ts @@ -0,0 +1,97 @@ +import { TranslateTaskProcessor } from "../../utils/translate"; +import { getPref } from "../../utils/prefs"; +import { getServiceSecret } from "../../utils/translate"; + +export const gptTranslate = async function (data) { + const model = getPref("gptModel"); + const temperature = parseFloat(getPref("gptTemperature") as string); + const apiUrl = getPref("gptUrl"); + const xhr = await Zotero.HTTP.request( + "POST", + apiUrl, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${data.secret}`, + }, + body: JSON.stringify({ + model: model, + messages: [ + { + role: "user", + content: `As an academic expert with specialized knowledge in various fields, please provide a proficient and precise translation translation from ${data.langfrom.split("-")[0]} to ${data.langto.split("-")[0]} of the academic text enclosed in 🔤. It is crucial to maintaining the original phrase or sentence and ensure accuracy while utilizing the appropriate language. The text is as follows: 🔤 ${data.raw} 🔤 Please provide the translated result without any additional explanation and remove 🔤.`, + }, + ], + temperature: temperature, + stream: true, + }), + responseType: "text", + requestObserver: (xmlhttp: XMLHttpRequest) => { + let preLength = 0; + let result = ""; + xmlhttp.onprogress = (e: any) => { + // Only concatenate the new strings + let newResponse = e.target.response.slice(preLength); + let dataArray = newResponse.split("data: "); + + for (let data of dataArray) { + try { + let obj = JSON.parse(data); + let choice = obj.choices[0]; + if (choice.finish_reason) { + break; + } + result += choice.delta.content || ""; + } catch { + continue; + } + } + + // Clear timeouts caused by stream transfers + if (e.target.timeout) { + e.target.timeout = 0; + } + + // Remove \n\n from the beginning of the data + data.result = result.replace(/^\n\n/, ""); + preLength = e.target.response.length; + + if (data.type === "text") { + addon.hooks.onReaderPopupRefresh(); + addon.hooks.onReaderTabPanelRefresh(); + } + }; + }, + } + ); + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + // data.result = xhr.response.choices[0].message.content.substr(2); +}; + +export const updateGPTModel = async function () { + const secret = getServiceSecret("gpt"); + const xhr = await Zotero.HTTP.request( + "GET", + "https://api.openai.com/v1/models", + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${secret}`, + }, + responseType: "json", + } + ); + + const models = xhr.response.data; + const availableModels = []; + + for (const model of models) { + if (model.id.includes("gpt")) { + availableModels.push(model.id); + } + } + + return availableModels; +}; diff --git a/src/modules/services/haici.ts b/src/modules/services/haici.ts new file mode 100644 index 0000000..17d0023 --- /dev/null +++ b/src/modules/services/haici.ts @@ -0,0 +1,66 @@ +import { getPref, setPref } from "../../utils/prefs"; +import { TranslateTaskProcessor } from "../../utils/translate"; + +export default async function (data) { + const xhr = await Zotero.HTTP.request( + "GET", + `http://api.microsofttranslator.com/V2/Ajax.svc/TranslateArray?appId=${await getAppId()}&from=${ + data.langfrom + }&to=${data.langto}&texts=["${encodeURIComponent(data.raw)}"]`, + { responseType: "json" } + ); + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + + try { + let tgt = ""; + xhr.response.forEach((line: { TranslatedText: string }) => { + tgt += line.TranslatedText; + }); + data.result = tgt; + } catch { + throw `Service error: ${xhr.response}`; + } +}; + +async function getAppId(forceRefresh: boolean = false) { + let appId = ""; + // Just in case the update fails + let doRefresh = true; + try { + const appIdObj = JSON.parse(getPref("haiciAppId") as string); + if ( + !forceRefresh && + appIdObj && + appIdObj.appId && + new Date().getTime() - appIdObj.t < 60 * 60 * 1000 + ) { + appId = appIdObj.appId; + doRefresh = false; + } + } catch (e) {} + if (doRefresh) { + const xhr = await Zotero.HTTP.request( + "GET", + "http://capi.dict.cn/fanyi.php", + { + headers: { + Referer: "http://fanyi.dict.cn/", + }, + responseType: "text", + } + ); + if (xhr && xhr.response) { + appId = xhr.response.match(/"(.+)"/)[1]; + setPref( + "haiciAppId", + JSON.stringify({ + t: new Date().getTime(), + appId: appId, + }) + ); + } + } + return appId; +} diff --git a/src/modules/services/haicidict.ts b/src/modules/services/haicidict.ts new file mode 100644 index 0000000..ab41c2d --- /dev/null +++ b/src/modules/services/haicidict.ts @@ -0,0 +1,39 @@ +import { TranslateTaskProcessor } from "../../utils/translate"; + +export default async function (data) { + const xhr = await Zotero.HTTP.request("GET", `http://dict.cn/${data.raw}`, { + responseType: "text", + }); + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + + let res = xhr.response as string; + let tgt = ""; + try { + const audioRegex = /naudio="(\w+.mp3\?t=\w+?)"/; + data.audio = + res.match(new RegExp(audioRegex, "gi"))?.map((s: string) => ({ + text: "", + url: "http://audio.dict.cn/" + s.match(new RegExp(audioRegex, "i"))![1], + })) || []; + const symbolsRegex = /(.)[\n\t\s]*?(.+?)<\/bdo>/; + let symbols: string[] = []; + res.match(new RegExp(symbolsRegex, "g"))!.forEach((line) => { + let [_, country, sym] = line.match(symbolsRegex)!; + symbols.push(`${country} ${sym}`); + }); + tgt += symbols.join("\n") + "\n"; + res = res.match(/
    [\s\S]+?<\/ul>/)![0]; + } catch (e) { + throw "Parse error"; + } + for (let line of res.match(/
  • [\s\S]+?<\/li>/g) || []) { + tgt += + line + .replace(/<\/?.+?>/g, "") + .replace(/[\n\t]+/g, " ") + .trim() + "\n"; + } + data.result = tgt; +}; diff --git a/src/modules/services/microsoft.ts b/src/modules/services/microsoft.ts new file mode 100644 index 0000000..7fd91d7 --- /dev/null +++ b/src/modules/services/microsoft.ts @@ -0,0 +1,33 @@ +import { TranslateTaskProcessor } from "../../utils/translate"; + +export default async function (data) { + const req_body = JSON.stringify([ + { + text: data.raw, + }, + ]); + + const xhr = await Zotero.HTTP.request( + "POST", + `https://api.cognitive.microsofttranslator.com/translate?api-version=3.0&to=${data.langto}`, + { + headers: { + "Content-Type": "application/json; charset=utf-8", + Host: "api.cognitive.microsofttranslator.com", + "Content-Length": req_body.length, + "Ocp-Apim-Subscription-Key": data.secret, + }, + responseType: "json", + body: req_body, + } + ); + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + + const result = xhr.response[0].translations[0].text; + if (!result) { + throw `Parse error: ${JSON.stringify(xhr.response)}`; + } + data.result = result; +}; diff --git a/src/modules/services/niutrans.ts b/src/modules/services/niutrans.ts new file mode 100644 index 0000000..0054bf1 --- /dev/null +++ b/src/modules/services/niutrans.ts @@ -0,0 +1,37 @@ +import { getPref } from "../../utils/prefs"; +import { TranslateTaskProcessor } from "../../utils/translate"; + +export default async function (data) { + const apikey = data.secret; + const dictNo = getPref("niutransDictNo"); + const memoryNo = getPref("niutransMemoryNo"); + const xhr = await Zotero.HTTP.request( + "POST", + "https://api.niutrans.com/NiuTransServer/translation", + { + headers: { + "content-type": "application/json", + accept: "application/json, text/plain, */*", + }, + body: JSON.stringify({ + from: data.langfrom.split("-")[0], + to: data.langto.split("-")[0], + apikey, + dictNo, + memoryNo, + source: "zotero", + src_text: data.raw, + }), + responseType: "json", + } + ); + + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + + if (xhr.response.error_code) { + throw `Service error: ${xhr.response.error_code}:${xhr.response.error_msg}`; + } + data.result = xhr.response.tgt_text; +}; diff --git a/src/modules/services/openl.ts b/src/modules/services/openl.ts new file mode 100644 index 0000000..4f68db0 --- /dev/null +++ b/src/modules/services/openl.ts @@ -0,0 +1,54 @@ +import { TranslateTaskProcessor } from "../../utils/translate"; + +export default async function (data) { + let [services, apikey] = data.secret.split("#"); + const serviceList = services.split(","); + + const xhr = await Zotero.HTTP.request( + "POST", + "https://api.openl.club/group/translate", + { + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + apikey: apikey, + services: serviceList, + text: data.raw, + source_lang: data.langfrom.split("-")[0], + target_lang: data.langto.split("-")[0], + }), + responseType: "json", + } + ); + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + + let res = xhr.response as { + status: boolean | any; + [key: string]: any; + }; + if (!res.status) { + throw `Service error: ${JSON.stringify(res)}`; + } + delete res.status; + let tgt = ""; + const openLServices = Object.keys(res); + if (openLServices.length === 1) { + // Only one engine + const resObj = res[openLServices[0]]; + if (resObj.status) { + tgt = resObj.result; + } else { + throw "Service error: all OpenL services failed."; + } + } else { + for (let openLService of openLServices) { + if (res[openLService].status) { + tgt += `[${openLService}] ${res[openLService].result}\n`; + } + } + } + data.result = tgt; +}; diff --git a/src/modules/services/tencent.ts b/src/modules/services/tencent.ts new file mode 100644 index 0000000..4d2b36a --- /dev/null +++ b/src/modules/services/tencent.ts @@ -0,0 +1,66 @@ +import { base64, hmacSha1Digest } from "../../utils/crypto"; +import { TranslateTaskProcessor } from "../../utils/translate"; + +export default async function (data) { + let params = data.secret.split("#"); + let secretId = params[0]; + let secretKey = params[1]; + let region = "ap-shanghai"; + if (params.length >= 3) { + region = params[2]; + } + let projectId = "0"; + if (params.length >= 4) { + projectId = params[3]; + } + + function encodeRFC5987ValueChars(str: string) { + return encodeURIComponent(str) + .replace( + /['()]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}` + ) // i.e., %27 %28 %29 + .replace(/\*/g, "%2A") + .replace(/%20/g, "+"); + } + + const rawStr = `Action=TextTranslate&Language=zh-CN&Nonce=9744&ProjectId=${projectId}&Region=${region}&SecretId=${secretId}&Source=${ + data.langfrom.split("-")[0] + }&SourceText=#$#&Target=${data.langto.split("-")[0]}&Timestamp=${new Date() + .getTime() + .toString() + .substring(0, 10)}&Version=2018-03-21`; + + const sha1Str = encodeRFC5987ValueChars( + base64( + await hmacSha1Digest( + `POSTtmt.tencentcloudapi.com/?${rawStr.replace("#$#", data.raw)}`, + secretKey + ) + ) + ); + + const xhr = await Zotero.HTTP.request( + "POST", + "https://tmt.tencentcloudapi.com", + { + headers: { + "content-type": "application/json", + }, + // Encode \s to + + body: `${rawStr.replace( + "#$#", + encodeRFC5987ValueChars(data.raw) + )}&Signature=${sha1Str}`, + responseType: "json", + } + ); + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + + if (xhr.response.Response.Error) { + throw `Service error: ${xhr.response.Response.Error.Code}:${xhr.response.Response.Error.Message}`; + } + data.result = xhr.response.Response.TargetText; +}; diff --git a/src/modules/services/webliodict.ts b/src/modules/services/webliodict.ts new file mode 100644 index 0000000..4185b9d --- /dev/null +++ b/src/modules/services/webliodict.ts @@ -0,0 +1,45 @@ +import { TranslateTaskProcessor } from "../../utils/translate"; + +export default async function (data) { + const xhr = await Zotero.HTTP.request( + "GET", + `https://ejje.weblio.jp/content/${encodeURIComponent(data.raw)}/`, + { responseType: "text" } + ); + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + + let res = xhr.response; + const doc: Document = ztoolkit + .getDOMParser() + .parseFromString(res, "text/html"); + const translations: string[][] = []; + + const process = (ele: Element | undefined) => { + if (!ele) { + return []; + } + return Array.from(ele.children).map((e) => + (e as HTMLElement).innerText.trim() + ); + }; + + translations.push(process(doc.querySelector(".descriptionWrp")?.children[0])); + doc.querySelector(".descriptionWrp")?.remove(); + + Array.prototype.forEach.call( + doc.querySelector(".summaryM")?.children, + (e: Element) => translations.push(process(e)) + ); + + Array.from(doc.querySelectorAll(".intrst")) + .map((e: Element) => e.querySelector("tr")) + .forEach((e) => { + e && translations.push(process(e)); + }); + data.result = translations + .filter((t) => t) + .map((t) => t.join(":")) + .join("\n"); +}; diff --git a/src/modules/services/xftrans.ts b/src/modules/services/xftrans.ts new file mode 100644 index 0000000..9d9f684 --- /dev/null +++ b/src/modules/services/xftrans.ts @@ -0,0 +1,87 @@ +import { base64, hmacSha256Digest, sha256Digest } from "../../utils/crypto"; +import { TranslateTaskProcessor } from "../../utils/translate"; + +export default async function (data) { + let [appid, apiSecret, apiKey] = data.secret.split("#"); + const config = { + appid, + apiSecret, + apiKey, + host: "itrans.xfyun.cn", + hostUrl: "https://itrans.xfyun.cn/v2/its", + uri: "/v2/its", + }; + + function transLang(inlang: string = "") { + const langs = [{ regex: /zh-\w+/, lang: "cn" }]; + // default + let outlang = inlang.split("-")[0]; + langs.forEach((obj) => { + if (obj.regex.test(inlang)) { + outlang = obj.lang; + } + }); + return outlang; + } + + let transVar = { + text: data.raw, + from: transLang(data.langfrom), + to: transLang(data.langto), + }; + const date = new Date().toUTCString(); + const postBody = getPostBody(transVar.text, transVar.from, transVar.to); + const digest = await getDigest(postBody); + const options = { + url: config.hostUrl, + headers: { + "Content-Type": "application/json", + Accept: "application/json,version=1.0", + Host: config.host, + Date: date, + Digest: digest, + Authorization: await getAuthStr(date, digest), + }, + json: true, + body: postBody, + }; + function getPostBody(text: string, from: string, to: string) { + const digestObj = { + common: { + app_id: config.appid, + }, + business: { + from: from, + to: to, + }, + data: { + text: base64(new TextEncoder().encode(text)), + }, + }; + return digestObj; + } + async function getDigest(body: any) { + return `SHA-256=${base64(await sha256Digest(JSON.stringify(body)))}`; + } + async function getAuthStr(date: string, digest: string) { + const signatureOrigin = `host: ${config.host}\ndate: ${date}\nPOST ${config.uri} HTTP/1.1\ndigest: ${digest}`; + const signatureSha = await hmacSha256Digest( + signatureOrigin, + config.apiSecret + ); + const signature = base64(signatureSha); + const authorizationOrigin = `api_key="${config.apiKey}", algorithm="hmac-sha256", headers="host date request-line digest", signature="${signature}"`; + return authorizationOrigin; + } + + const xhr = await Zotero.HTTP.request("POST", options.url, { + headers: options.headers, + responseType: "json", + body: JSON.stringify(options.body), + }); + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + + data.result = xhr.response.data.result.trans_result.dst; +}; diff --git a/src/modules/services/youdao.ts b/src/modules/services/youdao.ts new file mode 100644 index 0000000..c2be4f8 --- /dev/null +++ b/src/modules/services/youdao.ts @@ -0,0 +1,27 @@ +import { TranslateTaskProcessor } from "../../utils/translate"; + +export default async function (data) { + let param = `${data.langfrom.toUpperCase().replace("-", "_")}2${data.langto + .toUpperCase() + .replace("-", "_")}`; + + const xhr = await Zotero.HTTP.request( + "GET", + `http://fanyi.youdao.com/translate?&doctype=json&type=${param}&i=${encodeURIComponent( + data.raw + )}`, + { responseType: "json" } + ); + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + + let res = xhr.response.translateResult; + let tgt = ""; + for (let i in res) { + for (let j in res[i]) { + tgt += res[i][j].tgt; + } + } + data.result = tgt; +}; diff --git a/src/modules/services/youdaodict.ts b/src/modules/services/youdaodict.ts new file mode 100644 index 0000000..c371125 --- /dev/null +++ b/src/modules/services/youdaodict.ts @@ -0,0 +1,28 @@ +import { TranslateTaskProcessor } from "../../utils/translate"; + +export default async function (data) { + const xhr = await Zotero.HTTP.request( + "GET", + `https://www.youdao.com/w/${encodeURIComponent(data.raw)}/`, + { responseType: "text" } + ); + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + + let res = xhr.response; + res = res.replace(/(\r\n|\n|\r)/gm, ""); + res = res.match( + /
    /gm + ); + + let tgt = ""; + if (res.length > 0) { + tgt = res[0].replace(/<[^>]*>?/gm, "\n"); + tgt = tgt.replace(/\n\s*\n/g, "\n"); + tgt = tgt.replace(/\s\s+/g, " "); + tgt = tgt.trim(); + } + + data.result = tgt; +}; diff --git a/src/modules/services/youdaozhiyun.ts b/src/modules/services/youdaozhiyun.ts new file mode 100644 index 0000000..5a2f5bd --- /dev/null +++ b/src/modules/services/youdaozhiyun.ts @@ -0,0 +1,53 @@ +import { hex, sha256Digest } from "../../utils/crypto"; +import { TranslateTaskProcessor } from "../../utils/translate"; + +export default async function (data) { + function encodeRFC5987ValueChars(str: string) { + return encodeURIComponent(str) + .replace( + /['()]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}` + ) // i.e., %27 %28 %29 + .replace(/\*/g, "%2A") + .replace(/%20/g, "+"); + } + + function truncate(q: string) { + const len = q.length; + if (len <= 20) return q; + return q.substring(0, 10) + len + q.substring(len - 10, len); + } + + const [appid, key, vocabId] = data.secret.split("#"); + const salt = new Date().getTime(); + const curtime = Math.round(new Date().getTime() / 1000); + const query = data.raw; + const from = data.langfrom; + const to = data.langto; + const str1 = appid + truncate(query) + salt + curtime + key; + + const sign = hex(await sha256Digest(str1)); + + const xhr = await Zotero.HTTP.request( + "GET", + `https://openapi.youdao.com/api?q=${encodeRFC5987ValueChars( + query + )}&appKey=${appid}&salt=${salt}&from=${from}&to=${to}&sign=${sign}&signType=v3&curtime=${curtime}&vocabId=${vocabId}`, + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + responseType: "json", + } + ); + + if (xhr?.status !== 200) { + throw `Request error: ${xhr?.status}`; + } + + let res = xhr.response; + if (parseInt(res.errorCode) !== 0) { + throw `Service error: ${res.errorCode}`; + } + data.result = res.translation.join(""); +}; diff --git a/src/modules/shortcuts.ts b/src/modules/shortcuts.ts new file mode 100644 index 0000000..885585e --- /dev/null +++ b/src/modules/shortcuts.ts @@ -0,0 +1,20 @@ +import { config } from "../../package.json"; + +export function registerShortcuts() { + ztoolkit.Shortcut.register("element", { + id: `${config.addonRef}-translateKey`, + key: "T", + modifiers: "accel", + xulData: { + document, + command: `${config.addonRef}-translateCmd`, + _parentId: `${config.addonRef}-keyset`, + _commandOptions: { + id: `${config.addonRef}-translateCmd`, + document, + _parentId: `${config.addonRef}-cmdset`, + oncommand: `Zotero.${config.addonInstance}.hooks.onShortcuts(Zotero_Tabs.selectedType)`, + }, + }, + }); +} diff --git a/src/modules/tabpanel.ts b/src/modules/tabpanel.ts new file mode 100644 index 0000000..cc317ea --- /dev/null +++ b/src/modules/tabpanel.ts @@ -0,0 +1,1011 @@ +import { getString } from "../utils/locale"; +import { config } from "../../package.json"; +import { LANG_CODE, SERVICES } from "../utils/config"; +import { getPref, setPref } from "../utils/prefs"; +import { + addTranslateTask, + getLastTranslateTask, + putTranslateTaskAtHead, +} from "../utils/translate"; + +export function registerReaderTabPanel() { + ztoolkit.ReaderTabPanel.register( + getString("readerpanel.label"), + ( + panel: XUL.TabPanel | undefined, + ownerDeck: XUL.Deck, + ownerWindow: Window, + readerInstance: _ZoteroTypes.ReaderInstance + ) => { + if (ownerDeck.selectedPanel?.children[0].tagName === "vbox") { + panel = createPanel(ownerDeck, readerInstance._instanceID); + } + panel && buildPanel(panel, readerInstance._instanceID); + }, + { + selectPanel: getPref("autoFocus") as boolean, + } + ).then((tabId) => { + addon.data.panel.tabOptionId = tabId; + }); + new (ztoolkit.getGlobal("MutationObserver"))((_muts) => { + updateTextAreasSize(); + }).observe(document.querySelector("#zotero-context-pane")!, { + attributes: true, + attributeFilter: ["width"], + }); + document + .querySelector("#zotero-context-pane") + ?.querySelector("grippy") + ?.addEventListener("click", (ev) => { + updateTextAreasSize(); + }); + updateTextAreasSize(true); +} + +async function openWindowPanel() { + if (addon.data.panel.windowPanel && !addon.data.panel.windowPanel.closed) { + addon.data.panel.windowPanel.close(); + } + const dialogData = { + loadLock: Zotero.Promise.defer(), + }; + const win: Window = ztoolkit.getGlobal("openDialog")( + `chrome://${config.addonRef}/content/standalone.xhtml`, + `${config.addonRef}-standalone`, + `chrome,extrachrome,menubar,resizable=yes,scrollbars,status,dialog=no,${ + getPref("keepWindowTop") ? ",alwaysRaised=yes" : "" + }`, + dialogData + ); + await dialogData.loadLock.promise; + buildPanel( + win.document.querySelector("#panel-container") as XUL.Box, + "standalone" + ); + win.addEventListener("resize", (ev) => { + updateTextAreaSize(win.document); + }); + buildExtraPanel(win.document.querySelector("#extra-container") as XUL.Box); + updateTextAreaSize(win.document); + addon.data.panel.windowPanel = win; +} + +export function updateReaderTabPanels() { + ztoolkit.ReaderTabPanel.changeTabPanel(addon.data.panel.tabOptionId, { + selectPanel: getPref("autoFocus") as boolean, + }); + cleanPanels(); + addon.data.panel.activePanels.forEach((panel) => updatePanel(panel)); + if (addon.data.panel.windowPanel && !addon.data.panel.windowPanel.closed) { + updateExtraPanel(addon.data.panel.windowPanel.document); + } + updateTextAreasSize(true); +} + +function createPanel(ownerDeck: XUL.Deck, refID: string) { + const container = ownerDeck.selectedPanel; + container.innerHTML = ""; + ztoolkit.UI.appendElement( + { + tag: "tabbox", + id: `${config.addonRef}-${refID}-extra-tabbox`, + classList: ["zotero-view-tabbox"], + attributes: { + flex: "1", + }, + ignoreIfExists: true, + children: [ + { + tag: "tabs", + classList: ["zotero-editpane-tabs"], + attributes: { + orient: "horizontal", + }, + children: [ + { + tag: "tab", + attributes: { + label: getString("readerpanel.label"), + }, + }, + ], + }, + { + tag: "tabpanels", + classList: ["zotero-view-item"], + attributes: { + flex: "1", + }, + children: [ + { + tag: "tabpanel", + attributes: { + flex: "1", + }, + }, + ], + }, + ], + }, + container + ); + return container.querySelector("tabpanel") as XUL.TabPanel; +} + +function buildPanel(panel: HTMLElement, refID: string, force: boolean = false) { + const makeId = (type: string) => `${config.addonRef}-${refID}-panel-${type}`; + // Manually existance check to avoid unnecessary element creation with ... + if (!force && panel.querySelector(`#${makeId("root")}`)) { + return; + } + ztoolkit.UI.appendElement( + { + tag: "vbox", + id: makeId("root"), + classList: [`${config.addonRef}-panel-root`], + attributes: { + flex: "1", + align: "stretch", + }, + styles: { + padding: "8px", + }, + ignoreIfExists: true, + children: [ + { + tag: "hbox", + id: makeId("engine"), + attributes: { + flex: "0", + align: "center", + }, + children: [ + { + tag: "menulist", + id: makeId("services"), + attributes: { + flex: "0", + }, + listeners: [ + { + type: "command", + listener: (e: Event) => { + const newService = (e.target as XUL.MenuList).value; + setPref("translateSource", newService); + addon.hooks.onReaderTabPanelRefresh(); + const data = getLastTranslateTask(); + if (!data) { + return; + } + data.service = newService; + addon.hooks.onTranslate(undefined, { + noCheckZoteroItemLanguage: true, + }); + }, + }, + ], + children: [ + { + tag: "menupopup", + children: SERVICES.filter( + (service) => service.type === "sentence" + ).map((service) => ({ + tag: "menuitem", + attributes: { + label: getString(`service.${service.id}`), + value: service.id, + }, + })), + }, + ], + }, + { + tag: "button", + namespace: "xul", + attributes: { + label: `${getString( + "readerpanel.translate.button.label" + )}(${getString("ctrl")} + T)`, + flex: "1", + }, + listeners: [ + { + type: "click", + listener: (ev: Event) => { + if (!getLastTranslateTask()) { + addTranslateTask( + ( + panel.querySelector( + `#${makeId( + getPref("rawResultOrder") + ? "resulttext" + : "rawtext" + )}` + ) as HTMLTextAreaElement + )?.value + ); + } + addon.hooks.onTranslate(undefined, { + noCheckZoteroItemLanguage: true, + }); + }, + }, + ], + }, + ], + }, + { + tag: "hbox", + id: makeId("lang"), + attributes: { + flex: "0", + align: "center", + }, + styles: { + marginTop: "8px", + }, + children: [ + { + tag: "menulist", + id: makeId("langfrom"), + attributes: { + flex: "1", + }, + listeners: [ + { + type: "command", + listener: (e: Event) => { + setPref("sourceLanguage", (e.target as XUL.MenuList).value); + addon.hooks.onReaderTabPanelRefresh(); + }, + }, + ], + children: [ + { + tag: "menupopup", + children: LANG_CODE.map((lang) => ({ + tag: "menuitem", + attributes: { + label: lang.name, + value: lang.code, + }, + })), + }, + ], + }, + { + tag: "div", + styles: { + paddingLeft: "8px", + paddingRight: "8px", + }, + properties: { + innerHTML: "↔️", + }, + listeners: [ + { + type: "click", + listener: (ev) => { + const langfrom = getPref("sourceLanguage") as string; + const langto = getPref("targetLanguage") as string; + setPref("targetLanguage", langfrom); + setPref("sourceLanguage", langto); + addon.hooks.onReaderTabPanelRefresh(); + }, + }, + ], + }, + { + tag: "menulist", + id: makeId("langto"), + attributes: { + flex: "1", + }, + listeners: [ + { + type: "command", + listener: (e: Event) => { + setPref("targetLanguage", (e.target as XUL.MenuList).value); + addon.hooks.onReaderTabPanelRefresh(); + }, + }, + ], + children: [ + { + tag: "menupopup", + children: LANG_CODE.map((lang) => ({ + tag: "menuitem", + attributes: { + label: lang.name, + value: lang.code, + }, + })), + }, + ], + }, + ], + }, + { + tag: "hbox", + id: makeId("auto"), + attributes: { + flex: "0", + align: "center", + }, + styles: { + marginTop: "8px", + }, + children: [ + { + tag: "div", + styles: { + paddingLeft: "8px", + }, + properties: { + innerHTML: getString("readerpanel.auto.description.label"), + }, + }, + { + tag: "checkbox", + styles: { + paddingLeft: "8px", + }, + id: makeId("autotrans"), + attributes: { + label: getString("readerpanel.auto.selection.label"), + }, + listeners: [ + { + type: "command", + listener: (e: Event) => { + setPref("enableAuto", (e.target as XUL.Checkbox).checked); + addon.hooks.onReaderTabPanelRefresh(); + }, + }, + ], + }, + { + tag: "checkbox", + styles: { + paddingLeft: "8px", + }, + id: makeId("autoannot"), + attributes: { + label: getString("readerpanel.auto.annotation.label"), + }, + listeners: [ + { + type: "command", + listener: (e: Event) => { + setPref( + "enableComment", + (e.target as XUL.Checkbox).checked + ); + addon.hooks.onReaderTabPanelRefresh(); + }, + }, + ], + }, + ], + }, + { + tag: "hbox", + id: makeId("concat"), + styles: { + marginTop: "8px", + }, + attributes: { + flex: "0", + align: "center", + }, + children: [ + { + tag: "div", + styles: { + paddingLeft: "8px", + }, + properties: { + innerHTML: getString("readerpanel.concat.description.label"), + }, + }, + { + tag: "checkbox", + styles: { + paddingLeft: "8px", + }, + id: makeId("concat"), + attributes: { + label: `${getString( + "readerpanel.concat.enable.label" + )}/${getString("alt")}`, + }, + listeners: [ + { + type: "command", + listener: (e) => { + addon.data.translate.concatCheckbox = ( + e.target as XUL.Checkbox + ).checked; + addon.hooks.onReaderTabPanelRefresh(); + }, + }, + ], + }, + { + tag: "button", + styles: { + paddingLeft: "8px", + }, + namespace: "xul", + attributes: { + label: getString("readerpanel.concat.clear.label"), + flex: "0", + }, + listeners: [ + { + type: "click", + listener: (e) => { + const task = getLastTranslateTask(); + if (task) { + task.raw = ""; + task.result = ""; + addon.hooks.onReaderTabPanelRefresh(); + } + }, + }, + ], + }, + ], + }, + { + tag: "hbox", + id: makeId("raw"), + attributes: { + flex: "1", + spellcheck: false, + }, + styles: { + marginTop: "8px", + }, + children: [ + { + tag: "textarea", + id: makeId("rawtext"), + styles: { + resize: "none", + fontFamily: "inherit", + }, + listeners: [ + { + type: "input", + listener: (ev) => { + const task = getLastTranslateTask({ + id: panel.getAttribute("translate-task-id") || "", + }); + if (!task) { + return; + } + const reverseRawResult = getPref("rawResultOrder"); + if (!reverseRawResult) { + task.raw = (ev.target as HTMLTextAreaElement).value; + } else { + task.result = (ev.target as HTMLTextAreaElement).value; + } + putTranslateTaskAtHead(task.id); + }, + }, + ], + }, + ], + }, + { + tag: "splitter", + id: makeId("splitter"), + attributes: { collapse: "after" }, + children: [ + { + tag: "grippy", + }, + ], + }, + { + tag: "hbox", + id: makeId("result"), + attributes: { + flex: "1", + spellcheck: false, + }, + children: [ + { + tag: "textarea", + id: makeId("resulttext"), + styles: { + resize: "none", + fontFamily: "inherit", + }, + listeners: [ + { + type: "input", + listener: (ev) => { + const task = getLastTranslateTask({ + id: panel.getAttribute("translate-task-id") || "", + }); + if (!task) { + return; + } + const reverseRawResult = getPref("rawResultOrder"); + if (!reverseRawResult) { + task.result = (ev.target as HTMLTextAreaElement).value; + } else { + task.raw = (ev.target as HTMLTextAreaElement).value; + } + putTranslateTaskAtHead(task.id); + }, + }, + ], + }, + ], + }, + { + tag: "hbox", + id: makeId("copy"), + attributes: { + flex: "0", + align: "center", + }, + styles: { + marginTop: "8px", + }, + children: [ + { + tag: "div", + properties: { + innerHTML: getString("readerpanel.copy.description.label"), + }, + }, + { + tag: "button", + namespace: "xul", + attributes: { + label: getString("readerpanel.copy.raw.label"), + flex: "1", + }, + listeners: [ + { + type: "click", + listener: (e: Event) => { + const task = getLastTranslateTask({ + id: panel.getAttribute("translate-task-id") || "", + }); + if (!task) { + return; + } + new ztoolkit.Clipboard() + .addText(task.raw, "text/unicode") + .copy(); + }, + }, + ], + }, + { + tag: "button", + namespace: "xul", + attributes: { + label: getString("readerpanel.copy.result.label"), + flex: "1", + }, + listeners: [ + { + type: "click", + listener: (e: Event) => { + const task = getLastTranslateTask({ + id: panel.getAttribute("translate-task-id") || "", + }); + if (!task) { + return; + } + new ztoolkit.Clipboard() + .addText(task.result, "text/unicode") + .copy(); + }, + }, + ], + }, + { + tag: "button", + namespace: "xul", + attributes: { + label: getString("readerpanel.copy.both.label"), + flex: "1", + }, + listeners: [ + { + type: "click", + listener: (e: Event) => { + const task = getLastTranslateTask({ + id: panel.getAttribute("translate-task-id") || "", + }); + if (!task) { + return; + } + new ztoolkit.Clipboard() + .addText( + `${task.raw}\n----\n${task.result}`, + "text/unicode" + ) + .copy(); + }, + }, + ], + }, + ], + }, + { + tag: "hbox", + id: makeId("openwindow"), + styles: { + marginTop: "8px", + }, + attributes: { + flex: "0", + align: "center", + }, + children: [ + { + tag: "button", + namespace: "xul", + attributes: { + label: getString("readerpanel.openwindow.open.label"), + flex: "1", + }, + listeners: [ + { + type: "click", + listener: (e: Event) => { + openWindowPanel(); + }, + }, + ], + }, + ], + }, + ], + }, + panel + ); + updatePanel(panel); + updateTextAreaSize(panel); + recordPanel(panel); +} + +function buildExtraPanel(panel: XUL.Box) { + ztoolkit.UI.appendElement( + { + tag: "hbox", + id: "extraTools", + attributes: { + flex: "1", + align: "center", + }, + styles: { + paddingLeft: "8px", + paddingRight: "8px", + marginBottom: "8px", + }, + ignoreIfExists: true, + children: [ + { + tag: "button", + namespace: "xul", + attributes: { + label: getString("readerpanel.extra.addservice.label"), + flex: "1", + }, + listeners: [ + { + type: "click", + listener: (ev: Event) => { + const extraServices = getPref("extraEngines"); + setPref( + "extraEngines", + extraServices + ? `${extraServices},${SERVICES[0].id}` + : SERVICES[0].id + ); + openWindowPanel(); + }, + }, + ], + }, + { + tag: "button", + namespace: "xul", + attributes: { + label: getString("readerpanel.extra.resize.label"), + flex: "1", + }, + listeners: [ + { + type: "click", + listener: (ev: Event) => { + const win = addon.data.panel.windowPanel; + if (!win) { + return; + } + Array.from(win.document.querySelectorAll("textarea")).forEach( + (elem) => (elem.style.width = "280px") + ); + ztoolkit.getGlobal("setTimeout")(() => { + win?.resizeTo(300, win.outerHeight); + }, 10); + }, + }, + ], + }, + { + tag: "button", + namespace: "xul", + attributes: { + label: getString( + `readerpanel.extra.${ + getPref("keepWindowTop") ? "pinned" : "pin" + }.label` + ), + flex: "1", + }, + styles: { + minWidth: "0px", + }, + listeners: [ + { + type: "click", + listener: (ev: Event) => { + setPref("keepWindowTop", !getPref("keepWindowTop")); + openWindowPanel(); + }, + }, + ], + }, + ], + }, + panel + ); + const extraEngines = (getPref("extraEngines") as string) + .split(",") + .filter((thisServiceId) => + SERVICES.find((service) => service.id === thisServiceId) + ); + if (!extraEngines.length) { + panel.style.display = "contents"; + return; + } + ztoolkit.UI.appendElement( + { + tag: "vbox", + attributes: { + flex: "1", + align: "stretch", + }, + children: extraEngines.map((serviceId, idx) => { + return { + tag: "vbox", + attributes: { + flex: "1", + align: "stretch", + }, + children: [ + { + tag: "hbox", + id: `${serviceId}-${idx}`, + attributes: { + flex: "1", + align: "center", + }, + classList: [serviceId], + children: [ + { + tag: "menulist", + attributes: { + flex: "1", + value: serviceId, + }, + listeners: [ + { + type: "command", + listener: (ev: Event) => { + const menulist = ev.currentTarget as XUL.MenuList; + const newService = menulist.value; + const [serviceId, idx] = + menulist.parentElement?.id.split("-") || []; + const extraServices = ( + getPref("extraEngines") as string + ).split(","); + if (extraServices[Number(idx)] === serviceId) { + // If the idx and service matches + extraServices[Number(idx)] = newService; + menulist.parentElement!.id = `${newService}-${idx}`; + menulist.parentElement!.className = newService; + setPref("extraEngines", extraServices.join(",")); + } else { + // Otherwise reload window + openWindowPanel(); + } + }, + }, + ], + children: [ + { + tag: "menupopup", + children: SERVICES.filter( + (service) => service.type === "sentence" + ).map((service) => ({ + tag: "menuitem", + attributes: { + label: getString(`service.${service.id}`), + value: service.id, + }, + })), + }, + ], + }, + { + tag: "button", + namespace: "xul", + attributes: { + label: getString("readerpanel.extra.removeservice.label"), + }, + styles: { + minWidth: "0px", + }, + listeners: [ + { + type: "click", + listener: (ev) => { + const [serviceId, idx] = + (ev.target as XUL.Button).parentElement?.id.split( + "-" + ) || []; + const extraServices = ( + getPref("extraEngines") as string + ).split(","); + // If the idx and service matches + if (extraServices[Number(idx)] === serviceId) { + extraServices.splice(Number(idx), 1); + setPref("extraEngines", extraServices.join(",")); + } + openWindowPanel(); + }, + }, + ], + }, + ], + }, + { + tag: "hbox", + attributes: { + flex: "1", + spellcheck: false, + }, + children: [ + { + tag: "textarea", + styles: { + resize: "none", + fontSize: `${getPref("fontSize")}px`, + "font-family": "inherit", + lineHeight: getPref("lineHeight") as string, + }, + }, + ], + }, + ], + }; + }), + }, + panel + ); +} + +function updatePanel(panel: HTMLElement) { + const idPrefix = panel + .querySelector(`.${config.addonRef}-panel-root`)! + .id.split("-") + .slice(0, -1) + .join("-"); + const makeId = (type: string) => `${idPrefix}-${type}`; + const updateHidden = (type: string, pref: string) => { + const elem = panel.querySelector(`#${makeId(type)}`) as XUL.Box; + elem.hidden = !getPref(pref) as boolean; + }; + const setCheckBox = (type: string, checked: boolean) => { + const elem = panel.querySelector(`#${makeId(type)}`) as XUL.Checkbox; + elem.checked = checked; + }; + const setValue = (type: string, value: string) => { + const elem = panel.querySelector(`#${makeId(type)}`) as XUL.Textbox; + elem.value = value; + }; + const setTextBoxStyle = (type: string) => { + const elem = panel.querySelector(`#${makeId(type)}`) as XUL.Textbox; + elem.style.fontSize = `${getPref("fontSize")}px`; + elem.style.lineHeight = getPref("lineHeight") as string; + }; + + updateHidden("engine", "showSidebarEngine"); + updateHidden("lang", "showSidebarLanguage"); + updateHidden("auto", "showSidebarSettings"); + updateHidden("concat", "showSidebarConcat"); + updateHidden("raw", "showSidebarRaw"); + updateHidden("splitter", "showSidebarRaw"); + updateHidden("copy", "showSidebarCopy"); + + setValue("services", getPref("translateSource") as string); + setValue("langfrom", getPref("sourceLanguage") as string); + setValue("langto", getPref("targetLanguage") as string); + + setCheckBox("autotrans", getPref("enableAuto") as boolean); + setCheckBox("autoannot", getPref("enableComment") as boolean); + setCheckBox("concat", addon.data.translate.concatCheckbox); + + const lastTask = getLastTranslateTask(); + if (!lastTask) { + return; + } + // For manually update translation task + panel.setAttribute("translate-task-id", lastTask.id); + const reverseRawResult = getPref("rawResultOrder"); + setValue("rawtext", reverseRawResult ? lastTask.result : lastTask.raw); + setValue("resulttext", reverseRawResult ? lastTask.raw : lastTask.result); + setTextBoxStyle("rawtext"); + setTextBoxStyle("resulttext"); + panel + .querySelector(`#${makeId("splitter")}`) + ?.setAttribute("collapse", reverseRawResult ? "after" : "before"); +} + +function updateExtraPanel(container: HTMLElement | Document) { + const extraTasks = getLastTranslateTask()?.extraTasks; + if (extraTasks?.length === 0) { + return; + } + extraTasks?.forEach((task) => { + Array.from( + container.querySelectorAll(`.${task.service}+hbox>textarea`) + ).forEach((elem) => ((elem as HTMLTextAreaElement).value = task.result)); + }); +} + +function updateTextAreaSize( + container: HTMLElement | Document, + noDelay: boolean = false +) { + const setTimeout = ztoolkit.getGlobal("setTimeout"); + Array.from(container.querySelectorAll("textarea")).forEach((elem) => { + if (noDelay) { + elem.style.width = `${elem.parentElement?.scrollWidth}px`; + return; + } + elem.style.width = "0px"; + setTimeout(() => { + elem.style.width = `${elem.parentElement?.scrollWidth}px`; + }, 0); + }); +} + +function updateTextAreasSize(noDelay: boolean = false) { + cleanPanels(); + addon.data.panel.activePanels.forEach((panel) => + updateTextAreaSize(panel, noDelay) + ); +} + +function recordPanel(panel: HTMLElement) { + addon.data.panel.activePanels.push(panel); +} + +function cleanPanels() { + addon.data.panel.activePanels = addon.data.panel.activePanels.filter( + (elem) => elem.parentElement + ); +} diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 0000000..4ad27bb --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,569 @@ +interface TranslateService { + type: "word" | "sentence"; + id: string; + defaultSecret?: string; + secretValidator?: (secret: string) => SecretValidateResult; +} + +export interface SecretValidateResult { + secret: string; + status: boolean; + info: string; +} + +export const SERVICES: Readonly[]> = [ + { + type: "sentence", + id: "googleapi", + }, + { + type: "sentence", + id: "google", + }, + { + type: "sentence", + id: "cnki", + }, + { + type: "sentence", + id: "haici", + }, + { + type: "sentence", + id: "youdao", + }, + { + type: "sentence", + id: "youdaozhiyun", + defaultSecret: "appid#appsecret#vocabid(optional)", + secretValidator(secret: string) { + const parts = secret?.split("#"); + const flag = [2, 3].includes(parts.length); + const partsInfo = `AppID: ${parts[0]}\nAppKey: ${parts[1]}\nVocabID: ${ + parts[2] ? parts[2] : "" + }`; + const source = getService("youdaozhiyun"); + return { + secret, + status: flag && secret !== source.defaultSecret, + info: + secret === source.defaultSecret + ? "The secret is not set." + : flag + ? partsInfo + : `The secret format of YoudaoZhiyun is AppID#AppKey#VocabID(optional). The secret must have 2 or 3 parts joined by '#', but got ${parts?.length}.\n${partsInfo}`, + }; + }, + }, + { + type: "sentence", + id: "niutranspro", + defaultSecret: "", + secretValidator(secret: string) { + const flag = secret?.length === 32; + return { + secret, + status: flag, + info: flag + ? "" + : `The secret is your NiuTrans API-KEY. The secret length must be 32, but got ${secret?.length}.`, + }; + }, + }, + { + type: "sentence", + id: "microsoft", + defaultSecret: "", + secretValidator(secret: string) { + const flag = secret?.length === 32; + return { + secret, + status: flag, + info: flag + ? "" + : `The secret is your Azure translate service KEY. The secret length must be 32, but got ${secret?.length}.`, + }; + }, + }, + { + type: "sentence", + id: "caiyun", + defaultSecret: "3975l6lr5pcbvidl6jl2", + }, + { + type: "sentence", + id: "deeplfree", + defaultSecret: "", + secretValidator(secret: string) { + const flag = secret?.length >= 36; + return { + secret, + status: flag, + info: flag + ? "" + : `The secret is your DeepL (free plan) KEY. The secret length must >= 36, but got ${secret?.length}.`, + }; + }, + }, + { + type: "sentence", + id: "deeplpro", + defaultSecret: "", + secretValidator(secret: string) { + const flag = secret?.length >= 36; + return { + secret, + status: flag, + info: flag + ? "" + : `The secret is your DeepL (pro plan) KEY. The secret length must >= 36, but got ${secret?.length}.`, + }; + }, + }, + { + type: "sentence", + id: "deeplcustom", + defaultSecret: "", + secretValidator(secret: string) { + const flag = Boolean(secret); + return { + secret, + status: flag, + info: flag ? "" : `Please enter custom DeepL URL.`, + }; + }, + }, + { + type: "sentence", + id: "deeplx", + }, + { + type: "sentence", + id: "baidu", + defaultSecret: "appid#key", + secretValidator(secret: string) { + const parts = secret?.split("#"); + const flag = [2, 3].includes(parts.length); + const partsInfo = `AppID: ${parts[0]}\nKey: ${parts[1]}\nAction: ${ + parts[2] ? parts[2] : "0" + } + `; + const source = getService("baidu"); + return { + secret, + status: flag && secret !== source.defaultSecret, + info: + secret === source.defaultSecret + ? "The secret is not set." + : flag + ? partsInfo + : `The secret format of Baidu Text Translation is AppID#Key#Action(optional). The secret must have 2 or 3 parts joined by '#', but got ${parts?.length}.\n${partsInfo}`, + }; + }, + }, + { + type: "sentence", + id: "baidufield", + defaultSecret: "appid#key#field", + secretValidator(secret: string) { + const parts = secret?.split("#"); + const flag = parts.length === 3; + const partsInfo = `AppID: ${parts[0]}\nKey: ${parts[1]}\nDomainCode: ${parts[2]}`; + const source = getService("baidufield"); + return { + secret, + status: flag && secret !== source.defaultSecret, + info: + secret === source.defaultSecret + ? "The secret is not set." + : flag + ? partsInfo + : `The secret format of Baidu Domain Text Translation is AppID#Key#DomainCode. The secret must have 3 parts joined by '#', but got ${parts?.length}.\n${partsInfo}`, + }; + }, + }, + { + type: "sentence", + id: "openl", + defaultSecret: "service1,service2,...#apikey", + secretValidator(secret: string) { + const parts = secret?.split("#"); + const flag = parts.length === 2; + const partsInfo = `Services: ${parts[0]}\nAPIKey: ${parts[1]}`; + const source = getService("openl"); + return { + secret, + status: flag && secret !== source.defaultSecret, + info: + secret === source.defaultSecret + ? "The secret is not set." + : flag + ? partsInfo + : `The secret format of OpenL is service1,service2,...#APIKey. The secret must have 2 parts joined by '#', but got ${parts?.length}.\n${partsInfo}`, + }; + }, + }, + { + type: "sentence", + id: "tencent", + defaultSecret: + "secretId#SecretKey#Region(default ap-shanghai)#ProjectId(default 0)", + secretValidator(secret: string) { + const parts = secret?.split("#"); + const flag = [2, 3, 4].includes(parts.length); + const partsInfo = `SecretId: ${parts[0]}\nSecretKey: ${ + parts[1] + }\nRegion: ${parts[2] ? parts[2] : "ap-shanghai"}\nProjectId: ${ + parts[3] ? parts[3] : "0" + } + `; + const source = getService("tencent"); + return { + secret, + status: flag && secret !== source.defaultSecret, + info: + secret === source.defaultSecret + ? "The secret is not set." + : flag + ? partsInfo + : `The secret format of Tencent Translation is SecretId#SecretKey#Region(optional)#ProjectId(optional). The secret must have 2, 3 or 4 parts joined by '#', but got ${parts?.length}.\n${partsInfo}`, + }; + }, + }, + { + type: "sentence", + id: "xftrans", + defaultSecret: "AppID#ApiSecret#ApiKey", + secretValidator(secret: string) { + const parts = secret?.split("#"); + const flag = parts.length === 3; + const partsInfo = `AppID: ${parts[0]}\nApiSecret: ${parts[1]}\nApiKey: ${parts[2]}`; + const source = getService("xftrans"); + return { + secret, + status: flag && secret !== source.defaultSecret, + info: + secret === source.defaultSecret + ? "The secret is not set." + : flag + ? partsInfo + : `The secret format of Xftrans Domain Text Translation is AppID#ApiSecret#ApiKey. The secret must have 3 parts joined by '#', but got ${parts?.length}.\n${partsInfo}`, + }; + }, + }, + { + type: "sentence", + id: "gpt", + defaultSecret: "", + secretValidator(secret: string) { + const status = secret.length === 51 && /^sk-/.test(secret); + const empty = secret.length === 0; + return { + secret, + status, + info: empty + ? "The secret is not set." + : status + ? "Click the button to check connectivity." + : "Ths secret key format is invalid.", + }; + }, + }, + { + type: "word", + id: "bingdict", + }, + { + type: "word", + id: "haicidict", + }, + { + type: "word", + id: "youdaodict", + }, + { + type: "word", + id: "freedictionaryapi", + }, + { + type: "word", + id: "webliodict", + }, + { + type: "word", + id: "collinsdict", + }, +]; + +export function getService(id: string) { + return SERVICES[SERVICES.findIndex((service) => service.id === id)]; +} + +export const LANG_CODE = [ + { code: "af", name: "Afrikaans" }, + { code: "af-ZA", name: "Afrikaans (South Africa)" }, + { code: "ar", name: "Arabic" }, + { code: "ar-AE", name: "Arabic (U.A.E.)" }, + { code: "ar-BH", name: "Arabic (Bahrain)" }, + { code: "ar-DZ", name: "Arabic (Algeria)" }, + { code: "ar-EG", name: "Arabic (Egypt)" }, + { code: "ar-IQ", name: "Arabic (Iraq)" }, + { code: "ar-JO", name: "Arabic (Jordan)" }, + { code: "ar-KW", name: "Arabic (Kuwait)" }, + { code: "ar-LB", name: "Arabic (Lebanon)" }, + { code: "ar-LY", name: "Arabic (Libya)" }, + { code: "ar-MA", name: "Arabic (Morocco)" }, + { code: "ar-OM", name: "Arabic (Oman)" }, + { code: "ar-QA", name: "Arabic (Qatar)" }, + { code: "ar-SA", name: "Arabic (Saudi Arabia)" }, + { code: "ar-SY", name: "Arabic (Syria)" }, + { code: "ar-TN", name: "Arabic (Tunisia)" }, + { code: "ar-YE", name: "Arabic (Yemen)" }, + { code: "az", name: "Azeri (Latin)" }, + { code: "az-AZ", name: "Azeri (Latin) (Azerbaijan)" }, + { code: "az-AZ", name: "Azeri (Cyrillic) (Azerbaijan)" }, + { code: "be", name: "Belarusian" }, + { code: "be-BY", name: "Belarusian (Belarus)" }, + { code: "bg", name: "Bulgarian" }, + { code: "bg-BG", name: "Bulgarian (Bulgaria)" }, + { code: "bs-BA", name: "Bosnian (Bosnia and Herzegovina)" }, + { code: "ca", name: "Catalan" }, + { code: "ca-ES", name: "Catalan (Spain)" }, + { code: "cs", name: "Czech" }, + { code: "cs-CZ", name: "Czech (Czech Republic)" }, + { code: "cy", name: "Welsh" }, + { code: "cy-GB", name: "Welsh (United Kingdom)" }, + { code: "da", name: "Danish" }, + { code: "da-DK", name: "Danish (Denmark)" }, + { code: "de", name: "German" }, + { code: "de-AT", name: "German (Austria)" }, + { code: "de-CH", name: "German (Switzerland)" }, + { code: "de-DE", name: "German (Germany)" }, + { code: "de-LI", name: "German (Liechtenstein)" }, + { code: "de-LU", name: "German (Luxembourg)" }, + { code: "dv", name: "Divehi" }, + { code: "dv-MV", name: "Divehi (Maldives)" }, + { code: "el", name: "Greek" }, + { code: "el-GR", name: "Greek (Greece)" }, + { code: "en", name: "English" }, + { code: "en-AU", name: "English (Australia)" }, + { code: "en-BZ", name: "English (Belize)" }, + { code: "en-CA", name: "English (Canada)" }, + { code: "en-CB", name: "English (Caribbean)" }, + { code: "en-GB", name: "English (United Kingdom)" }, + { code: "en-IE", name: "English (Ireland)" }, + { code: "en-JM", name: "English (Jamaica)" }, + { code: "en-NZ", name: "English (New Zealand)" }, + { code: "en-PH", name: "English (Republic of the Philippines)" }, + { code: "en-TT", name: "English (Trinidad and Tobago)" }, + { code: "en-US", name: "English (United States)" }, + { code: "en-ZA", name: "English (South Africa)" }, + { code: "en-ZW", name: "English (Zimbabwe)" }, + { code: "eo", name: "Esperanto" }, + { code: "es", name: "Spanish" }, + { code: "es-AR", name: "Spanish (Argentina)" }, + { code: "es-BO", name: "Spanish (Bolivia)" }, + { code: "es-CL", name: "Spanish (Chile)" }, + { code: "es-CO", name: "Spanish (Colombia)" }, + { code: "es-CR", name: "Spanish (Costa Rica)" }, + { code: "es-DO", name: "Spanish (Dominican Republic)" }, + { code: "es-EC", name: "Spanish (Ecuador)" }, + { code: "es-ES", name: "Spanish (Castilian)" }, + { code: "es-ES", name: "Spanish (Spain)" }, + { code: "es-GT", name: "Spanish (Guatemala)" }, + { code: "es-HN", name: "Spanish (Honduras)" }, + { code: "es-MX", name: "Spanish (Mexico)" }, + { code: "es-NI", name: "Spanish (Nicaragua)" }, + { code: "es-PA", name: "Spanish (Panama)" }, + { code: "es-PE", name: "Spanish (Peru)" }, + { code: "es-PR", name: "Spanish (Puerto Rico)" }, + { code: "es-PY", name: "Spanish (Paraguay)" }, + { code: "es-SV", name: "Spanish (El Salvador)" }, + { code: "es-UY", name: "Spanish (Uruguay)" }, + { code: "es-VE", name: "Spanish (Venezuela)" }, + { code: "et", name: "Estonian" }, + { code: "et-EE", name: "Estonian (Estonia)" }, + { code: "eu", name: "Basque" }, + { code: "eu-ES", name: "Basque (Spain)" }, + { code: "fa", name: "Farsi" }, + { code: "fa-IR", name: "Farsi (Iran)" }, + { code: "fi", name: "Finnish" }, + { code: "fi-FI", name: "Finnish (Finland)" }, + { code: "fo", name: "Faroese" }, + { code: "fo-FO", name: "Faroese (Faroe Islands)" }, + { code: "fr", name: "French" }, + { code: "fr-BE", name: "French (Belgium)" }, + { code: "fr-CA", name: "French (Canada)" }, + { code: "fr-CH", name: "French (Switzerland)" }, + { code: "fr-FR", name: "French (France)" }, + { code: "fr-LU", name: "French (Luxembourg)" }, + { code: "fr-MC", name: "French (Principality of Monaco)" }, + { code: "gl", name: "Galician" }, + { code: "gl-ES", name: "Galician (Spain)" }, + { code: "gu", name: "Gujarati" }, + { code: "gu-IN", name: "Gujarati (India)" }, + { code: "he", name: "Hebrew" }, + { code: "he-IL", name: "Hebrew (Israel)" }, + { code: "hi", name: "Hindi" }, + { code: "hi-IN", name: "Hindi (India)" }, + { code: "hr", name: "Croatian" }, + { code: "hr-BA", name: "Croatian (Bosnia and Herzegovina)" }, + { code: "hr-HR", name: "Croatian (Croatia)" }, + { code: "hu", name: "Hungarian" }, + { code: "hu-HU", name: "Hungarian (Hungary)" }, + { code: "hy", name: "Armenian" }, + { code: "hy-AM", name: "Armenian (Armenia)" }, + { code: "id", name: "Indonesian" }, + { code: "id-ID", name: "Indonesian (Indonesia)" }, + { code: "is", name: "Icelandic" }, + { code: "is-IS", name: "Icelandic (Iceland)" }, + { code: "it", name: "Italian" }, + { code: "it-CH", name: "Italian (Switzerland)" }, + { code: "it-IT", name: "Italian (Italy)" }, + { code: "ja", name: "Japanese" }, + { code: "ja-JP", name: "Japanese (Japan)" }, + { code: "ka", name: "Georgian" }, + { code: "ka-GE", name: "Georgian (Georgia)" }, + { code: "kk", name: "Kazakh" }, + { code: "kk-KZ", name: "Kazakh (Kazakhstan)" }, + { code: "kn", name: "Kannada" }, + { code: "kn-IN", name: "Kannada (India)" }, + { code: "ko", name: "Korean" }, + { code: "ko-KR", name: "Korean (Korea)" }, + { code: "kok", name: "Konkani" }, + { code: "kok-IN", name: "Konkani (India)" }, + { code: "ky", name: "Kyrgyz" }, + { code: "ky-KG", name: "Kyrgyz (Kyrgyzstan)" }, + { code: "lt", name: "Lithuanian" }, + { code: "lt-LT", name: "Lithuanian (Lithuania)" }, + { code: "lv", name: "Latvian" }, + { code: "lv-LV", name: "Latvian (Latvia)" }, + { code: "mi", name: "Maori" }, + { code: "mi-NZ", name: "Maori (New Zealand)" }, + { code: "mk", name: "FYRO Macedonian" }, + { + code: "mk-MK", + name: "FYRO Macedonian (Former Yugoslav Republic of Macedonia)", + }, + { code: "mn", name: "Mongolian" }, + { code: "mn-MN", name: "Mongolian (Mongolia)" }, + { code: "mr", name: "Marathi" }, + { code: "mr-IN", name: "Marathi (India)" }, + { code: "ms", name: "Malay" }, + { code: "ms-BN", name: "Malay (Brunei Darussalam)" }, + { code: "ms-MY", name: "Malay (Malaysia)" }, + { code: "mt", name: "Maltese" }, + { code: "mt-MT", name: "Maltese (Malta)" }, + { code: "nb", name: "Norwegian (Bokm?l)" }, + { code: "nb-NO", name: "Norwegian (Bokm?l) (Norway)" }, + { code: "nl", name: "Dutch" }, + { code: "nl-BE", name: "Dutch (Belgium)" }, + { code: "nl-NL", name: "Dutch (Netherlands)" }, + { code: "nn-NO", name: "Norwegian (Nynorsk) (Norway)" }, + { code: "ns", name: "Northern Sotho" }, + { code: "ns-ZA", name: "Northern Sotho (South Africa)" }, + { code: "pa", name: "Punjabi" }, + { code: "pa-IN", name: "Punjabi (India)" }, + { code: "pl", name: "Polish" }, + { code: "pl-PL", name: "Polish (Poland)" }, + { code: "ps", name: "Pashto" }, + { code: "ps-AR", name: "Pashto (Afghanistan)" }, + { code: "pt", name: "Portuguese" }, + { code: "pt-BR", name: "Portuguese (Brazil)" }, + { code: "pt-PT", name: "Portuguese (Portugal)" }, + { code: "qu", name: "Quechua" }, + { code: "qu-BO", name: "Quechua (Bolivia)" }, + { code: "qu-EC", name: "Quechua (Ecuador)" }, + { code: "qu-PE", name: "Quechua (Peru)" }, + { code: "ro", name: "Romanian" }, + { code: "ro-RO", name: "Romanian (Romania)" }, + { code: "ru", name: "Russian" }, + { code: "ru-RU", name: "Russian (Russia)" }, + { code: "sa", name: "Sanskrit" }, + { code: "sa-IN", name: "Sanskrit (India)" }, + { code: "se", name: "Sami (Northern)" }, + { code: "se-FI", name: "Sami (Northern) (Finland)" }, + { code: "se-FI", name: "Sami (Skolt) (Finland)" }, + { code: "se-FI", name: "Sami (Inari) (Finland)" }, + { code: "se-NO", name: "Sami (Northern) (Norway)" }, + { code: "se-NO", name: "Sami (Lule) (Norway)" }, + { code: "se-NO", name: "Sami (Southern) (Norway)" }, + { code: "se-SE", name: "Sami (Northern) (Sweden)" }, + { code: "se-SE", name: "Sami (Lule) (Sweden)" }, + { code: "se-SE", name: "Sami (Southern) (Sweden)" }, + { code: "sk", name: "Slovak" }, + { code: "sk-SK", name: "Slovak (Slovakia)" }, + { code: "sl", name: "Slovenian" }, + { code: "sl-SI", name: "Slovenian (Slovenia)" }, + { code: "sq", name: "Albanian" }, + { code: "sq-AL", name: "Albanian (Albania)" }, + { code: "sr-BA", name: "Serbian (Latin) (Bosnia and Herzegovina)" }, + { code: "sr-BA", name: "Serbian (Cyrillic) (Bosnia and Herzegovina)" }, + { code: "sr-SP", name: "Serbian (Latin) (Serbia and Montenegro)" }, + { code: "sr-SP", name: "Serbian (Cyrillic) (Serbia and Montenegro)" }, + { code: "sv", name: "Swedish" }, + { code: "sv-FI", name: "Swedish (Finland)" }, + { code: "sv-SE", name: "Swedish (Sweden)" }, + { code: "sw", name: "Swahili" }, + { code: "sw-KE", name: "Swahili (Kenya)" }, + { code: "syr", name: "Syriac" }, + { code: "syr-SY", name: "Syriac (Syria)" }, + { code: "ta", name: "Tamil" }, + { code: "ta-IN", name: "Tamil (India)" }, + { code: "te", name: "Telugu" }, + { code: "te-IN", name: "Telugu (India)" }, + { code: "th", name: "Thai" }, + { code: "th-TH", name: "Thai (Thailand)" }, + { code: "tl", name: "Tagalog" }, + { code: "tl-PH", name: "Tagalog (Philippines)" }, + { code: "tn", name: "Tswana" }, + { code: "tn-ZA", name: "Tswana (South Africa)" }, + { code: "tr", name: "Turkish" }, + { code: "tr-TR", name: "Turkish (Turkey)" }, + { code: "tt", name: "Tatar" }, + { code: "tt-RU", name: "Tatar (Russia)" }, + { code: "ts", name: "Tsonga" }, + { code: "uk", name: "Ukrainian" }, + { code: "uk-UA", name: "Ukrainian (Ukraine)" }, + { code: "ur", name: "Urdu" }, + { code: "ur-PK", name: "Urdu (Islamic Republic of Pakistan)" }, + { code: "uz", name: "Uzbek (Latin)" }, + { code: "uz-UZ", name: "Uzbek (Latin) (Uzbekistan)" }, + { code: "uz-UZ", name: "Uzbek (Cyrillic) (Uzbekistan)" }, + { code: "vi", name: "Vietnamese" }, + { code: "vi-VN", name: "Vietnamese (Viet Nam)" }, + { code: "xh", name: "Xhosa" }, + { code: "xh-ZA", name: "Xhosa (South Africa)" }, + { code: "zh", name: "Chinese" }, + { code: "zh-CN", name: "Chinese (S)" }, + { code: "zh-HK", name: "Chinese (Hong Kong)" }, + { code: "zh-MO", name: "Chinese (Macau)" }, + { code: "zh-SG", name: "Chinese (Singapore)" }, + { code: "zh-TW", name: "Chinese (T)" }, + { code: "zu", name: "Zulu" }, + { code: "zu-ZA", name: "Zulu (South Africa)" }, +]; + +export const SVGIcon = ` + + + + + + + +`; diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts new file mode 100644 index 0000000..936eda6 --- /dev/null +++ b/src/utils/crypto.ts @@ -0,0 +1,110 @@ +function base64(buffer: ArrayBuffer) { + const str = String.fromCharCode(...new Uint8Array(buffer)); + return ztoolkit.getGlobal("btoa")(str); +} + +function hex(buffer: ArrayBuffer) { + const hashArray = Array.from(new Uint8Array(buffer)); + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +async function hmacSha1Digest(stringToSign: string, secretKey: string) { + const enc = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + enc.encode(secretKey), + { + name: "HMAC", + hash: "SHA-1", + }, + false, + ["sign"] + ); + return crypto.subtle.sign("HMAC", key, enc.encode(stringToSign)); +} + +async function hmacSha256Digest(stringToSign: string, secretKey: string) { + const enc = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + enc.encode(secretKey), + { + name: "HMAC", + hash: "SHA-256", + }, + false, + ["sign"] + ); + return crypto.subtle.sign("HMAC", key, enc.encode(stringToSign)); +} + +async function sha256Digest(message: string) { + const enc = new TextEncoder(); + return crypto.subtle.digest("SHA-256", enc.encode(message)); +} + +function pkcs7Pad(block: Uint8Array | Array) { + const padding = 16 - block.length; + const pad = new Uint8Array(padding); + pad.fill(padding); + return new Uint8Array([...block, ...pad]); +} + +// AES ECB encrypt, use CBC mode to simulate ECB mode +async function aesEcbEncrypt(message: string, secret: string) { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { + name: "AES-CBC", + }, + false, + ["encrypt"] + ); + + const encodeStr = new TextEncoder().encode(message); + // split encoded string to 16 byte blocks + const blocks = []; + for (let i = 0; i < encodeStr.length; i += 16) { + const block = encodeStr.subarray(i, i + 16); + blocks.push(block); + } + + if (!blocks.length || blocks[blocks.length - 1].length === 16) { + blocks.push(pkcs7Pad([])); // pad empty block + } else { + blocks[blocks.length - 1] = pkcs7Pad(blocks[blocks.length - 1]); + } + + // encrypt each block, do not pad + const zeros = new Uint8Array(16); + const encryptedBlocks = await Promise.all( + blocks.map((block) => + crypto.subtle.encrypt( + { + name: "AES-CBC", + iv: block, + }, + key, + zeros + ) + ) + ); + // concatenate encrypted blocks + const encrypted = new Uint8Array(encryptedBlocks.length * 16); + let offset = 0; + for (const block of encryptedBlocks) { + encrypted.set(new Uint8Array(block).subarray(0, 16), offset); + offset += 16; + } + return encrypted; +} + +export { + aesEcbEncrypt, + base64, + hex, + hmacSha1Digest, + hmacSha256Digest, + sha256Digest, +}; diff --git a/src/utils/gptModels.ts b/src/utils/gptModels.ts new file mode 100644 index 0000000..9e27b15 --- /dev/null +++ b/src/utils/gptModels.ts @@ -0,0 +1,213 @@ +import { getPref, setPref } from "./prefs"; +import { getString } from "./locale"; +import { updateGPTModel } from "../modules/services/gpt"; + +export async function gptStatusCallback(status: boolean) { + const selectedModel = getPref("gptModel"); + const dialog = new ztoolkit.Dialog(2, 1); + const dialogData: { [key: string | number]: any } = { + url: getPref("gptUrl"), + models: getPref("gptModel"), + temperature: parseFloat(getPref("gptTemperature") as string), + loadCallback: async () => { + const doc = dialog.window.document; + + try { + const models = await updateGPTModel(); + // Due to an unknown bug with Zotero 7, the `