Repository: l0o0/jasminum Branch: main Commit: e601b35dc67c Files: 88 Total size: 373.6 KB Directory structure: gitextract_udbmtm46/ ├── .gitattributes ├── .github/ │ ├── dependabot.yml │ ├── renovate.json │ └── workflows/ │ └── release.yml ├── .gitignore ├── .prettierignore ├── .vscode/ │ ├── extensions.json │ ├── launch.json │ ├── settings.json │ └── toolkit.code-snippets ├── LICENSE ├── README.md ├── addon/ │ ├── bootstrap.js │ ├── chrome/ │ │ └── content/ │ │ ├── preferences-main.xhtml │ │ ├── preferences-translators.xhtml │ │ ├── prefpanel.css │ │ └── progress.xhtml │ ├── locale/ │ │ ├── en-US/ │ │ │ ├── addon.ftl │ │ │ ├── mainWindow.ftl │ │ │ ├── preferences-main.ftl │ │ │ ├── preferences-translators.ftl │ │ │ └── progress.ftl │ │ ├── zh-CN/ │ │ │ ├── addon.ftl │ │ │ ├── mainWindow.ftl │ │ │ ├── preferences-main.ftl │ │ │ ├── preferences-translators.ftl │ │ │ └── progress.ftl │ │ └── zh-TW/ │ │ ├── addon.ftl │ │ ├── mainWindow.ftl │ │ ├── preferences-main.ftl │ │ ├── preferences-translators.ftl │ │ └── progress.ftl │ ├── manifest.json │ └── prefs.js ├── doc/ │ └── README-zhCN.md ├── eslint.config.mjs ├── package.json ├── src/ │ ├── addon.ts │ ├── hooks.ts │ ├── index.ts │ ├── modules/ │ │ ├── attachments/ │ │ │ ├── index.ts │ │ │ └── localMatch.ts │ │ ├── menu.ts │ │ ├── notifier.ts │ │ ├── outline/ │ │ │ ├── bookmark.ts │ │ │ ├── events.ts │ │ │ ├── index.ts │ │ │ ├── outline.ts │ │ │ └── style.ts │ │ ├── preferences/ │ │ │ ├── main.ts │ │ │ └── translators.ts │ │ ├── progress.ts │ │ ├── services/ │ │ │ ├── cnki.ts │ │ │ ├── index.ts │ │ │ ├── pubscholar.ts │ │ │ └── yiigle.ts │ │ ├── styles.ts │ │ ├── tools.ts │ │ ├── translators.ts │ │ ├── workers/ │ │ │ ├── index.ts │ │ │ └── outline.ts │ │ └── wps.ts │ └── utils/ │ ├── cookiebox.ts │ ├── detect.ts │ ├── http.ts │ ├── locale.ts │ ├── pattern.ts │ ├── pdfParser.ts │ ├── prefs.ts │ ├── task.ts │ ├── wait.ts │ ├── window.ts │ └── ztoolkit.ts ├── test/ │ ├── CNKI_translator_test.js │ ├── expert_china.json │ └── expert_oversea.json ├── tsconfig.json ├── typings/ │ ├── attachment.d.ts │ ├── global.d.ts │ ├── i10n.d.ts │ ├── myzotero.d.ts │ ├── notifier.d.ts │ ├── outline.d.ts │ ├── pdfParser.d.ts │ ├── prefs.d.ts │ ├── scrape.d.ts │ └── translators.d.ts └── zotero-plugin.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ * text=auto eol=lf ================================================ FILE: .github/dependabot.yml ================================================ # 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" ================================================ FILE: .github/renovate.json ================================================ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:recommended", ":semanticPrefixChore", ":prHourlyLimitNone", ":prConcurrentLimitNone", ":enableVulnerabilityAlerts", ":dependencyDashboard", "group:allNonMajor", "schedule:weekly" ], "labels": ["dependencies"], "packageRules": [ { "matchPackageNames": [ "zotero-plugin-toolkit", "zotero-types", "zotero-plugin-scaffold" ], "schedule": ["at any time"], "automerge": true } ], "git-submodules": { "enabled": true } } ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - v** workflow_dispatch: # 新增手动触发入口 inputs: version: description: 'Release version (格式 vX.Y.Z)' required: true skip-notification: description: '跳过通知 (true/false)' default: 'false' permissions: contents: write issues: write pull-requests: write jobs: release: runs-on: ubuntu-latest env: GITHUB_TOKEN: ${{ secrets.GitHub_TOKEN }} steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 # 手动触发时创建标签 - name: Create tag (manual) if: ${{ github.event_name == 'workflow_dispatch' }} run: | git config --local user.email "actions@github.com" git config --local user.name "GitHub Actions" git tag ${{ inputs.version }} git push origin ${{ inputs.version }} - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22.14 - name: Install deps run: npm install -f - name: Build run: npm run build - name: Release to GitHub run: npm run release - name: Notify release if: ${{ !contains(github.event.inputs.skip-notification, 'true') }} uses: apexskier/github-release-commenter@v1 continue-on-error: true with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} comment-template: | :rocket: _This ticket has been resolved in {release_tag}. See {release_link} for release notes._ ================================================ FILE: .gitignore ================================================ build logs node_modules pnpm-lock.yaml yarn.lock .DS_Store .env .scaffold tmp/ ================================================ FILE: .prettierignore ================================================ .vscode build logs node_modules package-lock.json yarn.lock pnpm-lock.yaml ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "macabeus.vscode-fluent" ] } ================================================ FILE: .vscode/launch.json ================================================ { // 使用 IntelliSense 了解相关属性。 // 悬停以查看现有属性的描述。 // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "type": "node", "request": "launch", "name": "Start", "runtimeExecutable": "npm", "runtimeArgs": ["run", "start"] }, { "type": "node", "request": "launch", "name": "Build", "runtimeExecutable": "npm", "runtimeArgs": ["run", "build"] } ] } ================================================ FILE: .vscode/settings.json ================================================ { "[javascript]": { "editor.defaultIndentSize": 2, "editor.tabSize": 2 }, "[typescript]": { "editor.defaultIndentSize": 2, "editor.tabSize": 2 }, "files.eol": "\n", "editor.formatOnType": false, "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, "eslint.validate": ["javascript", "typescript"], "prettier.requireConfig": true, "commentTranslate.hover.enabled": true, "editor.detectIndentation": false, // 关键:禁止自动检测 "prettier.tabWidth": 2, "eslint.options": { "overrideConfig": { "rules": { "indent": "off" // 强制禁用 ESLint 缩进规则 } } } } ================================================ FILE: .vscode/toolkit.code-snippets ================================================ { "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}", "});" ] } } ================================================ FILE: LICENSE ================================================ 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 . ================================================ FILE: README.md ================================================
![Jasminum](./addon/chrome/content/icons/icon.png) # 茉莉花 Jasminum [![zotero target version](https://img.shields.io/badge/Zotero-8/9-green?style=flat-square&logo=zotero&logoColor=CC2936)](https://www.zotero.org) [![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) ![Release](https://img.shields.io/github/release/l0o0/jasminum?style=flat-square)

简体中文 | [English](doc/README-en.md) ## 1. 基础功能 - 中文PDF元数据抓取 - 中文转换器下载,转换器来源于 Zotero中文社区 [translators_CN](https://github.com/l0o0/translators_CN) - 中文引用格式下载,引用格式来源于项目 Zotero中文社区 [styles](https://github.com/zotero-chinese/styles) - 小工具 - 语言设置 - 中文姓名拆分与合并 ## 2.使用教程 ### 2.1 元数据抓取 目前支持仅支持从**中国知网**获取元数据,后续考虑会添加其他数据来源。 在 Zotero 中添加中文附件后,右键附件,在菜单栏选择`茉莉花抓取` -> `抓取期刊元数据`,在弹出窗口可以看到元数据抓取的结果。 如果有多个搜索结果,需要你手动选择最匹配的结果,再点击确认,即可完成抓取。 ![alt text](doc/images/image2.png) ### 2.3 本地附件匹配功能 在使用 Zotero Connector 在浏览器上抓取中文期刊时(尤其是中国知网),经常出现元数据抓取成功而附件无法下载自动的异常,当你手动下载期刊附件(PDF/CAJ)后,可以方便地用此功能来将下载的附件与元数据匹配。 右键期刊条目,`小工具` -> `在下载文件夹中查找附件`,该功能会自动在当前`下载目录`中寻找与当前条目匹配的附件,匹配规划是**根据期刊标题与文件名的匹配度**。 `下载目录`默认是系统的下载目录,Windows系统默认是`C:\Users\用户名\Downloads`,Mac系统默认是`/Users/用户名/Downloads`,Linux系统默认是`/home/用户名/Downloads`。也可以在`设置`中修改下载目录。 下载目录中匹配成功的附件默认会移动到备份目录中`下载目录/jasminum-backup`中,在设置中还可以选择 - 删除匹配成功的附件。匹配到元数据的附件已经保存到Zotero中,可以放心删除下载目录中的附件(个人建议删除,避免下载目录中附件过多)。 - 无须处理。即使匹配成功,附件还是会在下载目录中,当然Zotero已经保存了一份。 ### 2.3 PDF大纲 在 PDF 阅读窗口的左侧边栏中,点击茉莉花书签按钮,即可看到书签大纲窗口。 ![alt text](doc/images/image.png) 最上方的5个按钮,功能分别是: - 展开所有书签 - 折叠所有书签 - 添加书签 - 删除书签 - 将书签内容保存到PDF(默认只以配置文件的形式保存到本地) **键盘快捷键导航** - 键盘↑,上一个书签(跳过折叠内容) - 键盘↓,下一个书签(跳过折叠内容) - 键盘←或→,展开或折叠节点 - 空格键,编辑书签内容 - [,将书签移到上一级(作为原上级节点的下一个相邻节点) - ],将书签移到下一级(自动将相邻的上一个节点作为上级节点) - \,创建新节点(默认作为选中节点的子节点) - Delete 或 Backspace,删除节点 ## 3. ❤️致谢 特别感谢 [jiaojiaodubai](https://github.com/jiaojiaodubai) 同学,长期以来对 [translators_CN](https://github.com/l0o0/translators_CN) 和 本项目 的贡献。 ================================================ FILE: addon/bootstrap.js ================================================ /* eslint-disable no-undef */ /** * 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 */ var chromeHandle; function install(data, reason) {} async function startup({ id, version, resourceURI, rootURI }, reason) { await Zotero.initializationPromise; // String 'rootURI' introduced in Zotero 7 if (!rootURI) { rootURI = resourceURI.spec; } 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/"], ]); /** * Global variables for plugin code. * The `_globalThis` is the global root variable of the plugin sandbox environment * and all child variables assigned to it is globally accessible. * See `src/index.ts` for details. */ const ctx = { rootURI, }; ctx._globalThis = ctx; Services.scriptloader.loadSubScript( `${rootURI}/chrome/content/scripts/__addonRef__.js`, ctx, ); Zotero.__addonInstance__.hooks.onStartup(); } async function onMainWindowLoad({ window }, reason) { Zotero.__addonInstance__?.hooks.onMainWindowLoad(window); } async function onMainWindowUnload({ window }, reason) { Zotero.__addonInstance__?.hooks.onMainWindowUnload(window); } 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/__addonRef__.js`); if (chromeHandle) { chromeHandle.destruct(); chromeHandle = null; } } function uninstall(data, reason) {} ================================================ FILE: addon/chrome/content/preferences-main.xhtml ================================================ ================================================ FILE: addon/chrome/content/preferences-translators.xhtml ================================================
================================================ FILE: addon/chrome/content/prefpanel.css ================================================ :root { --dropdown-bg-color: #f9f9f9; --dropdown-text-color: #333; --dropdown-border-color: #ccc; --dropdown-shadow-color: rgba(0, 0, 0, 0.2); } @media (prefers-color-scheme: dark) { :root { --dropdown-bg-color: #333; --dropdown-text-color: #f9f9f9; --dropdown-border-color: #555; --dropdown-shadow-color: rgba(255, 255, 255, 0.2); } } .dropdown-content { display: none; position: absolute; background-color: var(--dropdown-bg-color); border: 1px solid var(--dropdown-border-color); box-shadow: 0px 8px 16px var(--dropdown-shadow-color); z-index: 1; padding: 8px 16px; font-size: 12px; cursor: pointer; border-radius: 4px; color: var(--dropdown-text-color); } .dropdown-content label { display: block; margin: 5px 0; cursor: pointer; color: var(--dropdown-text-color); } .show { display: block; } .hidden { display: none; } .help-icon { margin-top: 5px; margin-bottom: 5px; width: 20px; } ================================================ FILE: addon/chrome/content/progress.xhtml ================================================

================================================ FILE: addon/locale/en-US/addon.ftl ================================================ plugin-name = Jasminum prefs-table-title = Title prefs-table-detail = Detail tabpanel-lib-tab-label = Lib Tab tabpanel-reader-tab-label = Reader Tab # Preference select-download-folder = Select download folder get-Chinese-styles = Get Chinese Styles info-translators-cn-updaing = Chinese translators are under updating. info-best-speed-source-updated = Updated to fastest source: { $source } info-best-speed-source-failed = Failed to select fastest source, please check network connection # Preference translator table th-filename = File name th-label = Label th-local-update-time = Local update time th-remote-update-time = Remote update time # Help menu help-menu-chinese = Zotero Chinese Community help-menu-wiki = Zotero Wiki help-menu-addons = Addon Store help-menu-csl = Donwload more CSL help-menu-translator = Help with Chinese literature capture # Menu menu-metadata = Metadata(CN) menuitem-retrieveMetadata = Find article metadata menuitem-retrieveMetadataForBook = Find book metadata menuitem-find-attachment = Find attachment in Folder menuitem-import-attachments = Import attachments from Folder menu-tools = Tools menuitem-mergeName = Concat Name menuitem-splitName = Split Name menuitem-updateCNKICite = Update CNKI citation # ui CNKIcitation = CNKICite # popup window citation = Cite no-chinese-item-for-citation = Only Chinese items can find CNKI citation update-translators-start = Start updating translators update-successfully = Update successfully: { $name } update-failed = Update failed: { $name } update-skipped = Up to date: { $name } update-translators-complete = Update translators completed, Success: { $successCounts }, Failed: { $failCounts }, Up to date: { $skipCounts } no-item-need-attachment = No item need attachment no-attachments-found = No attachments found (PDF, CAJ, etc.) import-attachments-success = Import attachments from folder successfully importing-attachments-is-running = An attachment import task is already running. Please try again later. # outline outline = Show Bookmark (By Jasminum) outline-expand-all = Expand all outline-collapse-all = Collapse all outline-add = Add bookmark outline-delete = Delete bookmark outline-save-to-pdf = Save outline to PDF outline-empty-prompt = Please click the button above { $icon } to create a bookmark outline-delete-confirm = This node has child nodes, do you want to delete? {" "} If you delete, all child nodes will be deleted. # bookmark bookmark = Show Bookmarks (By Jasminum) bookmark-add = Add bookmark bookmark-delete = Delete bookmark # Progress window task-msg-header = If you need help with capture issues, please screenshot the following content and contact the developer: [RedBook l0o0](https://www.xiaohongshu.com/user/profile/6153b4fa000000001f03ac8c) task-already-exists = Task already exists: { $title } ================================================ FILE: addon/locale/en-US/mainWindow.ftl ================================================ item-section-example1-head-text = .label = Plugin Template: Item Info item-section-example1-sidenav-tooltip = .tooltiptext = This is Plugin Template section (item info) item-section-example2-head-text = .label = Plugin Template: Reader [{$status}] item-section-example2-sidenav-tooltip = .tooltiptext = This is Plugin Template section (reader) item-section-example2-button-tooltip = .tooltiptext = Unregister this section ================================================ FILE: addon/locale/en-US/preferences-main.ftl ================================================ # Metadata Settings pref-group-metadata = Chinese Metadata Retrieval Settings label-isMainlandChina = .label = Currently located in Chinese Mainland (excluding Hong Kong, Macao and Taiwan), uncheck for overseas users label-autoupdate-metadata = .label = Automatically retrieve metadata from CNKI when adding Chinese PDF/CAJ files label-rename = .label = Rename attachments based on metadata (requires Attanger or zotmoov plugin) label-namepattern = Filename Parsing Template label-namepattern-auto = .label = Smart Recognition .tooltiptext = Use Jasmine's built-in algorithm to intelligently identify authors or titles from filenames label-namepattern-tg = .label = Title_Author (Default) .tooltiptext = Rename files in the format "Title_FirstAuthor," e.g., "Design and Application of Redundant Avionics Systems for Drones_Yang Lu.caj" label-namepattern-t = .label = Title .tooltiptext = Rename files using the "Title" format, e.g., "Design and Application of Redundant Avionics Systems for Drones.caj" label-namepattern-info = Filename recognition template. Select a format from the dropdown or enter directly label-namepattern-custom = .label = Custom .tooltiptext = Set custom rules to extract title and author information from filenames for metadata retrieval label-choose-namepattern = .label = Select Template label-metadata-source = Metadata Retrieval Source label-choose-source = .label = Select Data Source label-metadata-source-cnki = .label = CNKI (China National Knowledge Infrastructure) label-metadata-source-cvip = .label = VIP Journals (Chinese VIP Information) label-pdf-match-folder = Attachment Matching Folder label-choose-folder = .label = Select Folder namepattern-desc = .tooltiptext = Retrieve CNKI metadata based on filenames. Filename format settings: {"{"}%t{"}"}=Title, {"{"}%g{"}"}=Author, {"{"}%y{"}"}=Year, {"{"}%j{"}"}=Other (e.g., source information); specify separators as needed; multiple separators can be used consecutively; file extensions are ignored. Default uses {"{"}%t{"}"}_{"{"}%g{"}"}, which recognizes most CNKI filename formats, including filenames with only titles and no separators. # Transator Settings pref-group-translators = Chinese Translator Settings label-translator-source = Translator Download Source label-best-speed = Choose Fastest Source translatorSource-desc = .tooltiptext = Select the translator download source. Generally, there is no need to switch. If you cannot download the Chinese translator, you can try other sources or click the "Choose Fastest Source" button. label-auto-update-translators = .label = Automatically Update Translators label-translators-force-update = .label = Update Immediately label-translators-detail = Translator Details label-translators-detail-click = Click to View # Attachment Settings pref-group-attachment = Local Attachment Search Settings attachment-folder-desc = .tooltiptext = Search for attachments in the download directory and match them to entries that are missing attachments. Set this to your browser's download directory, and the plugin can batch import and search for attachments from the download directory. label-pdf-match-folder = Attachment Download Folder action-after-import = After matching attachments to entries, what to do with the original downloaded files: label-choose-folder = .label = Choose Folder nothing-label = .label = Do Nothing backup-label = .label = Backup Attachment delete-label = .label = Delete Attachment action-after-import-desc = .tooltiptext = After successfully matching attachments to entries, you can choose one of the following actions: 1. Do Nothing: No action is taken, and the downloaded files remain in the download directory. 2. Backup Attachment: Back up the original downloaded files to a specified directory. 3. Delete Attachment: Delete the original downloaded files (the attachments are already matched and saved in Zotero). # Outline Bookmark Settings pref-group-bookmark = Outline Bookmark Settings label-disableZoteroOutline = .label = Disable Zotero's Built-in Outline label-enableBookmark = .label = Enable Outline Bookmark outline-desc = .tooltiptext = Please note that when you modify the outline or bookmarks, you need to click the 'Save' button to save them to the PDF file. By default, bookmark and outline information is saved separately from the PDF file. # Tool Settings pref-group-tools = Tool Settings label-auto-split-name = .label = Automatically split first name and last name when adding new items label-split-en-name = .label = Include English names when splitting/merging names label-language = Manually Set Language label-tools-info-1 = 💡 The label-tools-info-2 = provides richer metadata inspection functionality label-tools-linter = Linter Plugin # WPS Plugin Installation pref-group-wps = WPS Zotero Plugin label-wps = Install Zotero Add-on for WPS label-wps-help = Usage Help label-install-wps-plugin-click = .label = Click to Install # About pref-group-about = About pref-help = Version { $version } Build { $time } ❤️ label-zotero-chinese = Zotero Chinese Community pref-enable = .label = Enable ================================================ FILE: addon/locale/en-US/preferences-translators.ftl ================================================ title = Chinese Community Translators List github-link = .label = Project Homepage search-box = .placeholder = Search translators # Links how-to-update-translators = How to update translators? translators-dashboard = Translators Dashboard # Buttons request-new-translator = Request new translator report-translator-bug = Report translator bug ================================================ FILE: addon/locale/en-US/progress.ftl ================================================ title = Jasmine Task Window task-list = Task List result-source = Source: { source } result-title = Title: { title } result-score = Match Score: { score } confirm-close = There are still xxx pending tasks. Close the window anyway? ================================================ FILE: addon/locale/zh-CN/addon.ftl ================================================ plugin-name = 茉莉花 prefs-table-title = 标题 prefs-table-detail = 详情 tabpanel-lib-tab-label = 库标签 tabpanel-reader-tab-label = 阅读器标签 # Preference select-download-folder = 选择下载文件保存目录 get-Chinese-styles = 获取中文社区样式… info-translators-cn-updaing = 中文转换器正在更新中 info-best-speed-source-updated = 已更新为最快源:{ $source } info-best-speed-source-failed = 选择最快源失败,请检查网络连接 # Preference translator table th-filename = 文件名 th-label = 标签 th-local-update-time = 本地更新时间 th-remote-update-time = 远程更新时间 # Help menu help-menu-chinese = Zotero 中文社区 help-menu-wiki = Zotero 使用文档 help-menu-addons = 插件商店 help-menu-csl = CSL样式下载 help-menu-translator = 中文文献抓取异常修复 # Menu menu-metadata = 茉莉花抓取 menuitem-retrieveMetadata = 抓取期刊元数据 menuitem-retrieveMetadataForBook = 抓取书籍元数据 menuitem-find-attachment = 在下载文件夹中查找附件 menuitem-import-attachments = 从下载文件夹中导入附件 menu-tools = 小工具 menuitem-mergeName = 合并姓名 menuitem-splitName = 拆分姓名 menuitem-updateCNKICite = 更新知网引用数 # ui CNKIcitation = 知网引用数 # popup window citation = 引用 no-chinese-item-for-citation = 只有中文条目才能抓取引用数哦😀 update-translators-start = 开始更新转换器 update-successfully = 更新成功:{ $name } update-failed = 更新失败:{ $name } update-skipped = 已最新:{ $name } update-translators-complete = 转换器更新完成,成功:{ $successCounts }, 失败:{ $failCounts }, 已最新:{ $skipCounts } no-item-need-attachment = 这些条目已有附件或属于非学术类型条目 no-attachments-found = 未找到可导入的附件(PDF, CAJ等) import-attachments-success = 从文件夹中导入附件成功 importing-attachments-is-running = 已有一个附件导入任务正在运行,请稍后再试。 # outline outline = 显示书签大纲(茉莉花) outline-expand-all = 展开所有 outline-collapse-all = 收起所有 outline-add = 添加书签 outline-delete = 删除书签 outline-save-to-pdf = 将大纲保存到PDF文件 outline-edit-placeholder = 请输入书签 outline-empty-prompt = 请点击上方按钮{ $icon }创建书签 outline-delete-confirm = 该节点有子节点,是否删除? {" "} 如果删除,则所有子节点也会被删除。 # bookmark bookmark = 显示书签(茉莉花) bookmark-add = 添加书签 bookmark-delete = 删除书签 # Progress window task-msg-header = 如果抓取异常需要帮助,请截图以下内容并联系开发者:[小红书l0o0](https://www.xiaohongshu.com/user/profile/6153b4fa000000001f03ac8c) task-already-exists = 任务已存在:{ $title } ================================================ FILE: addon/locale/zh-CN/mainWindow.ftl ================================================ item-section-example1-head-text = .label = 插件模板: 条目信息 item-section-example1-sidenav-tooltip = .tooltiptext = 这是插件模板面板(条目信息) item-section-example2-head-text = .label = 插件模板: 阅读器[{$status}] item-section-example2-sidenav-tooltip = .tooltiptext = 这是插件模板面板(阅读器) item-section-example2-button-tooltip = .tooltiptext = 移除此面板 ================================================ FILE: addon/locale/zh-CN/preferences-main.ftl ================================================ # 元数据设置 pref-group-metadata = 中文元数据抓取设置 label-isMainlandChina = .label = 当前位于中国大陆(不包括中国香港、中国澳门及中国台湾),海外用户请取消勾选 label-autoupdate-metadata = .label = 添加中文PDF/CAJ时自动从知网抓取元数据 label-rename = .label = 根据元数据重命名附件(依赖Attanger或zotmoov插件) label-namepattern = 文件名解析模板 label-namepattern-auto = .label = 智能识别 .tooltiptext = 利用茉莉花内置的算法智能识别文件名的作者或标题 label-namepattern-tg = .label = 标题_作者(默认设置) .tooltiptext =「标题_第一作者」格式命名文件,如「无人机多余度航空电子系统设计与应用_杨璐.caj」 label-namepattern-t = .label = 标题 .tooltiptext =「标题」格式命名文件,如「无人机多余度航空电子系统设计与应用.caj」 label-namepattern-info = 文件名识别模板,从下拉菜单中选择对应格式或直接输入 label-namepattern-custom = .label = 自定义 .tooltiptext = 设置自定义规则,识别文件名中的标题、作者信息用于元数据抓取 label-choose-namepattern = .label = 选择模板 label-metadata-source = 元数据抓取来源 label-choose-source = .label = 选择数据源 label-metadata-source-cnki = .label = 中国知网CNKI label-metadata-source-cvip = .label = 维普期刊CVIP namepattern-desc = .tooltiptext = 根据文件名抓取知网元数据,文件名格式设置: {"{"}%t{"}"}=标题,{"{"}%g{"}"}=作者,{"{"}%y{"}"}=年份,{"{"}%j{"}"}=其他(例如来源信息);分隔符依实情指定,可连续使用多个;不用考虑文件后缀名。 默认使用{"{"}%t{"}"}_{"{"}%g{"}"},可识别大部分知网下载的文件名格式,包括文件名只包括标题无分隔符号。 # 附件查找设置 pref-group-attachment = 本地附件查找设置 attachment-folder-desc = .tooltiptext = 从下载目录中查找附件,并匹配到缺少附件的条目中。 此处请设置为浏览器的下载目录,插件就可以批量从下载目录中导入和查询附件。 label-pdf-match-folder = 附件下载文件夹 action-after-import = 附件匹配到条目之后,如何处理原始下载的附件文件: label-choose-folder = .label = 选择文件夹 nothing-label = .label = 无须处理 backup-label = .label = 备份附件 delete-label = .label = 删除附件 action-after-import-desc = .tooltiptext = 附件成功匹配到条目之后,您可以选择以下操作: 1. 无须处理:不做任何操作,下载的附件还在下载目录中; 2. 备份附件:将原始下载的附件文件备份到指定目录; 3. 删除附件:删除原始下载的附件文件,该附件已经匹配到条目,保存到Zotero中,可放心删除。 # 转换器设置 pref-group-translators = 中文转换器设置 label-translator-source = 转换器下载源 label-best-speed = 选择最快源 translatorSource-desc = .tooltiptext = 选择转换器下载源,一般情况下不用切换。如果您无法下载中文转换器,可选择尝试其他源或点击 选择最快源 按钮。 label-auto-update-translators = .label = 自动更新转换器 label-translators-force-update = .label = 立即更新 label-translators-detail = 转换器详情 label-translators-detail-click = 点击查看 # 大纲书签设置 pref-group-bookmark = 大纲书签设置 label-disableZoteroOutline = .label = 禁用 Zotero 自带的大纲 label-enableBookmark = .label = 启用大纲书签 outline-desc = .tooltiptext = 请注意,当您修改大纲或书签时,需要点击保存按钮才会保存到PDF文件中。默认将书签大纲信息与PDF文件分开保存。 # 小工具设置 pref-group-tools = 小工具设置 label-auto-split-name = .label = 导入新条目时自动拆分姓名 label-split-en-name = .label = 拆分/合并姓名时包括英文名 label-language = 手动设置语言 label-tools-info-1 = 💡 label-tools-info-2 = 提供更丰富的元数据检查功能 label-tools-linter = Linter 插件 # WPS 插件安装 pref-group-wps = WPS Zotero 插件 label-wps = 为 WPS 安装 Zotero 加载项 label-wps-help = 使用帮助 label-install-wps-plugin-click = .label = 点击安装 # 其他 pref-group-about = 关于 pref-help = 版本 { $version } 构建于 { $time } ❤️ label-zotero-chinese = Zotero中文社区 pref-enable = .label = 启用 ================================================ FILE: addon/locale/zh-CN/preferences-translators.ftl ================================================ title = 中文社区转换器列表 github-link = .label = 项目主页 search-box = .placeholder = 搜索转换器 # Links how-to-update-translators = 如何更新转换器? translators-dashboard = 转换器看板 # Buttons request-new-translator = 申请适配 report-translator-bug = 反馈错误 ================================================ FILE: addon/locale/zh-CN/progress.ftl ================================================ title = 茉莉花任务窗口 task-list = 任务列表 result-source = 来源:{ source } result-title = 标题:{ title } result-score = 匹配度:{ score } confirm-close = 还有 xxx 个任务未完成,是否关闭窗口? ================================================ FILE: addon/locale/zh-TW/addon.ftl ================================================ plugin-name = 茉莉花 prefs-table-title = 標題 prefs-table-detail = 詳細資料 tabpanel-lib-tab-label = 圖書館標籤 tabpanel-reader-tab-label = 閱讀器標籤 # Preference select-download-folder = 選擇下載檔案儲存目錄 get-Chinese-styles = 取得中文社群樣式… info-translators-cn-updaing = 中文轉換器正在更新中 info-best-speed-source-updated = 已更新為最快源:{ $source } info-best-speed-source-failed = 選擇最快源失敗,請檢查網路連接 # Preference translator table th-filename = 檔案名稱 th-label = 標籤 th-local-update-time = 本地更新時間 th-remote-update-time = 遠程更新時間 # Help menu help-menu-chinese = Zotero 中文社群 help-menu-wiki = Zotero 使用說明 help-menu-addons = 插件商店 help-menu-csl = CSL樣式下載 help-menu-translator = 中文文獻抓取異常解決 # Menu menu-metadata = 元資料抓取 menuitem-retrieveMetadata = 抓取期刊元資料 menuitem-retrieveMetadataForBook = 抓取書籍元資料 menuitem-find-attachment = 在資料夾中尋找附件 menuitem-import-attachments = 從資料夾中導入附件 menu-tools = 小工具 menuitem-mergeName = 合併姓名 menuitem-splitName = 拆分姓名 menuitem-updateCNKICite = 更新知網引用數 # ui CNKIcitation = 知網引用數 # popup window citation = 引用 no-chinese-item-for-citation = 只有中文項目才能抓取引用數哦😀 update-translators-start = 開始更新轉換器 update-successfully = 更新成功:{ $name } update-failed = 更新失敗:{ $name } update-skipped = 已最新:{ $name } update-translators-complete = 轉換器更新完成,成功:{ $successCounts }, 失敗:{ $failCounts }, 已最新:{ $skipCounts } no-item-need-attachment = 項目已存在附件或屬於非學術類型 import-attachments-success = 從資料夾中導入附件成功 importing-attachments-is-running = 已有附件匯入任務正在執行,請稍後再試 no-attachments-found = 未找到可匯入的附件(PDF、CAJ等) # outline outline = 顯示書籤大纲(茉莉花) outline-expand-all = 展開所有 outline-collapse-all = 收起所有 outline-add = 添加書籤 outline-delete = 刪除書籤 outline-save-to-pdf = 將大纲儲存到PDF檔案 outline-edit-placeholder = 請輸入書籤 outline-empty-prompt = 請點擊上方按鈕{ $icon }創建書籤 outline-delete-confirm = 該節點有子節點,是否刪除? {" "} 如果刪除,則所有子節點也會被刪除。 # bookmark bookmark = 顯示書籤(茉莉花) bookmark-add = 添加書籤 bookmark-delete = 刪除書籤 # Progress window task-msg-header = 如果抓取異常需要幫助,請截圖以下內容並聯繫開發者:[小紅書l0o0](https://www.xiaohongshu.com/user/profile/6153b4fa000000001f03ac8c) task-already-exists = 已存在任務:{ $title } ================================================ FILE: addon/locale/zh-TW/mainWindow.ftl ================================================ item-section-example1-head-text = .label = 插件模板: 条目信息 item-section-example1-sidenav-tooltip = .tooltiptext = 这是插件模板面板(条目信息) item-section-example2-head-text = .label = 插件模板: 阅读器[{$status}] item-section-example2-sidenav-tooltip = .tooltiptext = 这是插件模板面板(阅读器) item-section-example2-button-tooltip = .tooltiptext = 移除此面板 ================================================ FILE: addon/locale/zh-TW/preferences-main.ftl ================================================ # 元數據設定 pref-group-metadata = 中文元數據抓取設定 label-isMainlandChina = .label = 目前位於中國大陸(不包括中國香港、中國澳門及中國台灣),海外用戶請取消勾選 label-autoupdate-metadata = .label = 新增中文PDF/CAJ時自動從知網抓取元數據 label-rename = .label = 根據元數據重新命名附件(依賴Attanger或zotmoov插件) label-namepattern = 檔案名稱解析範本 label-namepattern-auto = .label = 智能識別 .tooltiptext = 利用茉莉花內建的演算法智能識別檔案名稱的作者或標題 label-namepattern-tg = .label = 標題_作者(預設設定) .tooltiptext =「標題_第一作者」格式命名檔案,如「無人機多餘度航空電子系統設計與應用_楊璐.caj」 label-namepattern-t = .label = 標題 .tooltiptext =「標題」格式命名檔案,如「無人機多餘度航空電子系統設計與應用.caj」 label-namepattern-info = 檔案名稱識別範本,從下拉選單中選擇對應格式或直接輸入 label-namepattern-custom = .label = 自訂 .tooltiptext = 設定自訂規則,識別檔案名稱中的標題、作者資訊用於元數據抓取 label-choose-namepattern = .label = 選擇範本 label-metadata-source = 元數據抓取來源 label-choose-source = .label = 選擇資料來源 label-metadata-source-cnki = .label = 中國知網CNKI label-metadata-source-cvip = .label = 維普期刊CVIP label-pdf-match-folder = 附件匹配資料夾 label-choose-folder = .label = 選擇資料夾 namepattern-desc = .tooltiptext = 根據檔案名稱抓取知網元數據,檔案名稱格式設定:{"{"}%t{"}"}=標題,{"{"}%g{"}"}=作者,{"{"}%y{"}"}=年份,{"{"}%j{"}"}=其他(例如來源資訊);分隔符依實際情況指定,可連續使用多個;不用考慮檔案副檔名。預設使用{"{"}%t{"}"}_{"{"}%g{"}"},可識別大部分知網下載的檔案名稱格式,包括檔案名稱只包括標題無分隔符號。 # 轉換器設定 pref-group-translators = 中文轉換器設定 label-translator-source = 轉換器下載源 label-best-speed = 選擇最快源 translatorSource-desc = .tooltiptext = 選擇轉換器下載源,一般情況下不用切換。如果您無法下載中文轉換器,可選擇嘗試其他源或點擊「選擇最快源」按鈕。 label-auto-update-translators = .label = 自動更新轉換器 label-translators-force-update = .label = 立即更新 label-translators-detail = 轉換器詳情 label-translators-detail-click = 點擊查看 # 附件設定 pref-group-attachment = 本地附件查找設定 attachment-folder-desc = .tooltiptext = 從下載目錄中查找附件,並匹配到缺少附件的條目中 此處請設定為瀏覽器的下載目錄,插件即可批次從下載目錄中匯入及查詢附件。 label-pdf-match-folder = 附件下載資料夾 action-after-import = 附件匹配到條目之後,如何處理原始下載的附件檔案: label-choose-folder = .label = 選擇資料夾 nothing-label = .label = 無須處理 backup-label = .label = 備份附件 delete-label = .label = 刪除附件 action-after-import-desc = .tooltiptext = 附件成功匹配到條目之後,您可以選擇以下操作: 1. 無須處理:不做任何操作,下載的附件仍保留在下載目錄中; 2. 備份附件:將原始下載的附件檔案備份到指定目錄; 3. 刪除附件:刪除原始下載的附件檔案(該附件已匹配到條目並儲存到Zotero中)。 # 大綱書籤設定 pref-group-bookmark = 大綱書籤設定 label-disableZoteroOutline = .label = 禁用 Zotero 自帶的大綱 label-enableBookmark = .label = 啟用大綱書籤 outline-desc = .tooltiptext = 請注意,當您修改大綱或書籤時,需要點擊「儲存」按鈕才會將變更保存至PDF檔案中。預設情況下,書籤與大綱資訊會與PDF檔案分開儲存。 # 小工具設定 pref-group-tools = 小工具設定 label-auto-split-name = .label = 導入新條目時自動拆分姓名 label-split-en-name = .label = 拆分/合併姓名時包括英文名 label-language = 手動設定語言 label-tools-info-1 = 💡 label-tools-info-2 = 提供更豐富的元數據檢查功能 label-tools-linter = Linter 插件 # WPS 插件安裝 pref-group-wps = WPS Zotero 插件 label-wps = 為 WPS 安裝 Zotero 加載項 label-wps-help = 使用說明 label-install-wps-plugin-click = .label = 點擊安裝 # 其他 pref-group-about = 關於 pref-help = 版本 { $version } 建置於 { $time } ❤️ label-zotero-chinese = Zotero中文社群 pref-enable = .label = 啟用 ================================================ FILE: addon/locale/zh-TW/preferences-translators.ftl ================================================ title = 中文社群轉換器列表 github-link = .label = 專案首頁 search-box = .placeholder = 搜尋轉換器 # Links how-to-update-translators = 如何更新轉換器? translators-dashboard = 轉換器看板 # Buttons request-new-translator = 請求适配 report-translator-bug = 報告錯誤 ================================================ FILE: addon/locale/zh-TW/progress.ftl ================================================ title = 茉莉花任務視窗 task-list = 任務列表 result-source = 來源:{ source } result-title = 標題:{ title } result-score = 匹配度:{ score } confirm-close = 仍有 xxx 個抓取任務未完成,是否關閉視窗? ================================================ FILE: addon/manifest.json ================================================ { "manifest_version": 2, "name": "__addonName__", "version": "__buildVersion__", "description": "__description__", "homepage_url": "__homepage__", "author": "__author__", "icons": { "48": "chrome/content/icons/icon@0.5x.png", "96": "chrome/content/icons/icon.png" }, "applications": { "zotero": { "id": "__addonID__", "update_url": "__updateURL__", "strict_min_version": "7.999", "strict_max_version": "8.*.*" } } } ================================================ FILE: addon/prefs.js ================================================ /* eslint-disable no-undef */ pref("firstRun", true); pref("translatorsMended", false); /* tools */ pref("autoSplitName", false); pref("splitEnName", false); pref("language", "zh"); /* retrieve metadata */ pref("autoUpdateMetadata", true); pref("namePattern", "{%t}_{%g}"); pref("namePatternCustom", "{%t}"); pref("metadataSource", "CNKI"); pref("isMainlandChina", true); pref("cnkiAttachmentCookie", ""); pref("similarityThresholdForMetaData", "0.6"); /* match pdf */ pref("pdfMatchFolder", ""); pref("actionAfterAttachmentImport", "backup") pref("similarityThreshold", "0.8"); pref("topMatchCount", 3); /* update translators */ pref("autoUpdateTranslators", true); pref("translatorUpdateTime", "0"); pref("translatorSource", ""); /* bookmark */ pref("enableBookmark", true); pref("newNodeAsChild", false); pref("disableZoteroOutline", true); ================================================ FILE: doc/README-zhCN.md ================================================ # Zotero Plugin Template [![zotero target version](https://img.shields.io/badge/Zotero-7-green?style=flat-square&logo=zotero&logoColor=CC2936)](https://www.zotero.org) [![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) 这是 [Zotero](https://www.zotero.org/) 的插件模板. [English](../README.md) | [简体中文](./README-zhCN.md) - 开发指南 - [📖 插件开发文档](https://zotero-chinese.com/plugin-dev-guide/) (中文版,尚不完善) - [📖 Zotero 7 插件开发文档](https://www.zotero.org/support/dev/zotero_7_for_developers) - 开发工具参考 - [🛠️ Zotero 插件工具包](https://github.com/windingwind/zotero-plugin-toolkit) | [API 文档](https://github.com/windingwind/zotero-plugin-toolkit/blob/master/docs/zotero-plugin-toolkit.md) - [🛠️ Zotero 插件开发脚手架](https://github.com/northword/zotero-plugin-scaffold) - [📜 Zotero 源代码](https://github.com/zotero/zotero) - [ℹ️ Zotero 类型定义](https://github.com/windingwind/zotero-types) - [📌 Zotero 插件模板](https://github.com/windingwind/zotero-plugin-template) (即本仓库) > [!tip] > 👁 Watch 本仓库,以及时收到修复或更新的通知. ## 使用此模板构建的插件 [![GitHub Repo stars](https://img.shields.io/github/stars/windingwind/zotero-better-notes?label=zotero-better-notes&style=flat-square)](https://github.com/windingwind/zotero-better-notes) [![GitHub Repo stars](https://img.shields.io/github/stars/windingwind/zotero-pdf-preview?label=zotero-pdf-preview&style=flat-square)](https://github.com/windingwind/zotero-pdf-preview) [![GitHub Repo stars](https://img.shields.io/github/stars/windingwind/zotero-pdf-translate?label=zotero-pdf-translate&style=flat-square)](https://github.com/windingwind/zotero-pdf-translate) [![GitHub Repo stars](https://img.shields.io/github/stars/windingwind/zotero-tag?label=zotero-tag&style=flat-square)](https://github.com/windingwind/zotero-tag) [![GitHub Repo stars](https://img.shields.io/github/stars/iShareStuff/ZoteroTheme?label=zotero-theme&style=flat-square)](https://github.com/iShareStuff/ZoteroTheme) [![GitHub Repo stars](https://img.shields.io/github/stars/MuiseDestiny/zotero-reference?label=zotero-reference&style=flat-square)](https://github.com/MuiseDestiny/zotero-reference) [![GitHub Repo stars](https://img.shields.io/github/stars/MuiseDestiny/zotero-citation?label=zotero-citation&style=flat-square)](https://github.com/MuiseDestiny/zotero-citation) [![GitHub Repo stars](https://img.shields.io/github/stars/MuiseDestiny/ZoteroStyle?label=zotero-style&style=flat-square)](https://github.com/MuiseDestiny/ZoteroStyle) [![GitHub Repo stars](https://img.shields.io/github/stars/volatile-static/Chartero?label=Chartero&style=flat-square)](https://github.com/volatile-static/Chartero) [![GitHub Repo stars](https://img.shields.io/github/stars/l0o0/tara?label=tara&style=flat-square)](https://github.com/l0o0/tara) [![GitHub Repo stars](https://img.shields.io/github/stars/redleafnew/delitemwithatt?label=delitemwithatt&style=flat-square)](https://github.com/redleafnew/delitemwithatt) [![GitHub Repo stars](https://img.shields.io/github/stars/redleafnew/zotero-updateifsE?label=zotero-updateifsE&style=flat-square)](https://github.com/redleafnew/zotero-updateifsE) [![GitHub Repo stars](https://img.shields.io/github/stars/northword/zotero-format-metadata?label=zotero-format-metadata&style=flat-square)](https://github.com/northword/zotero-format-metadata) [![GitHub Repo stars](https://img.shields.io/github/stars/inciteful-xyz/inciteful-zotero-plugin?label=inciteful-zotero-plugin&style=flat-square)](https://github.com/inciteful-xyz/inciteful-zotero-plugin) [![GitHub Repo stars](https://img.shields.io/github/stars/MuiseDestiny/zotero-gpt?label=zotero-gpt&style=flat-square)](https://github.com/MuiseDestiny/zotero-gpt) [![GitHub Repo stars](https://img.shields.io/github/stars/zoushucai/zotero-journalabbr?label=zotero-journalabbr&style=flat-square)](https://github.com/zoushucai/zotero-journalabbr) [![GitHub Repo stars](https://img.shields.io/github/stars/MuiseDestiny/zotero-figure?label=zotero-figure&style=flat-square)](https://github.com/MuiseDestiny/zotero-figure) [![GitHub Repo stars](https://img.shields.io/github/stars/l0o0/jasminum?label=jasminum&style=flat-square)](https://github.com/l0o0/jasminum) [![GitHub Repo stars](https://img.shields.io/github/stars/lifan0127/ai-research-assistant?label=ai-research-assistant&style=flat-square)](https://github.com/lifan0127/ai-research-assistant) [![GitHub Repo stars](https://img.shields.io/github/stars/daeh/zotero-markdb-connect?label=zotero-markdb-connect&style=flat-square)](https://github.com/daeh/zotero-markdb-connect) 如果你正在使用此库,我建议你将这个标志 ([![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)) 放在 README 文件中: ```md [![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) ``` ## Features 特性 - 事件驱动、函数式编程的可扩展框架; - 简单易用,开箱即用; - ⭐[新特性!]自动热重载!每当修改源码时,都会自动编译并重新加载插件;[详情请跳转→](#自动热重载) - `src/modules/examples.ts` 中有丰富的示例,涵盖了插件中常用的大部分API (使用 [zotero-plugin-toolkit](https://github.com/windingwind/zotero-plugin-toolkit); - TypeScript 支持: - 为使用 JavaScript 编写的 Zotero 源码提供全面的类型定义支持 (使用 [zotero-types](https://github.com/windingwind/zotero-types)); - 全局变量和环境设置; - 插件开发/构建/发布工作流: - 自动生成/更新插件版本、更新配置和设置环境变量 (`development`/`production`); - 自动在 Zotero 中构建和重新加载代码; - 自动发布到 GitHub ; - 集成 Prettier 和 ES Lint; ## Examples 示例 此库提供了 [zotero-plugin-toolkit](https://github.com/windingwind/zotero-plugin-toolkit) 中API的示例. 在 `src/examples.ts` 中搜索`@example` 查看示例. 这些示例在 `src/hooks.ts` 中调用演示. ### 基本示例(Basic Examples) - registerNotifier - registerPrefs, unregisterPrefs ### 快捷键示例(Shortcut Keys Examples) - registerShortcuts - exampleShortcutLargerCallback - exampleShortcutSmallerCallback - exampleShortcutConflictionCallback ### UI示例(UI Examples) ![image](https://user-images.githubusercontent.com/33902321/211739774-cc5c2df8-5fd9-42f0-9cdf-0f2e5946d427.png) - registerStyleSheet(the official make-it-red example) - registerRightClickMenuItem - registerRightClickMenuPopup - registerWindowMenuWithSeprator - registerExtraColumn - registerExtraColumnWithCustomCell - registerCustomItemBoxRow - registerLibraryTabPanel - registerReaderTabPanel ### 首选项面板示例(Preference Pane Examples) ![image](https://user-images.githubusercontent.com/33902321/211737987-cd7c5c87-9177-4159-b975-dc67690d0490.png) - Preferences bindings - UI Events - Table - Locale 详情参见 [`src/modules/preferenceScript.ts`](./src/modules/preferenceScript.ts) ### 帮助示例(HelperExamples) ![image](https://user-images.githubusercontent.com/33902321/215119473-e7d0d0ef-6d96-437e-b989-4805ffcde6cf.png) - dialogExample - clipboardExample - filePickerExample - progressWindowExample - vtableExample(See Preference Pane Examples) ### 指令行示例(PromptExamples) Obsidian风格的指令输入模块,它通过接受文本来运行插件,并在弹出窗口中显示可选项. 使用 `Shift+P` 激活. ![image](https://user-images.githubusercontent.com/33902321/215120009-e7c7ed27-33a0-44fe-b021-06c272481a92.png) - registerAlertPromptExample ## 快速上手 ### 0 环境要求 1. 安装 [beta 版 Zotero](https://www.zotero.org/support/beta_builds) 2. 安装 [Node.js](https://nodejs.org/en/) 和 [Git](https://git-scm.com/) > [!note] > 本指南假定你已经对 Zotero 插件的基本结构和工作原理有初步的了解. 如果你还不了解,请先参考[官方文档](https://www.zotero.org/support/dev/zotero_7_for_developers) 和[官方插件样例 Make It Red](https://github.com/zotero/make-it-red)。 ### 1 创建你的仓库(Create Your Repo) 1. 点击 `Use this template`; 2. 使用 `git clone` 克隆上一步生成的仓库;
💡 从 GitHub Codespace 开始 _GitHub CodeSpace_ 使你可以直接开始开发而无需在本地下载代码/IDE/依赖. 重复下列步骤,仅需三十秒即可开始构建你的第一个插件! - 点击首页 `Use this template` 按钮,随后点击 `Open in codespace`, 你需要登录你的 GitHub 账号. - 等待 codespace 加载.
3. 进入项目文件夹; ### 2 配置模板和开发环境(Config Template Settings and Enviroment) 1. 修改 `./package.json` 中的设置,包括: ```json5 { version: "", // 修改为 0.0.0 author: "", description: "", homepage: "", config: { addonName: "", // 插件名称 addonID: "", // 插件 ID 【重要:防止冲突】 addonRef: "", // 插件命名空间:元素前缀等 addonInstance: "", // 注册在 Zotero 根下的实例名 prefsPrefix: "extensions.zotero.${addonRef}", // 首选项的前缀 }, } ``` > [!warning] > 注意设置 addonID 和 addonRef 以避免冲突. 如果你需要在 GitHub 以外的地方托管你的 XPI 包,请修改 `zotero-plugin.config.ts` 中的 `updateURL` 和 `xpiDownloadLink`。 2. 复制 Zotero 启动配置,填入 Zotero 可执行文件路径和 profile 路径. > (可选项) 创建开发用 profile 目录: > > 此操作仅需执行一次: 使用 `/path/to/zotero -p` 启动 Zotero,创建一个新的配置文件并用作开发配置文件。 ```sh cp .env.example .env vim .env ``` 如果你维护了多个插件,可以将这些内容存入系统环境变量,以避免在每个插件中都需要重复设置。 3. 运行 `npm install` 以安装相关依赖 > 如果你使用 `pnpm` 作为包管理器,你需要添加 `public-hoist-pattern[]=*@types/bluebird*` 到`.npmrc`, 详情请查看 [zotero-types](https://github.com/windingwind/zotero-types?tab=readme-ov-file#usage) 的文档. 如果你使用 `npm install` 的过程中遇到了 `npm ERR! ERESOLVE unable to resolve dependency tree` ,这是由于上游依赖 typescript-eslint 导致的错误,请使用 `npm i -f` 命令进行安装。 ### 3 开发插件 使用 `npm start` 启动开发服务器,它将: - 在开发模式下预构建插件 - 启动 Zotero ,并让其从 `build/` 中加载插件 - 打开开发者工具(devtool) - 监听 `src/**` 和 `addon/**`. - 如果 `src/**` 修改了,运行 esbuild 并且重新加载 - 如果 `addon/**` 修改了,(在开发模式下)重新构建插件并且重新加载 #### 自动热重载 厌倦了无休止的重启吗?忘掉它,拥抱热加载! 1. 运行 `npm start`. 2. 编码. (是的,就这么简单) 当检测到 `src` 或 `addon` 中的文件修改时,插件将自动编译并重新加载.
💡 将此功能添加到现有插件的步骤 请参阅:[zotero-plugin-scaffold](https://github.com/northword/zotero-plugin-scaffold)。
#### 调试代码 你还可以: - 在 Tools->Developer->Run Javascript 中测试代码片段; - 使用 `Zotero.debug()` 调试输出. 在 Help->Debug Output Logging->View Output 查看输出; - 调试 UI. Zotero 建立在 Firefox XUL 框架之上. 使用 [XUL Explorer](https://udn.realityripple.com/docs/Archive/Mozilla/XUL_Explorer) 等软件调试 XUL UI. > XUL 文档: ### 4 构建插件 运行 `npm run build` 在生产模式下构建插件,构建的结果位于 `build/` 目录中. 构建步骤: - 创建/清空 `build/` - 复制 `addon/**` 到 `build/addon/**` - 替换占位符:使用 `replace-in-file` 去替换在 `package.json` 中定义的关键字和配置 (`xhtml`、`.flt` 等) - 准备本地化文件以避免冲突,查看官方文档了解更多( - 重命名`**/*.flt` 为 `**/${addonRef}-*.flt` - 在每个消息前加上 `addonRef-` - 使用 Esbuild 来将 `.ts` 源码构建为 `.js`,从 `src/index.ts` 构建到`./build/addon/chrome/content/scripts` - (仅在生产模式下工作) 压缩 `./build/addon` 目录为 `./build/*.xpi` - (仅在生产模式下工作) 准备 `update.json` 或 `update-beta.json` > [!note] > > **Dev & prod 两者有什么区别?** > > - 此环境变量存储在 `Zotero.${addonInstance}.data.env` 中,控制台输出在生产模式下被禁用. > - 你可以根据此变量决定用户无法查看/使用的内容. > - 在生产模式下,构建脚本将自动打包插件并更新 `update.json`. ### 5 发布 如果要构建和发布插件,运行如下指令: ```shell # version increase, git add, commit and push # then on ci, npm run build, and release to GitHub npm run release ``` > [!note] > 在此模板中,release-it 被配置为在本地更新版本号、提交并推送标签,随后 GitHub Action 将重新构建插件并将 XPI 发布到 GitHub Release. #### 关于预发布 该模板将 `prerelease` 定义为插件的测试版,当你在 release-it 中选择 `prerelease` 版本 (版本号中带有 `-` ),构建脚本将创建一个 `update-beta.json` 给预发布版本使用,这将确保常规版本的用户不会自动更新到测试版,只有手动下载并安装了测试版的用户才能自动更新到下一个测试版. 当下一个正式版本更新时,脚本将同步更新 `update.json` 和 `update-beta.json`,这将使正式版和测试版用户都可以更新到最新的正式版. > [!warning] > 严格来说,区分 Zotero 6 和 Zotero 7 兼容的插件版本应该通过 `update.json` 的 `addons.__addonID__.updates[]` 中分别配置 `applications.zotero.strict_min_version`,这样 Zotero 才能正确识别,详情在 Zotero 7 开发文档(. ## Details 更多细节 ### 关于Hooks(About Hooks) > 可以在 [`src/hooks.ts`](https://github.com/windingwind/zotero-plugin-template/blob/main/src/hooks.ts) 中查看更多 1. 当在 Zotero 中触发安装/启用/启动时,`bootstrap.js` > `startup` 被调用 - 等待 Zotero 就绪 - 加载 `index.js` (插件代码的主入口,从 `index.ts` 中构建) - 如果是 Zotero 7 以上的版本则注册资源 2. 主入口 `index.js` 中,插件对象被注入到 `Zotero` ,并且 `hooks.ts` > `onStartup` 被调用. - 初始化插件需要的资源,包括通知监听器、首选项面板和UI元素. 3. 当在 Zotero 中触发卸载/禁用时,`bootstrap.js` > `shutdown` 被调用. - `events.ts` > `onShutdown` 被调用. 移除 UI 元素、首选项面板或插件创建的任何内容. - 移除脚本并释放资源. ### 关于全局变量(About Global Variables) > 可以在 [`src/index.ts`](https://github.com/windingwind/zotero-plugin-template/blob/main/src/index.ts)中查看更多 bootstrap插件在沙盒中运行,但沙盒中没有默认的全局变量,例如 `Zotero` 或 `window` 等我们曾在overlay插件环境中使用的变量. 此模板将以下变量注册到全局范围: ```ts Zotero, ZoteroPane, Zotero_Tabs, window, document, rootURI, ztoolkit, addon; ``` ### 创建元素 API(Create Elements API) 插件模板为 bootstrap 插件提供了一些新的API. 我们有两个原因使用这些 API,而不是使用 `createElement/createElementNS`: - 在 bootstrap 模式下,插件必须在推出(禁用或卸载)时清理所有 UI 元素,这非常麻烦. 使用 `createElement`,插件模板将维护这些元素. 仅仅在退出时 `unregisterAll` . - Zotero 7 需要 createElement()/createElementNS() → createXULElement() 来表示其他的 XUL 元素,而 Zotero 6 并不支持 `createXULElement`. 类似于 React.createElement 的API `createElement` 检测 namespace(xul/html/svg) 并且自动创建元素,返回元素为对应的 TypeScript 元素类型. ```ts createElement(document, "div"); // returns HTMLDivElement createElement(document, "hbox"); // returns XUL.Box createElement(document, "button", { namespace: "xul" }); // manually set namespace. returns XUL.Button ``` ### 关于 Zotero API(About Zotero API) Zotero 文档已过时且不完整,克隆 并全局搜索关键字. > ⭐[zotero-types](https://github.com/windingwind/zotero-types) 提供了最常用的 Zotero API,在默认情况下它被包含在此模板中. 你的 IDE 将为大多数的 API 提供提醒. 猜你需要:查找所需 API的技巧 在 `.xhtml`/`.flt` 文件中搜索 UI 标签,然后在 locale 文件中找到对应的键. ,然后在 `.js`/`.jsx` 文件中搜索此键. ### 目录结构(Directory Structure) 本部分展示了模板的目录结构. - 所有的 `.js/.ts` 代码都在 `./src`; - 插件配置文件:`./addon/manifest.json`; - UI 文件: `./addon/chrome/content/*.xhtml`. - 区域设置文件: `./addon/locale/**/*.flt`; - 首选项文件: `./addon/prefs.js`; > 不要在 `prefs.js` 中换行 ```shell . |-- .eslintrc.json # eslint conf |-- .gitattributes # git conf |-- .github/ # github conf |-- .gitignore # git conf |-- .prettierrc # prettier conf |-- .release-it.json # release-it conf |-- .vscode # vs code conf | |-- extensions.json | |-- launch.json | |-- setting.json | `-- toolkit.code-snippets |-- package-lock.json # npm conf |-- package.json # npm conf |-- LICENSE |-- README.md |-- addon | |-- bootstrap.js # addon load/unload script, like a main.c | |-- chrome | | `-- content | | |-- icons/ | | |-- preferences.xhtml # preference panel | | `-- zoteroPane.css | |-- locale # locale | | |-- en-US | | | |-- addon.ftl | | | `-- preferences.ftl | | `-- zh-CN | | |-- addon.ftl | | `-- preferences.ftl | |-- manifest.json # addon config | `-- prefs.js |-- build/ # build dir |-- scripts # scripts for dev | |-- build.mjs # script to build plugin | |-- scripts.mjs # scripts send to Zotero, such as reload, openDevTool, etc | |-- server.mjs # script to start a development server | |-- start.mjs # script to start Zotero process | |-- stop.mjs # script to kill Zotero process | |-- utils.mjs # utils functions for dev scripts | |-- update-template.json # template of `update.json` | `-- zotero-cmd-template.json # template of local env |-- src # source code | |-- addon.ts # base class | |-- hooks.ts # lifecycle hooks | |-- index.ts # main entry | |-- modules # sub modules | | |-- examples.ts | | `-- preferenceScript.ts | `-- utils # utilities | |-- locale.ts | |-- prefs.ts | |-- wait.ts | `-- window.ts |-- tsconfig.json # https://code.visualstudio.com/docs/languages/jsconfig |-- typings # ts typings | `-- global.d.ts `-- update.json ``` ## Disclaimer 免责声明 在 AGPL 下使用此代码. 不提供任何保证. 遵守你所在地区的法律! 如果你想更改许可,请通过 与我联系. ================================================ FILE: eslint.config.mjs ================================================ // @ts-check Let TS check this config file import eslint from "@eslint/js"; import tseslint from "typescript-eslint"; export default tseslint.config( { ignores: ["build/**", "dist/**", "node_modules/**", "scripts/"], }, { extends: [eslint.configs.recommended, ...tseslint.configs.recommended], rules: { "no-restricted-globals": [ "error", { message: "Use `Zotero.getMainWindow()` instead.", name: "window" }, { message: "Use `Zotero.getMainWindow().document` instead.", name: "document", }, { message: "Use `Zotero.getActiveZoteroPane()` instead.", name: "ZoteroPane", }, "Zotero_Tabs", ], "@typescript-eslint/ban-ts-comment": [ "warn", { "ts-expect-error": "allow-with-description", "ts-ignore": "allow-with-description", "ts-nocheck": "allow-with-description", "ts-check": "allow-with-description", }, ], "@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-explicit-any": [ "off", { ignoreRestArgs: true, }, ], "@typescript-eslint/no-non-null-assertion": "off", }, }, ); ================================================ FILE: package.json ================================================ { "name": "jasminum", "version": "1.1.31", "description": "一个简单的 Zotero 中文插件", "config": { "addonName": "Jasminum", "addonID": "jasminum@linxzh.com", "addonRef": "jasminum", "addonInstance": "Jasminum", "prefsPrefix": "extensions.jasminum" }, "repository": { "type": "git", "url": "git+https://github.com/l0o0/jasminum.git" }, "author": "l0o0", "bugs": { "url": "https://github.com/l0o0/jasminum/issues" }, "homepage": "https://github.com/l0o0/jasminum#readme", "license": "AGPL-3.0-or-later", "scripts": { "start": "zotero-plugin serve", "build": "tsc --noEmit && zotero-plugin build", "lint": "prettier --write . && eslint . --fix", "release": "zotero-plugin release", "test": "echo \"Error: no test specified\" && exit 1", "update-deps": "npm update --save" }, "dependencies": { "pdf-lib": "^1.17.1", "string-similarity": "^4.0.4", "zotero-plugin-toolkit": "5.1.0-beta.4" }, "devDependencies": { "@eslint/js": "^9.27.0", "@types/node": "^25.0.10", "@types/string-similarity": "^4.0.2", "eslint": "^9.27.0", "prettier": "^3.5.3", "typescript": "^5.8.3", "typescript-eslint": "^8.33.0", "zotero-plugin-scaffold": "^0.8.0", "zotero-types": "4.1.0-beta.4" }, "prettier": { "printWidth": 80, "tabWidth": 2, "endOfLine": "lf", "overrides": [ { "files": [ "*.xhtml" ], "options": { "htmlWhitespaceSensitivity": "css" } } ] }, "packageManager": "pnpm@9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf" } ================================================ FILE: src/addon.ts ================================================ import hooks from "./hooks"; import { createZToolkit } from "./utils/ztoolkit"; import { Progress } from "./modules/progress"; import { VirtualizedTableHelper } from "zotero-plugin-toolkit"; import { MyCookieSandbox } from "./utils/cookiebox"; import { getOutlineFromPDF } from "./modules/outline/outline"; import { TaskRunner } from "./utils/task"; import { requestDocument } from "./utils/http"; class Addon { public data: { alive: boolean; // Env type, see build.js env: "development" | "production"; ztoolkit: ZToolkit; locale?: { current: any; }; prefs?: { window: Window; }; progress: Progress; windows: Record; translators: { window?: Window; helper?: VirtualizedTableHelper; rows: TableRow[]; allRows: TableRow[]; selected?: string; updating?: boolean; }; myCookieSandbox: MyCookieSandbox; isImportingAttachments: boolean; }; // Lifecycle hooks public hooks: typeof hooks; // APIs public api: object; public taskRunner: TaskRunner; constructor() { this.data = { alive: true, env: __env__, ztoolkit: createZToolkit(), progress: new Progress(), windows: {}, translators: { rows: [], allRows: [], updating: false, }, myCookieSandbox: new MyCookieSandbox(), isImportingAttachments: false, }; this.hooks = hooks; this.api = { getOutlineFromPDF, requestDocument }; this.taskRunner = new TaskRunner(); } } export default Addon; ================================================ FILE: src/hooks.ts ================================================ import { config } from "../package.json"; import { initLocale } from "./utils/locale"; import { registerPrefsPane, onPrefsWindowLoad, initPrefs, } from "./modules/preferences/main"; import { createZToolkit } from "./utils/ztoolkit"; import { registerMenu } from "./modules/menu"; import { registerExtraColumnWithCustomCell, registerNotifiers, registerTab, } from "./modules/notifier"; import { injectStylesLink } from "./modules/styles"; import { updateTranslators } from "./modules/translators"; import { getPref } from "./utils/prefs"; async function onStartup() { await Promise.all([ Zotero.initializationPromise, Zotero.unlockPromise, Zotero.uiReadyPromise, ]); initLocale(); registerPrefsPane(); initPrefs(); registerNotifiers(); registerMenu(); registerTab(); await registerExtraColumnWithCustomCell(); injectStylesLink(); // @ts-ignore - Not typed. await Zotero.Promise.delay(1000); await Promise.all( Zotero.getMainWindows().map((win) => onMainWindowLoad(win)), ); } async function onMainWindowLoad(win: Window): Promise { // Create ztoolkit for every window addon.data.ztoolkit = createZToolkit(); // @ts-ignore - Not typed. await Zotero.Promise.delay(1000); if (getPref("autoUpdateTranslators")) { // @ts-ignore - Not typed. await Zotero.Promise.delay(10000); ztoolkit.log("auto update translators"); updateTranslators(); } } function onShutdown(): void { ztoolkit.unregisterAll(); // Remove addon object addon.data.alive = false; // @ts-ignore - Plugin instance is not typed delete Zotero[config.addonInstance]; } // 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, onMainWindowLoad, onPrefsWindowLoad, }; ================================================ FILE: src/index.ts ================================================ import { BasicTool } from "zotero-plugin-toolkit"; import Addon from "./addon"; import { config } from "../package.json"; const basicTool = new BasicTool(); // @ts-ignore - Plugins instance not typed. if (!basicTool.getGlobal("Zotero")[config.addonInstance]) { _globalThis.addon = new Addon(); defineGlobal("ztoolkit", () => { return _globalThis.addon.data.ztoolkit; }); // @ts-ignore - Plugins instance not typed. Zotero[config.addonInstance] = addon; } function defineGlobal(name: Parameters[0]): void; function defineGlobal(name: string, getter: () => any): void; function defineGlobal(name: string, getter?: () => any) { Object.defineProperty(_globalThis, name, { get() { return getter ? getter() : basicTool.getGlobal(name); }, }); } ================================================ FILE: src/modules/attachments/index.ts ================================================ import { getPref } from "../../utils/prefs"; import { LocalAttachmentService } from "./localMatch"; const localService = new LocalAttachmentService(); export async function attachmentSearch(task: AttachmentTask): Promise { const attachmentSearchResults = await localService.searchAttachments(task); if (!attachmentSearchResults || attachmentSearchResults.length === 0) { task.addMsg("No matching attachments found in local."); task.status = "fail"; return; } else if (attachmentSearchResults.length === 1) { task.searchResults = attachmentSearchResults; task.resultIndex = 0; task.addMsg("Found one matching attachment in local."); } else { task.status = "multiple_results"; task.searchResults = attachmentSearchResults; task.addMsg( `Found ${attachmentSearchResults.length} matching attachments in local.`, ); } } export async function importAttachment(task: AttachmentTask): Promise { // Maybe oneday I will support remote attachment import await localService.importAttachment(task); // Action after import await actionAfterImport(task.searchResults![task.resultIndex!].url); } export async function actionAfterImport( attachmentPath: string, action?: string, ): Promise { const a = action || getPref("actionAfterAttachmentImport") || "nothing"; const attachmentName = PathUtils.filename(attachmentPath); const backupFolder = PathUtils.join( getPref("pdfMatchFolder"), "jasminum-backup", ); const backupFile = PathUtils.join(backupFolder, attachmentName); ztoolkit.log("Action after import: ", a, attachmentName); switch (a) { case "nothing": ztoolkit.log("No action after import."); break; case "backup": ztoolkit.log("Backing up the attachment..."); await IOUtils.makeDirectory(backupFolder, { ignoreExisting: true }); await IOUtils.move(attachmentPath, backupFile); break; case "delete": ztoolkit.log("Deleting the attachment..."); await IOUtils.remove(attachmentPath); break; } } ================================================ FILE: src/modules/attachments/localMatch.ts ================================================ import { compareTwoStrings } from "string-similarity"; import { getPref } from "../../utils/prefs"; import { isChineseAttachmentFilename } from "../../utils/detect"; // Return full path of the attachments. export async function findAttachmentsInFolder( folder?: string, ): Promise { if (!folder) folder = getPref("pdfMatchFolder"); ztoolkit.log(folder); return (await IOUtils.getChildren(folder)).filter((filename) => { ztoolkit.log(filename); return isChineseAttachmentFilename(PathUtils.filename(filename)); }); } export class LocalAttachmentService implements AttachmentService { async searchAttachments( task: AttachmentTask, ): Promise { ztoolkit.log("Searching for local attachments..."); const threshold = parseFloat(getPref("similarityThreshold")); const top = getPref("topMatchCount"); const searchString = task.item.getField("title"); const attachmentFilenames = await findAttachmentsInFolder(); ztoolkit.log(attachmentFilenames); if (!attachmentFilenames || attachmentFilenames.length === 0) { return null; } // 创建包含评分和文件名的对象数组 const scoredItems = attachmentFilenames.map((filename) => { const name = PathUtils.filename(filename); const name_no_ext = name.replace(/\.(pdf|caj|kdh|nh)$/i, ""); const score = compareTwoStrings( searchString.toUpperCase(), name_no_ext.toUpperCase(), ); ztoolkit.log( searchString.toUpperCase(), name, name_no_ext.toUpperCase(), score, ); return { title: name, filename: name, score: score, url: filename, source: "local", }; }); ztoolkit.log(scoredItems); // 按评分降序排序 const sortedItems = scoredItems.sort((a, b) => b.score - a.score); // 过滤阈值并取前3项 const topMatches = sortedItems .filter((item) => item.score >= threshold) .slice(0, top); return topMatches.length > 0 ? topMatches : null; } async importAttachment(task: AttachmentTask): Promise { if ( !task.searchResults || task.searchResults.length === 0 || task.resultIndex === undefined ) { task.addMsg("Found attachment, but import failed."); task.status = "fail"; return; } const searchResult = task.searchResults[task.resultIndex]; const importOptions: _ZoteroTypes.Attachments.OptionsFromFile = { file: searchResult.url, parentItemID: task.item.id, title: `FullText_by_Jasminum.${searchResult.title}`, }; const importItem = await Zotero.Attachments.importFromFile(importOptions); if (importItem) { task.status = "success"; } } } ================================================ FILE: src/modules/menu.ts ================================================ import { MenuitemOptions } from "zotero-plugin-toolkit/dist/managers/menu"; import { config } from "../../package.json"; import { getString } from "../utils/locale"; import { mergeName, splitName, updateCNKICite, importAttachmentsFromFolder, handleAttachmentMenu, } from "./tools"; import { isChineseTopAttachment, isChinsesSnapshot } from "../utils/detect"; const metaddataMenuItems: MenuitemOptions[] = [ { tag: "menuitem", label: "retrieveMetadata", icon: `chrome://${config.addonRef}/content/icons/searchCNKI.png`, isHidden: (_elm, _ev) => Zotero.getActiveZoteroPane() .getSelectedItems() .some((item) => { return !(isChineseTopAttachment(item) || isChinsesSnapshot(item)); }), commandListener: async () => { const items = Zotero.getActiveZoteroPane().getSelectedItems(); for (const item of items) { await addon.taskRunner.createAndAddTask( item, isChineseTopAttachment(item) ? "attachment" : "snapshot", ); } }, }, { tag: "menuitem", label: "retrieveMetadataForBook", icon: `chrome://${config.addonRef}/content/icons/searchCNKI.png`, isHidden: () => true, // getVisibility: (_elm, _ev) => // Zotero.getActiveZoteroPane() // .getSelectedItems() // .some((item) => { // return isChineseTopAttachment(item); // }), commandListener: () => { // @ts-ignore - The plugin instance is not typed. Zotero[config.addonInstance].scraper.search( Zotero.getActiveZoteroPane().getSelectedItems()[0], ); }, }, ]; const toolsMenuItems: MenuitemOptions[] = [ { tag: "menuitem", label: "mergeName", icon: `chrome://${config.addonRef}/content/icons/name.png`, commandListener: () => { for (const item of Zotero.getActiveZoteroPane().getSelectedItems()) { mergeName(item); } }, }, { tag: "menuitem", label: "splitName", icon: `chrome://${config.addonRef}/content/icons/name.png`, commandListener: () => { for (const item of Zotero.getActiveZoteroPane().getSelectedItems()) { splitName(item); } }, }, { tag: "menuitem", label: "updateCNKICite", icon: `chrome://${config.addonRef}/content/icons/cite.png`, commandListener: async () => { await updateCNKICite(Zotero.getActiveZoteroPane().getSelectedItems()); }, }, { tag: "menuitem", label: "find-attachment", icon: `chrome://${config.addonRef}/content/icons/attachment-search.svg`, commandListener: () => { handleAttachmentMenu("item"); }, }, ]; export function registerMenu() { const separatorMenu: MenuitemOptions = { tag: "menuseparator", id: `${config.addonRef}-separator`, isHidden: (_event) => Zotero.getActiveZoteroPane() .getSelectedItems() .some((item) => { return !( isChineseTopAttachment(item) || isChinsesSnapshot(item) || (item.isTopLevelItem() && item.isRegularItem()) ); }), }; const metadataMenu: MenuitemOptions = { tag: "menu", label: getString("menu-metadata"), id: `${config.addonRef}-metadata-menu`, icon: `chrome://${config.addonRef}/content/icons/icon.png`, children: metaddataMenuItems.map((subOption) => { const label = subOption.label as string; subOption.id = `${config.addonRef}-menuitem-${label}`; subOption.label = getString(`menuitem-${label}`); return subOption; }), isHidden: (_event) => Zotero.getActiveZoteroPane() .getSelectedItems() .some((item) => { return !(isChineseTopAttachment(item) || isChinsesSnapshot(item)); }), }; const toolsMenu: MenuitemOptions = { tag: "menu", label: getString("menu-tools"), id: `${config.addonRef}-tools-menu`, icon: `chrome://${config.addonRef}/content/icons/icon.png`, children: toolsMenuItems.map((subOption) => { const label = subOption.label as string; subOption.id = `${config.addonRef}-menuitem-${label}`; subOption.label = getString(`menuitem-${label}`); return subOption; }), isHidden: () => Zotero.getActiveZoteroPane() .getSelectedItems() .some((item) => { return !(item.isTopLevelItem() && item.isRegularItem()); }), }; ztoolkit.Menu.register("item", separatorMenu); ztoolkit.Menu.register("item", metadataMenu); ztoolkit.Menu.register("item", toolsMenu); const attachmentMenu: MenuitemOptions = { tag: "menuitem", label: getString("menuitem-find-attachment"), id: `${config.addonRef}-attachment-menu`, icon: `chrome://${config.addonRef}/content/icons/attachment-search.svg`, commandListener: () => { handleAttachmentMenu("collection"); }, isHidden: () => Zotero.getActiveZoteroPane().getSelectedCollection() === undefined ? true : false, }; const importAttachmentMenu: MenuitemOptions = { tag: "menuitem", label: getString("menuitem-import-attachments"), id: `${config.addonRef}-attachment-menu`, icon: `chrome://${config.addonRef}/content/icons/folder-import.svg`, commandListener: async () => { await importAttachmentsFromFolder(); }, isHidden: () => Zotero.getActiveZoteroPane().getSelectedCollection() === undefined ? true : false, }; ztoolkit.Menu.register("collection", attachmentMenu); ztoolkit.Menu.register("collection", importAttachmentMenu); // ztoolkit.Menu.register("item", { // tag: "menuitem", // label: "TEST", // commandListener: async () => { // // downloadTranslator(true); // const item = Zotero.getActiveZoteroPane().getSelectedItems()[0]; // const title = await getPDFTitle(item.id); // ztoolkit.log(title); // }, // }); // Disable in collection // ztoolkit.Menu.register("collection", metadataMenu); } ================================================ FILE: src/modules/notifier.ts ================================================ import { config } from "../../package.json"; import { getString } from "../utils/locale"; import { getPref } from "../utils/prefs"; import { isChineseTopAttachment } from "../utils/detect"; import { registerOutline } from "./outline"; import { splitName } from "./tools"; /** * A wrap for Zotero.Notifier.registerObserver, * which will automatically unregister the observer when the addon is disabled. */ function registerNotifier( onNotify: ( event: string, type: string, ids: number[] | string[], extraData: { [key: string]: any }, ) => void, types: _ZoteroTypes.Notifier.Type[], ) { const callback = { notify: async ( event: string, type: string, ids: number[] | string[], extraData: { [key: string]: any }, ) => { if (!addon?.data.alive) { unregisterNotifier(notifierID); return; } onNotify(event, type, ids, extraData); }, }; // Register the callback in Zotero as an item observer const notifierID = Zotero.Notifier.registerObserver(callback, types); Zotero.Plugins.addObserver({ shutdown: ({ id }) => { if (id === config.addonID) unregisterNotifier(notifierID); }, }); } function unregisterNotifier(notifierID: string) { Zotero.Notifier.unregisterObserver(notifierID); } /** * Register notifiers for the addon at startup hooks. */ export function registerNotifiers() { registerNotifier(onAddItem, ["item"]); // registerNotifier(onOpenTab, ["tab"]); } async function onAddItem( event: string, type: string, ids: Array, extraData: { [key: string]: any }, ) { // ztoolkit.log(`notify: add item, event: ${event}, type: ${type}, ids: ${ids}`); if (event !== "add" || type !== "item") return; for (const id of ids) { const item = Zotero.Items.get(id); if (getPref("autoUpdateMetadata")) { if (isChineseTopAttachment(item)) { await addon.taskRunner.createAndAddTask(item, "attachment"); } } if (getPref("autoSplitName")) { splitName(item); } } } // TODO: Complete the notifier. // async function onOpenTab( // event: string, // type: string, // ids: Array, // extraData: { [key: string]: any }, // ) { // const id = ids[0]; // if ( // (event == "select" || event == "load") && // type == "tab" && // extraData[id].type == "reader" // ) { // ztoolkit.log("onOpenTab", event, type, extraData); // if (getPref("enableBookmark")) { // await registerOutline(id as string); // } else { // ztoolkit.log("Jasminum bookmark is disabled"); // } // } // } export async function registerExtraColumnWithCustomCell() { const registeredDataKey = Zotero.ItemTreeManager.registerColumn({ dataKey: "CNKIcitation", label: getString("CNKIcitation"), pluginID: config.addonID, dataProvider: (item, dataKey) => { // 网友提供的特殊字符,方便排序 return ztoolkit.ExtraField.getExtraField(item, "CNKICite") || "\u2068"; }, // @ts-ignore - Not typed. // renderCell(index, data, column, isFirstColumn, doc) { // const span = doc.createElementNS("http://www.w3.org/1999/xhtml", "span"); // span.className = `cell ${column.className}`; // span.title = getString("CNKIcitation"); // span.innerText = data == "" ? null : data; // return span; // }, }); } // For Outline register. export function registerTab() { Zotero.Reader.registerEventListener( "renderToolbar", tabRegisterCallback, config.addonID, ); // Zotero.Reader.registerEventListener( // "renderTextSelectionPopup", // (event: any) => { // ztoolkit.log(event); // event.append("
Jasminum
"); // }, // ); } async function tabRegisterCallback(event: any) { if (getPref("enableBookmark")) { const { reader } = event; await registerOutline(reader.tabID); } else { ztoolkit.log("Jasminum bookmark is disabled"); } } ================================================ FILE: src/modules/outline/bookmark.ts ================================================ import { version } from "../../../package.json"; import { getString } from "../../utils/locale"; import { ICONS } from "./style"; import { OUTLINE_SCHEMA } from "./outline"; export const BOOKMARK_SCHEMA = OUTLINE_SCHEMA; export const DEFAULT_BOOKMARK_FONT_SIZE = 13; // Default font size for bookmarks // 学生友好的清新现代颜色 export const DEFAULT_BOOKMARK_COLORS = [ "#FF6B6B", // 珊瑚红 "#4ECDC4", // 薄荷绿 "#45B7D1", // 天空蓝 "#96CEB4", // 薄荷色 "#FECA57", // 向日葵黄 "#FF9FF3", // 粉紫色 "#54A0FF", // 宝蓝色 "#5F27CD", // 紫罗兰 "#00D2D3", // 青绿色 "#FF9F43", // 橙色 "#10AC84", // 翡翠绿 "#EE5A24", // 朱砂橙 ]; // 获取随机颜色 function getRandomBookmarkColor(): string { const randomIndex = Math.floor( Math.random() * DEFAULT_BOOKMARK_COLORS.length, ); return DEFAULT_BOOKMARK_COLORS[randomIndex]; } function migrateBookmarkInfo( raw: any, fromSchema: number, ): { bookmarks: BookmarkNode[]; baseFontSize: number } { let bookmarks: BookmarkNode[] = raw.bookmarks ?? []; let baseFontSize = DEFAULT_BOOKMARK_FONT_SIZE; // v1 → v2: add baseFontSize and bookmark color if (fromSchema < 2) { baseFontSize = raw.info?.baseFontSize ?? DEFAULT_BOOKMARK_FONT_SIZE; bookmarks = bookmarks.map((b: any) => ({ ...b, color: b.color || getRandomBookmarkColor(), })); } // Future v2 → v3 migrations go here return { bookmarks, baseFontSize }; } function getReaderPagePosition(): PdfPosition { const reader = Zotero.Reader.getByTabID( ztoolkit.getGlobal("Zotero_Tabs").selectedID, ); const primaryView = reader._internalReader ._primaryView as _ZoteroTypes.Reader.PDFView; const PDFViewerApplication = primaryView._iframeWindow!.PDFViewerApplication; const doc = primaryView._iframeWindow!.document; const container = doc.getElementById("viewerContainer")!; const pageIndex = PDFViewerApplication.pdfViewer!.currentPageNumber - 1; const pageView = PDFViewerApplication.pdfViewer!.getPageView(pageIndex); const viewport = pageView.viewport; const scrollX = 0; const scrollY = container.scrollTop - pageView.div.offsetTop; const [x, y] = viewport.convertToPdfPoint(scrollX, scrollY); return { position: { pageIndex, rects: [[x, y, x, y]] } }; } export async function saveBookmarksToJSON( item?: Zotero.Item, bookmarks?: BookmarkNode[], baseFontSize?: number, ) { if (!bookmarks) { bookmarks = getBookmarksFromPage(); } if (!item) { const reader = Zotero.Reader.getByTabID( ztoolkit.getGlobal("Zotero_Tabs").selectedID, ); item = reader._item; } // Get current baseFontSize if not provided if (baseFontSize === undefined) { const currentInfo = await loadBookmarkInfoFromJSON(item); baseFontSize = currentInfo?.baseFontSize ?? DEFAULT_BOOKMARK_FONT_SIZE; } const bookmarkInfo: BookmarkInfo = { info: { itemID: item.id, schema: BOOKMARK_SCHEMA, jasminumVersion: version, baseFontSize: baseFontSize, }, bookmarks: bookmarks, }; const bookmarkStr = JSON.stringify(bookmarkInfo); const bookmarkPath = PathUtils.join( Zotero.DataDirectory.dir, "storage", item.key, "jasminum-bookmarks.json", ); await Zotero.File.putContentsAsync(bookmarkPath, bookmarkStr); ztoolkit.log("Save bookmarks to JSON"); } export async function loadBookmarkInfoFromJSON( item: Zotero.Item, ): Promise<{ bookmarks: BookmarkNode[]; baseFontSize: number } | null> { const bookmarkPath = PathUtils.join( Zotero.DataDirectory.dir, "storage", item.key, "jasminum-bookmarks.json", ); const isFileExist = await IOUtils.exists(bookmarkPath); if (!isFileExist) { ztoolkit.log(`Bookmarks json is missing: ${bookmarkPath}`); return null; } else { const content = (await Zotero.File.getContentsAsync( bookmarkPath, )) as string; const tmp = JSON.parse(content); const fileSchema = tmp.info?.schema ?? 1; if (fileSchema < BOOKMARK_SCHEMA) { // Migrate old bookmark data instead of discarding const migrated = migrateBookmarkInfo(tmp, fileSchema); await saveBookmarksToJSON( item, migrated.bookmarks, migrated.baseFontSize, ); return migrated; } else { const bookmarkInfo = JSON.parse(content) as BookmarkInfo; return { bookmarks: bookmarkInfo.bookmarks, baseFontSize: bookmarkInfo.info.baseFontSize ?? DEFAULT_BOOKMARK_FONT_SIZE, }; } } } export async function loadBookmarksFromJSON( item: Zotero.Item, ): Promise { const info = await loadBookmarkInfoFromJSON(item); return info?.bookmarks ?? null; } export function getBookmarksFromPage(): BookmarkNode[] { const reader = Zotero.Reader.getByTabID( ztoolkit.getGlobal("Zotero_Tabs").selectedID, ); const rootUL = reader._iframeWindow!.document.querySelector( "#bookmark-root-list", ); if (!rootUL) return []; const bookmarkItems = Array.from(rootUL.querySelectorAll("li.bookmark-item")); if (bookmarkItems.length === 0) { ztoolkit.log("No bookmarks found on this page."); return []; } return bookmarkItems.map((li, index) => { const bookmarkDiv = (li as Element).querySelector("div.bookmark-node")!; const titleSpan = (li as Element).querySelector("span.bookmark-title")!; return { id: bookmarkDiv.getAttribute("data-id")!, title: titleSpan.textContent!, page: parseInt(bookmarkDiv.getAttribute("page")!), x: parseFloat(bookmarkDiv.getAttribute("x")!), y: parseFloat(bookmarkDiv.getAttribute("y")!), order: index, createdAt: parseInt(bookmarkDiv.getAttribute("data-created") || "0"), color: bookmarkDiv.getAttribute("data-color") || DEFAULT_BOOKMARK_COLORS[0], }; }); } export function createBookmarkNodes( nodes: BookmarkNode[] | null, parentElement: HTMLElement, doc: Document, ) { if (nodes === null || nodes.length == 0) { ztoolkit.UI.appendElement( { tag: "div", namespace: "html", classList: ["empty-bookmark-prompt"], properties: { innerHTML: `请点击上方按钮${ICONS.add}创建书签` }, }, parentElement, ); } else { // 按order排序 const sortedNodes = [...nodes].sort((a, b) => a.order - b.order); sortedNodes.forEach((node) => { const li = ztoolkit.UI.createElement(doc, "li", { namespace: "html", classList: ["bookmark-item"], children: [ { tag: "div", namespace: "html", classList: ["bookmark-node"], attributes: { draggable: "true", "data-id": node.id, page: node.page, x: node.x, y: node.y, "data-created": node.createdAt, "data-color": node.color, }, styles: { borderLeftColor: node.color, }, children: [ { tag: "div", namespace: "html", classList: ["bookmark-content"], children: [ { tag: "span", namespace: "html", classList: ["bookmark-title"], properties: { textContent: node.title }, attributes: { title: `${node.title}, Page: ${node.page}`, }, }, ], }, ], }, ], }); parentElement.appendChild(li); }); } } // 生成智能书签名称 function generateSmartBookmarkTitle(pageNumber: number): string { const existingBookmarks = getBookmarksFromPage(); const baseName = `P_${pageNumber}_`; // 检查是否有重名 const existingTitles = existingBookmarks.map((b) => b.title); // 找到下一个可用的数字后缀 let counter = 1; let candidateName = `${baseName}${counter}`; while (existingTitles.includes(candidateName)) { counter++; candidateName = `${baseName}${counter}`; } return candidateName; } export function addNewBookmark(title?: string): BookmarkNode { const location = getReaderPagePosition(); const now = Date.now(); const pageNumber = location.position.pageIndex + 1; return { id: `bookmark_${now}_${Math.random().toString(36).substr(2, 9)}`, title: title || generateSmartBookmarkTitle(pageNumber), page: pageNumber, x: location.position.rects[0][0], y: location.position.rects[0][1], order: now, // 使用时间戳作为默认排序 createdAt: now, color: getRandomBookmarkColor(), }; } export function addBookmarkButton(doc: Document) { if (doc.querySelector("#sidebarContainer div.start") === null) { ztoolkit.log("Sidebar toolbar button is missing."); } ztoolkit.UI.appendElement( { tag: "button", namespace: "html", id: "j-bookmark-button", classList: ["toolbar-button"], properties: { innerHTML: ICONS.bookmark }, attributes: { title: getString("bookmark"), tabindex: "-1", role: "tab", "aria-selected": "false", "aria-controls": "j-bookmark-viewer", }, }, doc.querySelector("#sidebarContainer div.start")!, ); } // Update bookmark font size dynamically export function updateBookmarkFontSize(doc: Document, baseFontSize: number) { const styleId = "jasminum-bookmark-dynamic-font-size"; let styleElement = doc.getElementById(styleId) as HTMLStyleElement; if (!styleElement) { styleElement = doc.createElement("style"); styleElement.id = styleId; styleElement.type = "text/css"; doc.querySelector("head")!.appendChild(styleElement); } const dynamicCSS = ` .bookmark-node { font-size: ${baseFontSize}px !important; } `; styleElement.textContent = dynamicCSS; ztoolkit.log(`Updated bookmark font size: ${baseFontSize}px`); } ================================================ FILE: src/modules/outline/events.ts ================================================ import { saveOutlineToJSON, createTreeNodes, getOutlineFromPDF, updateOutlineFontSize, loadOutlineInfoFromJSON, DEFAULT_BASE_FONT_SIZE, } from "./outline"; import { saveBookmarksToJSON, createBookmarkNodes, addNewBookmark, DEFAULT_BOOKMARK_COLORS, updateBookmarkFontSize, loadBookmarkInfoFromJSON, DEFAULT_BOOKMARK_FONT_SIZE, } from "./bookmark"; import { ICONS } from "./style"; import { getString } from "../../utils/locale"; import { getPref } from "../../utils/prefs"; const MAX_LEVEL = 7; function getReaderPagePosition(): PdfPosition { const reader = Zotero.Reader.getByTabID( ztoolkit.getGlobal("Zotero_Tabs").selectedID, ); const primaryView = reader._internalReader ._primaryView as _ZoteroTypes.Reader.PDFView; const PDFViewerApplication = primaryView._iframeWindow!.PDFViewerApplication; const doc = primaryView._iframeWindow!.document; const container = doc.getElementById("viewerContainer")!; const pageIndex = PDFViewerApplication.pdfViewer!.currentPageNumber - 1; const pageView = PDFViewerApplication.pdfViewer!.getPageView(pageIndex); const viewport = pageView.viewport; // const scrollX = container.scrollLeft - pageView.div.offsetLeft; const scrollX = 0; const scrollY = container.scrollTop - pageView.div.offsetTop; const [x, y] = viewport.convertToPdfPoint(scrollX, scrollY); ztoolkit.log( "get position", pageIndex + 1, container.scrollTop, scrollX, scrollY, x, y, ); return { position: { pageIndex, rects: [[x, y, x, y]] } }; } export function initEventListener( reader: _ZoteroTypes.ReaderInstance, doc: Document, ) { // Hide or show side bar function hideShowMyOutlineAndBar(e: Event) { const targetElement = e.target as Element; const button = targetElement.closest("button"); if (!button) return; ztoolkit.log("click to hide outline/bookmark", targetElement, button); // Enable j outline view if (button.id === "j-outline-button") { ztoolkit.log("jasminum show outline"); reader.setSidebarView("jasminum-outline"); doc.getElementById("jasminum-outline")?.classList.remove("hidden"); doc.getElementById("jasminum-bookmarks")?.classList.add("hidden"); doc .getElementById("j-outline-toolbar") ?.classList.toggle("j-hidden", false); doc .getElementById("j-bookmark-toolbar") ?.classList.toggle("j-hidden", true); button.classList.toggle("active", true); doc .getElementById("j-bookmark-button") ?.classList.toggle("active", false); } else if (button.id === "j-bookmark-button") { ztoolkit.log("jasminum show bookmark"); reader.setSidebarView("jasminum-bookmarks"); doc.getElementById("jasminum-bookmarks")?.classList.remove("hidden"); doc.getElementById("jasminum-outline")?.classList.add("hidden"); doc .getElementById("j-bookmark-toolbar") ?.classList.toggle("j-hidden", false); doc .getElementById("j-outline-toolbar") ?.classList.toggle("j-hidden", true); button.classList.toggle("active", true); doc.getElementById("j-outline-button")?.classList.toggle("active", false); } else { // Hide both outline and bookmark views ztoolkit.log("hide jasminum views"); doc.getElementById("jasminum-outline")?.classList.toggle("hidden", true); doc .getElementById("jasminum-bookmarks") ?.classList.toggle("hidden", true); doc .getElementById("j-outline-toolbar") ?.classList.toggle("j-hidden", true); doc .getElementById("j-bookmark-toolbar") ?.classList.toggle("j-hidden", true); doc.getElementById("j-outline-button")?.classList.toggle("active", false); doc .getElementById("j-bookmark-button") ?.classList.toggle("active", false); } } // 给默认按钮添加事件,避免切换面板时异常 doc .querySelector("#sidebarContainer > div.sidebar-toolbar > div.start") ?.addEventListener("click", hideShowMyOutlineAndBar); const treeContainer = doc.getElementById("j-outline-viewer"); if (!treeContainer) return; // 节点展开/折叠事件,选中节点 treeContainer // 节点点击选择事件 .addEventListener("click", async (e: Event) => { const target = e.target as HTMLElement; ztoolkit.log("click container", e.target); // 检查是否点击的是展开/折叠图标 const spanElement = target.closest("span"); if (spanElement && spanElement.classList.contains("expander")) { ztoolkit.log("click expander"); const listItem = target.closest("li"); if (!listItem) return; toggleNode(listItem); e.stopPropagation(); await saveOutlineToJSON(); return; } // 节点选择 if (target.closest(".tree-node")) { selectNode(target.closest(".tree-node")!); clickToPosition(target); } }); // 双击编辑节点 treeContainer.addEventListener("dblclick", function (e) { if ((e.target as Element).classList.contains("node-title")) { makeNodeEditable(e.target as Element); e.stopPropagation(); } }); // 书签上方工具栏事件 doc .getElementById("j-outline-expand-all") ?.addEventListener("click", expandAll); doc .getElementById("j-outline-collapse-all") ?.addEventListener("click", collapseAll); doc .getElementById("j-outline-add-node") ?.addEventListener("click", addNewNode); doc .getElementById("j-outline-delete-node") ?.addEventListener("click", deleteSelectedNode); doc .getElementById("j-outline-save-pdf") ?.addEventListener("click", async (ev: Event) => { const button = ev.currentTarget as HTMLButtonElement; button.disabled = true; await addOutlineToPDFRunner(); button.disabled = false; }); // 拖拽相关事件 treeContainer.addEventListener("dragstart", handleDragStart); treeContainer.addEventListener("dragover", handleDragOver); treeContainer.addEventListener("dragleave", handleDragLeave); treeContainer.addEventListener("drop", handleDrop); treeContainer.addEventListener("dragend", handleDragEnd); // 处理键盘事件 treeContainer.addEventListener("keydown", handleKeydownEvent); // 点击书签跳转到具体页码 // 书签相关事件处理 const bookmarkContainer = doc.getElementById("j-bookmark-viewer"); if (bookmarkContainer) { // 书签点击选择和跳转事件 bookmarkContainer.addEventListener("click", async (e: Event) => { const target = e.target as HTMLElement; ztoolkit.log("click bookmark container", e.target); // 书签选择和跳转 if (target.closest(".bookmark-node")) { selectBookmarkNode(target.closest(".bookmark-node")!); clickToBookmarkPosition(target); } }); // 双击编辑书签 bookmarkContainer.addEventListener("dblclick", function (e) { if ((e.target as Element).classList.contains("bookmark-title")) { makeBookmarkNodeEditable(e.target as Element); e.stopPropagation(); } }); // 书签拖拽相关事件 bookmarkContainer.addEventListener("dragstart", handleBookmarkDragStart); bookmarkContainer.addEventListener("dragover", handleBookmarkDragOver); bookmarkContainer.addEventListener("dragleave", handleBookmarkDragLeave); bookmarkContainer.addEventListener("drop", handleBookmarkDrop); bookmarkContainer.addEventListener("dragend", handleBookmarkDragEnd); } // 书签工具栏事件 doc .getElementById("j-bookmark-add") ?.addEventListener("click", addNewBookmarkNode); doc .getElementById("j-bookmark-delete") ?.addEventListener("click", deleteSelectedBookmarkNode); // 字体大小调整按钮事件 doc .getElementById("j-outline-zoom-in") ?.addEventListener("click", handleFontSizeIncrease); doc .getElementById("j-outline-zoom-out") ?.addEventListener("click", handleFontSizeDecrease); } // 为节点添加事件监听,以下为事件处理函数 async function expandAll(ev: Event) { const doc = (ev.target as Element).ownerDocument; const collapsedNodes = doc.querySelectorAll(".tree-item.collapsed"); collapsedNodes.forEach((node) => { node.classList.remove("collapsed"); const expander = node.querySelector(".expander"); if (expander?.hasChildNodes()) { //expander!.textContent = "▼"; expander!.innerHTML = ICONS.down; } }); await saveOutlineToJSON(); } async function collapseAll(ev: Event) { const doc = (ev.target as Element).ownerDocument; const parentNodes = doc.querySelectorAll(".tree-item.has-children"); parentNodes.forEach((node) => { node.classList.add("collapsed"); const expander = node.querySelector(".expander"); if (expander?.hasChildNodes()) { //expander!.textContent = "►"; expander!.innerHTML = ICONS.right; } }); await saveOutlineToJSON(); } // 切换节点展开/折叠状态 function toggleNode(node: Element) { if (node.classList.contains("has-children")) { node.classList.toggle("collapsed"); // 更新展开/折叠图标 const expander = node.querySelector(".expander"); if (node.classList.contains("collapsed")) { //expander!.textContent = "►"; expander!.innerHTML = ICONS.right; } else { //expander!.textContent = "▼"; expander!.innerHTML = ICONS.down; } } } // 选择节点 function selectNode(node: Element) { const doc = node.ownerDocument; const selectedNode = doc.querySelector(".node-selected"); // 取消之前的选择 if (selectedNode) { selectedNode.classList.remove("node-selected"); } // 设置新选择 node.classList.add("node-selected"); } // Key events for the outline panel. export async function handleKeydownEvent(ev: KeyboardEvent) { const newPanel = (ev.target! as Element).ownerDocument.getElementById( "root-list", )!; const nodes = Array.from(newPanel.querySelectorAll("div.tree-node")); const selectedNode = newPanel.querySelector("div.tree-node.node-selected"); let currentIdx = nodes.indexOf(selectedNode as Element); // ztoolkit.log("Keydown event", currentIdx, ev); if (ev.key === "ArrowDown") { while (currentIdx < nodes.length - 1) { const nextNode = nodes[currentIdx + 1] as HTMLElement; // ztoolkit.log("Next node", currentIdx, nextNode); if (nextNode && nextNode.checkVisibility()) { nextNode.querySelector("span.node-title")!.click(); nextNode.focus(); break; } currentIdx += 1; } } if (ev.key === "ArrowUp") { while (currentIdx > 0) { const nextNode = nodes[currentIdx - 1] as HTMLElement; if (nextNode && nextNode.checkVisibility()) { nextNode.querySelector("span.node-title")!.click(); nextNode.focus(); break; } currentIdx -= 1; } } if (ev.key === "ArrowLeft" || ev.key === "ArrowRight") { (selectedNode?.querySelector("span.expander") as HTMLElement).click(); } if (ev.key === " ") { // ztoolkit.log("Space key pressed", selectedNode); ev.preventDefault(); makeNodeEditable( selectedNode!.querySelector("span.node-title")!, ); } if (ev.key === "Delete" || ev.key === "Backspace") { // ztoolkit.log("Delete key pressed"); deleteSelectedNode(ev); } // Level up if (ev.key === "[") { // ztoolkit.log("[ key pressed"); const targetNode = (ev.target as Element).querySelector( ".node-selected", )!; const targetLi = targetNode.closest("li")!; const oldParentUl = targetLi.parentElement!; const oldGrandParent = oldParentUl.parentElement!; // 如果是根节点,直接返回 if (oldParentUl.id === "root-list") return; oldParentUl.removeChild(targetLi); // 此时原来的父节点已经没有子节点了,删除 if (oldParentUl.children.length === 0) { oldGrandParent.removeChild(oldParentUl); oldGrandParent.classList.remove("has-children"); const expander = oldGrandParent.querySelector(".expander")!; expander.textContent = " "; } oldGrandParent.parentElement!.insertBefore( targetLi, oldGrandParent.nextSibling, ); updateNodeLevels(targetLi); await saveOutlineToJSON(); } // Level down if (ev.key === "]") { // ztoolkit.log("] key pressed"); const targetNode = (ev.target as Element).querySelector( ".node-selected", )!; const targetLi = targetNode.closest("li")!; const parentLi = targetLi.previousElementSibling; if (!parentLi) return; let parentUl = parentLi.querySelector("ul"); // 如果没有子列表,创建一个 if (!parentUl) { parentUl = targetNode.ownerDocument.createElement("ul"); parentUl.classList.add("tree-list"); parentLi.appendChild(parentUl); // 更新父节点状态 parentLi.classList.add("has-children"); const expander = parentLi.querySelector(".expander")!; // expander.textContent = "▼"; expander.innerHTML = ICONS.down; } // 添加到子列表 parentUl.appendChild(targetLi); // 确保目标节点展开 targetLi.classList.remove("collapsed"); updateNodeLevels(targetLi); await saveOutlineToJSON(); } // Add new node if (ev.key === "\\") { // ztoolkit.log("\\ key pressed"); addNewNode(ev); } } export function handleDragStart(e: DragEvent) { // if (!(e.target instanceof HTMLElement)) return; ztoolkit.log(" start to drag"); const target = e.target as Element; if (!target.classList.contains("tree-node")) return; const draggedNode = target.closest("li") as HTMLElement; e.dataTransfer!.setData("text/plain", draggedNode.innerText); e.dataTransfer!.effectAllowed = "move"; // 为拖拽中的元素添加样式 setTimeout(() => { draggedNode.classList.add("dragging"); }, 0); } // 拖拽经过目标元素 export function handleDragOver(e: DragEvent) { e.preventDefault(); e.dataTransfer!.dropEffect = "move"; const target = e.target as HTMLElement; const doc = target.ownerDocument; // 修复坐标异常 const upperHeight = doc.querySelector("html")?.getBoundingClientRect().height || 41; const draggedNode = doc.querySelector(".dragging"); if (!draggedNode) return; // if (!(e.target instanceof HTMLElement)) return; // 找到最近的节点元素 const targetNode = target.closest(".tree-node"); if (!targetNode) { hideDropIndicator(doc); return; } // 不能拖拽到自己或自己的子元素 const targetLi = targetNode.closest("li") as Element; if (draggedNode === targetLi || isAncestor(draggedNode, targetLi)) { hideDropIndicator(doc); return; } // 计算拖拽位置(上方、中间放入其中、下方) const rect = targetNode.getBoundingClientRect(); const mouseY = e.clientY; const relativeY = mouseY - rect.top; const height = rect.height; let dropPosition; if (relativeY < height * 0.25) { dropPosition = "before"; } else if (relativeY > height * 0.75) { dropPosition = "after"; } else { dropPosition = "inside"; } // 如果位置或目标变化了,才更新指示器 // 临时数据暂时存储在window中 if ( doc.defaultView!.lastDropPosition !== dropPosition || doc.defaultView!.lastDropTarget !== targetLi ) { updateDropIndicator(targetNode, dropPosition, upperHeight); doc.defaultView!.lastDropPosition = dropPosition; doc.defaultView!.lastDropTarget = targetLi; } // 添加可放置样式 doc.querySelectorAll(".dragover").forEach((el) => { el.classList.remove("dragover"); }); targetNode.classList.add("dragover"); } function updateDropIndicator( targetNode: Element, position: string, upperHeight: number, ) { const rect = targetNode.getBoundingClientRect(); const doc = targetNode.ownerDocument; const dropIndicator = doc.querySelector(".drop-indicator") as HTMLElement; // 清除所有位置类 dropIndicator.classList.remove("top", "middle", "bottom"); dropIndicator.classList.add("visible"); if (position === "before") { dropIndicator.classList.add("top"); dropIndicator.style.left = `${rect.left}px`; dropIndicator.style.top = `${rect.top - 2 - upperHeight}px`; dropIndicator.style.width = `${rect.width}px`; } else if (position === "after") { dropIndicator.classList.add("bottom"); dropIndicator.style.left = `${rect.left}px`; dropIndicator.style.top = `${rect.bottom - upperHeight}px`; dropIndicator.style.width = `${rect.width}px`; } else { // inside position dropIndicator.classList.add("middle"); dropIndicator.style.left = `${rect.left + 20}px`; dropIndicator.style.top = `${rect.top + rect.height / 2 - upperHeight}px`; dropIndicator.style.width = `${rect.width - 25}px`; } } function hideDropIndicator(doc: Document) { const dropIndicator = doc.querySelector(".drop-indicator")!; dropIndicator.classList.remove("visible"); doc.defaultView!.lastDropPosition = null; doc.defaultView!.lastDropTarget = null; } // 拖拽离开目标元素 export function handleDragLeave(e: DragEvent) { const doc = (e.target as Element).ownerDocument; if ( !e.relatedTarget || !(e.relatedTarget as Element).closest("#j-outline-viewer") ) { hideDropIndicator(doc); } const targetNode = (e.target as HTMLElement).closest(".tree-node"); if (targetNode) { // 移除可放置样式 targetNode.classList.remove("dragover"); } } // 处理放置 export async function handleDrop(e: DragEvent) { e.preventDefault(); // if (!(e.target instanceof HTMLElement)) return; const target = e.target as HTMLElement; const doc = target.ownerDocument; const draggedNode = doc.querySelector(".dragging"); // 隐藏指示器 hideDropIndicator(doc); if (!draggedNode) return; // 获取目标节点 const targetTreeNode = target.closest(".tree-node"); if (!targetTreeNode) return; // 移除可放置样式 doc.querySelectorAll(".dragover").forEach((el) => { el.classList.remove("dragover"); }); // 获取目标列表项 const targetLi = targetTreeNode.closest("li")!; // 不能将节点拖到自己或其子节点上 if (draggedNode === targetLi || isAncestor(draggedNode, targetLi)) { return; } // 移除拖拽的节点 const oldParent = draggedNode.parentNode! as HTMLElement; oldParent.removeChild(draggedNode); // 判断放置位置:是作为子节点还是兄弟节点 const dropPosition = determineDropPosition(e, targetTreeNode); if (dropPosition === "child") { // 作为子节点 let targetUl = targetLi.querySelector("ul"); // 如果没有子列表,创建一个 if (!targetUl) { targetUl = doc.createElement("ul"); targetUl.classList.add("tree-list"); targetLi.appendChild(targetUl); // 更新父节点状态 targetLi.classList.add("has-children"); const expander = targetLi.querySelector(".expander")!; // expander.textContent = "▼"; expander.innerHTML = ICONS.down; } // 确保目标节点展开 targetLi.classList.remove("collapsed"); // 添加到子列表 targetUl.appendChild(draggedNode); } else { // 作为兄弟节点 const targetParent = targetLi.parentNode!; if (dropPosition === "before") { targetParent.insertBefore(draggedNode, targetLi); } else { // 'after' targetParent.insertBefore(draggedNode, targetLi.nextSibling); } } // 如果原父列表为空,更新其父节点状态 if ( oldParent.children.length === 0 && oldParent.tagName === "UL" && oldParent !== doc.getElementById("root-list") ) { const oldGrandParent = oldParent.parentNode as HTMLElement; oldGrandParent.removeChild(oldParent); oldGrandParent.classList.remove("has-children"); const expander = oldGrandParent.querySelector(".expander")!; expander.textContent = " "; } // 更新节点级别样式 updateNodeLevels(draggedNode); // 保存节点信息 await saveOutlineToJSON(); } // 拖拽结束 export function handleDragEnd(e: DragEvent) { // if (!(e.target instanceof HTMLElement)) return; const doc = (e.target as HTMLElement).ownerDocument; const draggedNode = doc.querySelector(".dragging"); if (!draggedNode) return; draggedNode.classList.remove("dragging"); // 隐藏指示器 hideDropIndicator(doc); // 清除所有dragover样式 doc.querySelectorAll(".dragover").forEach((el) => { el.classList.remove("dragover"); }); } // 检查一个节点是否是另一个节点的祖先 function isAncestor(ancestor: Element, descendant: Element) { let current = descendant.parentNode; while (current) { if (current === ancestor) { return true; } current = current.parentNode; } return false; } // 确定放置位置:作为子节点、同级前面或同级后面 function determineDropPosition(event: DragEvent, targetNode: Element) { const rect = targetNode.getBoundingClientRect(); const mouseY = event.clientY; // 上三分之一区域放在前面,下三分之一区域放在后面,中间放在内部 const relativeY = mouseY - rect.top; const height = rect.height; if (relativeY < height / 3) { return "before"; } else if (relativeY > (height * 2) / 3) { return "after"; } else { return "child"; } } // 更新节点及其子节点的级别样式 function updateNodeLevels(node: Element) { const updateLevel = (element: Element, level: number) => { const nodeDiv = element.querySelector(".tree-node")!; // 移除所有级别类 for (let i = 1; i <= MAX_LEVEL; i++) { nodeDiv.classList.remove(`level-${i}`); } // 添加正确的级别类 nodeDiv.classList.add(`level-${level}`); nodeDiv.setAttribute("level", level.toString()); // 递归处理子节点 const childList = element.querySelector("ul"); if (childList) { Array.from(childList.children).forEach((child) => { updateLevel(child, level + 1); }); } }; // 计算当前节点的级别 let level = 1; let parent = node.parentNode as Element; while (parent && parent.id !== "root-list") { if (parent.tagName === "UL") { level++; } parent = parent.parentNode as Element; } updateLevel(node, level); } export function makeNodeEditable(titleElement: Element) { const doc = titleElement.ownerDocument; const parent = titleElement.parentNode! as Element; const treeNode = titleElement.closest("div.tree-node")!; // 获取当前值 const currentTitle = titleElement.textContent || ""; const currentPage = treeNode.getAttribute("page")!; // 创建容器 const container = doc.createElement("div"); container.style.display = "flex"; container.style.gap = "5px"; // 创建标题输入框 const titleInput = doc.createElement("input"); titleInput.type = "text"; titleInput.value = currentTitle.trim(); titleInput.placeholder = getString("outline-edit-placeholder"); // 替换原始元素 container.appendChild(titleInput); // container.appendChild(pageInput); parent.replaceChild(container, titleElement); // 聚焦到标题输入框 titleInput.focus(); // 禁用拖拽功能 treeNode.setAttribute("draggable", "false"); // 保存逻辑 const saveChanges = async () => { const newTitle = titleInput.value.trim(); // 更新原始元素 titleElement.textContent = newTitle || currentTitle; titleElement.setAttribute("title", `${newTitle}, Page: ${currentPage}`); treeNode.setAttribute("page", currentPage); // 恢复 DOM 结构 parent.replaceChild(titleElement, container); // 恢复拖拽功能 treeNode.setAttribute("draggable", "true"); // 保存节点信息 await saveOutlineToJSON(); }; // 事件处理 const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter") { saveChanges(); doc.getElementById("j-outline-viewer")!.focus(); } else if (e.key === "Escape") { parent.replaceChild(titleElement, container); } e.stopPropagation(); // 保留焦点 }; const handleBlur = (e: FocusEvent) => { if (!container.contains(e.relatedTarget as Node)) { saveChanges(); } }; // 绑定事件 titleInput.addEventListener("keydown", handleKeyDown); container.addEventListener("blur", handleBlur, true); } // 删除选中节点 export async function deleteSelectedNode(ev: Event) { const doc = (ev.target as Element).ownerDocument; const selectedNode = doc.querySelector(".node-selected")!; const rootNode = doc.getElementById("root-list"); if (!selectedNode || !rootNode) return; const listItem = selectedNode.closest("li")!; const beforeSelectedLi = listItem.previousElementSibling; const parent = listItem.parentNode as HTMLElement; // 如果有子节点,则进行提示确认是否删除 if (listItem.classList.contains("has-children")) { const confirmDelete = ztoolkit.getGlobal("confirm")( getString("outline-delete-confirm"), ); if (!confirmDelete) return; } // 移除节点 parent.removeChild(listItem); // 如果父列表没有其他子元素,更新其父节点的状态 if ( parent.children.length === 0 && parent.tagName === "UL" && parent !== doc.getElementById("root-list") ) { const parentLi = parent.parentNode as HTMLElement; parentLi.removeChild(parent); parentLi.classList.remove("has-children"); const expander = parentLi.querySelector(".expander")!; expander.textContent = " "; } // 保存节点信息 await saveOutlineToJSON(); if (!rootNode.hasChildNodes()) { ztoolkit.UI.appendElement( { tag: "div", namespace: "html", classList: ["empty-outline-prompt"], properties: { innerHTML: getString("outline-empty-prompt", { args: { icon: ICONS.add }, }), }, }, rootNode, ); } if (beforeSelectedLi) { beforeSelectedLi .querySelector("div.tree-node") ?.classList.add("node-selected"); } else { parent.parentNode ?.querySelector("div.tree-node") ?.classList.add("node-selected"); } doc.getElementById("j-outline-viewer")?.focus(); } // 添加新节点。选中节点的子节点还是下一个同级节点 // 默认设置为添加节点的同级节点 export async function addNewNode(ev: Event) { const doc = (ev.target as Element).ownerDocument; const newTitle = "新书签"; const selectedNode = doc.querySelector(".node-selected"); const location = getReaderPagePosition(); // 如果没有选中节点,添加到根 if (!selectedNode) { const rootList = doc.getElementById("root-list")!; createTreeNodes( [ { level: 1, title: newTitle, page: location.position.pageIndex + 1, x: location.position.rects[0][0], y: location.position.rects[0][1], }, ], rootList, doc, ); doc.querySelector(".empty-outline-prompt")?.classList.add("hidden"); } else { // 添加为选中节点的子节点或兄弟节点 let targetChildrenList: HTMLElement; let targetLevel: number; const selectedLevel = parseInt(selectedNode.getAttribute("level") || "1"); if (getPref("newNodeAsChild")) { // 作为子节点 const selectedLi = selectedNode.closest("li.tree-item")!; targetLevel = selectedLevel + 1; // 检查是否有子列表,如果没有,创建一个 targetChildrenList = selectedLi.querySelector("ul")!; if (!targetChildrenList) { targetChildrenList = ztoolkit.UI.createElement(doc, "ul", { classList: ["tree-list"], }); selectedLi.appendChild(targetChildrenList); // 添加父节点标记并更新展开图标 selectedLi.classList.add("has-children"); const expander = selectedLi.querySelector(".expander")!; //expander.textContent = "▼"; expander.innerHTML = ICONS.down; } // 确保父节点展开 selectedLi.classList.remove("collapsed"); } else { targetLevel = selectedLevel; targetChildrenList = selectedNode.closest("ul.tree-list") as HTMLElement; } createTreeNodes( [ { level: targetLevel, title: newTitle, page: location.position.pageIndex + 1, x: location.position.rects[0][0], y: location.position.rects[0][1], }, ], targetChildrenList, doc, ); } // 保存节点信息 await saveOutlineToJSON(); } function clickToPosition(targetElement: Element) { const reader = Zotero.Reader.getByTabID( ztoolkit.getGlobal("Zotero_Tabs").selectedID, ); const treeNode = targetElement.closest("div.tree-node"); if (!treeNode) return; const page = parseInt(treeNode.getAttribute("page")!); const x = parseInt(treeNode.getAttribute("x")!); const y = parseInt(treeNode.getAttribute("y")!); ztoolkit.log("Click to position", page, x, y); // const location = { // position: { pageIndex: page - 1, rects: [[x, y, x, y]] }, // }; // @ts-ignore - not typed // reader.navigate(location); const PDFViewerApplication = ( reader._internalReader._primaryView as _ZoteroTypes.Reader.PDFView )._iframeWindow.PDFViewerApplication; const pageView = PDFViewerApplication.pdfViewer!.getPageView(page - 1); // @ts-ignore - Not typed const [scrollX, scrollY] = pageView.viewport.convertToViewportPoint(x, y); ( reader._internalReader._primaryView as _ZoteroTypes.Reader.PDFView )._iframeWindow!.PDFViewerApplication.page = page; const container = ( reader._internalReader._primaryView as _ZoteroTypes.Reader.PDFView )._iframeWindow!.document.getElementById("viewerContainer")!; ztoolkit.log(`Scroll to ${scrollX}, ${scrollY}`); container.scrollBy(scrollX, scrollY); } // Use worker to add outline to PDF export async function addOutlineToPDFRunner(): Promise { const reader = Zotero.Reader.getByTabID( ztoolkit.getGlobal("Zotero_Tabs").selectedID, ); if (!reader) { ztoolkit.log("No reader found"); return; } const outlineNodes = await getOutlineFromPDF(reader); if (!outlineNodes) { ztoolkit.log("No outline nodes found"); return; } const filePath = reader._item.getFilePath(); const worker = new Worker( "chrome://jasminum/content/scripts/jasminum-worker.js", ); worker.onmessage = (event) => { // @ts-ignore - event.data is not typed const data = event.data; ztoolkit.log("data", data); if (data && data.action === "addOutlineReturn") { ztoolkit.log("Add outline to PDF return", data); } }; return new Promise((resolve, reject) => { ztoolkit.log(filePath, outlineNodes); const jobID = Zotero.Utilities.randomString(); // 消息处理器 const handler = (event: MessageEvent) => { const data = event.data; ztoolkit.log("Main handler", data); // 仅处理匹配 jobID 和 action 的消息 if (data?.action !== "addOutlineReturn" || data?.jobID !== jobID) return; worker.removeEventListener("message", handler as EventListener); if (data.status === "success") { resolve(data); } else { reject(new Error(data.error || "Unknown error")); } }; worker.addEventListener("message", handler as EventListener); worker.postMessage({ action: "addOutline", jobID, filePath, outlineNodes }); }); } // ========== 书签相关函数 ========== // 选择书签节点 function selectBookmarkNode(node: Element) { const doc = node.ownerDocument; const selectedNode = doc.querySelector(".bookmark-selected"); // 取消之前的选择 if (selectedNode) { selectedNode.classList.remove("bookmark-selected"); } // 设置新选择 node.classList.add("bookmark-selected"); } // 点击书签跳转到对应位置 function clickToBookmarkPosition(targetElement: Element) { const reader = Zotero.Reader.getByTabID( ztoolkit.getGlobal("Zotero_Tabs").selectedID, ); const bookmarkNode = targetElement.closest("div.bookmark-node"); if (!bookmarkNode) return; const page = parseInt(bookmarkNode.getAttribute("page")!); const x = parseInt(bookmarkNode.getAttribute("x")!); const y = parseInt(bookmarkNode.getAttribute("y")!); ztoolkit.log("Click to bookmark position", page, x, y); const PDFViewerApplication = ( reader._internalReader._primaryView as _ZoteroTypes.Reader.PDFView )._iframeWindow!.PDFViewerApplication; const pageView = PDFViewerApplication.pdfViewer!.getPageView(page - 1); // @ts-ignore - Not typed const [scrollX, scrollY] = pageView.viewport.convertToViewportPoint(x, y); ( reader._internalReader._primaryView as _ZoteroTypes.Reader.PDFView )._iframeWindow!.PDFViewerApplication.page = page; const container = ( reader._internalReader._primaryView as _ZoteroTypes.Reader.PDFView )._iframeWindow!.document.getElementById("viewerContainer")!; ztoolkit.log(`Scroll to bookmark ${scrollX}, ${scrollY}`); container.scrollBy(scrollX, scrollY); } // 编辑书签节点 export function makeBookmarkNodeEditable(titleElement: Element) { const doc = titleElement.ownerDocument; const parent = titleElement.parentNode! as Element; const bookmarkNode = titleElement.closest("div.bookmark-node")!; // 获取当前值 const currentTitle = titleElement.textContent || ""; const currentPage = bookmarkNode.getAttribute("page")!; const currentColor = bookmarkNode.getAttribute("data-color") || DEFAULT_BOOKMARK_COLORS[0]; // 创建编辑容器 const editContainer = doc.createElement("div"); editContainer.className = "bookmark-edit-container"; // 创建标题输入框 const titleInput = doc.createElement("input"); titleInput.type = "text"; titleInput.value = currentTitle.trim(); titleInput.placeholder = "书签标题"; // 创建颜色选择器容器 const colorContainer = doc.createElement("div"); colorContainer.className = "bookmark-color-picker"; let selectedColor = currentColor; // 创建颜色选项 DEFAULT_BOOKMARK_COLORS.forEach((color) => { const colorOption = doc.createElement("div"); colorOption.className = "bookmark-color-option"; if (color === currentColor) { colorOption.classList.add("selected"); } colorOption.style.backgroundColor = color; colorOption.addEventListener("click", () => { // 更新选中状态 colorContainer.querySelectorAll("div").forEach((opt) => { opt.classList.remove("selected"); }); colorOption.classList.add("selected"); selectedColor = color; // 实时更新书签的颜色显示 (bookmarkNode as HTMLElement).style.borderLeftColor = color; bookmarkNode.setAttribute("data-color", color); }); colorContainer.appendChild(colorOption); }); // 创建分隔线 const separator = doc.createElement("div"); separator.className = "bookmark-edit-separator"; editContainer.appendChild(titleInput); editContainer.appendChild(separator); editContainer.appendChild(colorContainer); // 替换原始元素 parent.replaceChild(editContainer, titleElement); // 聚焦到输入框 titleInput.focus(); // 禁用拖拽功能 bookmarkNode.setAttribute("draggable", "false"); // 保存逻辑 const saveChanges = async () => { const newTitle = titleInput.value.trim(); // 更新原始元素 titleElement.textContent = newTitle || currentTitle; titleElement.setAttribute("title", `${newTitle}, Page: ${currentPage}`); // 更新颜色 bookmarkNode.setAttribute("data-color", selectedColor); (bookmarkNode as HTMLElement).style.borderLeftColor = selectedColor; // 恢复 DOM 结构 parent.replaceChild(titleElement, editContainer); // 恢复拖拽功能 bookmarkNode.setAttribute("draggable", "true"); // 保存书签信息 await saveBookmarksToJSON(); }; // 事件处理 const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter") { saveChanges(); doc.getElementById("j-bookmark-viewer")!.focus(); } else if (e.key === "Escape") { parent.replaceChild(titleElement, editContainer); bookmarkNode.setAttribute("draggable", "true"); } e.stopPropagation(); }; const handleBlur = (e: FocusEvent) => { if (!editContainer.contains(e.relatedTarget as Node)) { saveChanges(); } }; // 绑定事件 titleInput.addEventListener("keydown", handleKeyDown); editContainer.addEventListener("blur", handleBlur, true); } // 添加新书签 export async function addNewBookmarkNode(ev: Event) { const doc = (ev.target as Element).ownerDocument; const newBookmark = addNewBookmark(); const rootList = doc.getElementById("bookmark-root-list")!; // 清除空提示 doc.querySelector(".empty-bookmark-prompt")?.remove(); createBookmarkNodes([newBookmark], rootList, doc); // 保存书签信息 await saveBookmarksToJSON(); } // 删除选中的书签 export async function deleteSelectedBookmarkNode(ev: Event) { const doc = (ev.target as Element).ownerDocument; const selectedNode = doc.querySelector(".bookmark-selected")!; const rootNode = doc.getElementById("bookmark-root-list"); if (!selectedNode || !rootNode) return; const listItem = selectedNode.closest("li")!; const parent = listItem.parentNode as HTMLElement; // 移除节点 parent.removeChild(listItem); // 保存书签信息 await saveBookmarksToJSON(); // 如果没有书签了,显示提示 if (!rootNode.hasChildNodes()) { ztoolkit.UI.appendElement( { tag: "div", namespace: "html", classList: ["empty-bookmark-prompt"], properties: { innerHTML: `请点击上方按钮${ICONS.add}创建书签` }, }, rootNode, ); } doc.getElementById("j-bookmark-viewer")?.focus(); } // 书签拖拽开始 export function handleBookmarkDragStart(e: DragEvent) { ztoolkit.log("start to drag bookmark"); const target = e.target as Element; if (!target.classList.contains("bookmark-node")) return; const draggedNode = target.closest("li") as HTMLElement; e.dataTransfer!.setData("text/plain", draggedNode.innerText); e.dataTransfer!.effectAllowed = "move"; // 为拖拽中的元素添加样式 setTimeout(() => { draggedNode.classList.add("dragging"); }, 0); } // 书签拖拽经过目标元素 export function handleBookmarkDragOver(e: DragEvent) { e.preventDefault(); e.dataTransfer!.dropEffect = "move"; const target = e.target as HTMLElement; const doc = target.ownerDocument; const draggedNode = doc.querySelector(".dragging"); if (!draggedNode) return; // 找到最近的书签节点元素 const targetNode = target.closest(".bookmark-node"); if (!targetNode) { hideBookmarkDropIndicator(doc); return; } // 不能拖拽到自己 const targetLi = targetNode.closest("li") as Element; if (draggedNode === targetLi) { hideBookmarkDropIndicator(doc); return; } // 计算拖拽位置(上方或下方) const rect = targetNode.getBoundingClientRect(); const mouseY = e.clientY; const relativeY = mouseY - rect.top; const height = rect.height; let dropPosition; if (relativeY < height * 0.5) { dropPosition = "before"; } else { dropPosition = "after"; } // 更新指示器 updateBookmarkDropIndicator(targetNode, dropPosition); // 添加可放置样式 doc.querySelectorAll(".bookmark-dragover").forEach((el) => { el.classList.remove("bookmark-dragover"); }); targetNode.classList.add("bookmark-dragover"); } // 更新书签拖拽指示器 function updateBookmarkDropIndicator(targetNode: Element, position: string) { const rect = targetNode.getBoundingClientRect(); const doc = targetNode.ownerDocument; const dropIndicator = doc.querySelector( ".bookmark-drop-indicator", ) as HTMLElement; dropIndicator.classList.add("visible"); if (position === "before") { dropIndicator.style.left = `${rect.left}px`; dropIndicator.style.top = `${rect.top - 2}px`; dropIndicator.style.width = `${rect.width}px`; } else { // after position dropIndicator.style.left = `${rect.left}px`; dropIndicator.style.top = `${rect.bottom}px`; dropIndicator.style.width = `${rect.width}px`; } } // 隐藏书签拖拽指示器 function hideBookmarkDropIndicator(doc: Document) { const dropIndicator = doc.querySelector(".bookmark-drop-indicator")!; dropIndicator.classList.remove("visible"); } // 书签拖拽离开目标元素 export function handleBookmarkDragLeave(e: DragEvent) { const doc = (e.target as Element).ownerDocument; if ( !e.relatedTarget || !(e.relatedTarget as Element).closest("#j-bookmark-viewer") ) { hideBookmarkDropIndicator(doc); } const targetNode = (e.target as HTMLElement).closest(".bookmark-node"); if (targetNode) { targetNode.classList.remove("bookmark-dragover"); } } // 处理书签放置 export async function handleBookmarkDrop(e: DragEvent) { e.preventDefault(); const target = e.target as HTMLElement; const doc = target.ownerDocument; const draggedNode = doc.querySelector(".dragging"); // 隐藏指示器 hideBookmarkDropIndicator(doc); if (!draggedNode) return; // 获取目标节点 const targetBookmarkNode = target.closest(".bookmark-node"); if (!targetBookmarkNode) return; // 移除可放置样式 doc.querySelectorAll(".bookmark-dragover").forEach((el) => { el.classList.remove("bookmark-dragover"); }); // 获取目标列表项 const targetLi = targetBookmarkNode.closest("li")!; // 不能将节点拖到自己上 if (draggedNode === targetLi) { return; } // 移除拖拽的节点 const oldParent = draggedNode.parentNode! as HTMLElement; oldParent.removeChild(draggedNode); // 判断放置位置 const rect = targetBookmarkNode.getBoundingClientRect(); const mouseY = e.clientY; const relativeY = mouseY - rect.top; const height = rect.height; const targetParent = targetLi.parentNode!; if (relativeY < height * 0.5) { // 放在前面 targetParent.insertBefore(draggedNode, targetLi); } else { // 放在后面 targetParent.insertBefore(draggedNode, targetLi.nextSibling); } // 保存书签信息 await saveBookmarksToJSON(); } // 书签拖拽结束 export function handleBookmarkDragEnd(e: DragEvent) { const doc = (e.target as HTMLElement).ownerDocument; const draggedNode = doc.querySelector(".dragging"); if (!draggedNode) return; draggedNode.classList.remove("dragging"); // 隐藏指示器 hideBookmarkDropIndicator(doc); // 清除所有dragover样式 doc.querySelectorAll(".bookmark-dragover").forEach((el) => { el.classList.remove("bookmark-dragover"); }); } // ========== 字体大小调整函数 ========== const MIN_FONT_SIZE = 8; const MAX_FONT_SIZE = 20; // Increase font size for both outline and bookmark async function handleFontSizeIncrease(ev: Event) { const doc = (ev.target as Element).ownerDocument; const reader = Zotero.Reader.getByTabID( ztoolkit.getGlobal("Zotero_Tabs").selectedID, ); if (!reader) return; // Get current baseFontSize for outline const outlineInfo = await loadOutlineInfoFromJSON(reader._item); const currentOutlineSize = outlineInfo?.baseFontSize ?? DEFAULT_BASE_FONT_SIZE; // Get current baseFontSize for bookmark const bookmarkInfo = await loadBookmarkInfoFromJSON(reader._item); const currentBookmarkSize = bookmarkInfo?.baseFontSize ?? DEFAULT_BOOKMARK_FONT_SIZE; // Increase by 1, max 20 const newOutlineSize = Math.min(currentOutlineSize + 1, MAX_FONT_SIZE); const newBookmarkSize = Math.min(currentBookmarkSize + 1, MAX_FONT_SIZE); if ( newOutlineSize !== currentOutlineSize || newBookmarkSize !== currentBookmarkSize ) { // Update CSS updateOutlineFontSize(doc, newOutlineSize); updateBookmarkFontSize(doc, newBookmarkSize); // Save to JSON await saveOutlineToJSON(reader._item, undefined, newOutlineSize); await saveBookmarksToJSON(reader._item, undefined, newBookmarkSize); ztoolkit.log( `Font size increased: outline=${newOutlineSize}px, bookmark=${newBookmarkSize}px`, ); } } // Decrease font size for both outline and bookmark async function handleFontSizeDecrease(ev: Event) { const doc = (ev.target as Element).ownerDocument; const reader = Zotero.Reader.getByTabID( ztoolkit.getGlobal("Zotero_Tabs").selectedID, ); if (!reader) return; // Get current baseFontSize for outline const outlineInfo = await loadOutlineInfoFromJSON(reader._item); const currentOutlineSize = outlineInfo?.baseFontSize ?? DEFAULT_BASE_FONT_SIZE; // Get current baseFontSize for bookmark const bookmarkInfo = await loadBookmarkInfoFromJSON(reader._item); const currentBookmarkSize = bookmarkInfo?.baseFontSize ?? DEFAULT_BOOKMARK_FONT_SIZE; // Decrease by 1, min 8 const newOutlineSize = Math.max(currentOutlineSize - 1, MIN_FONT_SIZE); const newBookmarkSize = Math.max(currentBookmarkSize - 1, MIN_FONT_SIZE); if ( newOutlineSize !== currentOutlineSize || newBookmarkSize !== currentBookmarkSize ) { // Update CSS updateOutlineFontSize(doc, newOutlineSize); updateBookmarkFontSize(doc, newBookmarkSize); // Save to JSON await saveOutlineToJSON(reader._item, undefined, newOutlineSize); await saveBookmarksToJSON(reader._item, undefined, newBookmarkSize); ztoolkit.log( `Font size decreased: outline=${newOutlineSize}px, bookmark=${newBookmarkSize}px`, ); } } ================================================ FILE: src/modules/outline/index.ts ================================================ import { wait } from "zotero-plugin-toolkit"; import { getString } from "../../utils/locale"; import { initEventListener } from "./events"; import { addButton, createTreeNodes, getOutlineFromPDF, registerOutlineCSS, registerThemeChange, updateOutlineFontSize, loadOutlineInfoFromJSON, DEFAULT_BASE_FONT_SIZE, } from "./outline"; import { addBookmarkButton, createBookmarkNodes, loadBookmarksFromJSON, updateBookmarkFontSize, loadBookmarkInfoFromJSON, DEFAULT_BOOKMARK_FONT_SIZE, } from "./bookmark"; import { ICONS } from "./style"; import { getPref } from "../../utils/prefs"; export function renderTree( reader: _ZoteroTypes.ReaderInstance, doc: Document, data: OutlineNode[] | null, ) { const dropIndicator = ztoolkit.UI.createElement(doc, "div", { classList: ["drop-indicator"], }); const toolbar = ztoolkit.UI.createElement(doc, "div", { namespace: "html", id: "j-outline-toolbar", classList: ["j-hidden"], // 默认隐藏 children: [ { tag: "button", id: "j-outline-expand-all", classList: ["j-outline-toolbar-button", "toolbar-button"], properties: { innerHTML: ICONS.expand }, attributes: { title: getString("outline-expand-all") }, }, { tag: "button", id: "j-outline-collapse-all", classList: ["j-outline-toolbar-button", "toolbar-button"], properties: { innerHTML: ICONS.collapse }, attributes: { title: getString("outline-collapse-all") }, }, { tag: "button", id: "j-outline-add-node", classList: ["j-outline-toolbar-button", "toolbar-button"], properties: { innerHTML: ICONS.add }, attributes: { title: getString("outline-add") }, }, { tag: "button", id: "j-outline-delete-node", classList: ["j-outline-toolbar-button", "toolbar-button"], properties: { innerHTML: ICONS.del }, attributes: { title: getString("outline-delete") }, }, { tag: "button", id: "j-outline-save-pdf", classList: ["j-outline-toolbar-button", "toolbar-button"], properties: { innerHTML: ICONS.save }, attributes: { title: getString("outline-save-to-pdf") }, }, ], }); const treeContainer = ztoolkit.UI.createElement(doc, "div", { id: "jasminum-outline", classList: ["hidden"], // 默认隐藏 namespace: "html", children: [ { tag: "div", namespace: "html", id: "j-outline-viewer", classList: ["outline-view"], attributes: { tabindex: "-1", "data-tabstop": "1", role: "tabpanel", "aria-labelledby": "j-outline-button", }, children: [ { tag: "ul", namespace: "html", id: "root-list", classList: ["tree-list"], }, ], }, { tag: "div", namespace: "html", classList: ["jasminum-sidebar-bottom"], children: [ { tag: "button", namespace: "html", id: "j-outline-zoom-in", classList: ["j-outline-toolbar-button", "toolbar-button"], properties: { innerHTML: ICONS.plus }, attributes: { title: "字体变大" }, styles: { paddingBottom: "7px" }, }, { tag: "button", namespace: "html", id: "j-outline-zoom-out", classList: ["j-outline-toolbar-button", "toolbar-button"], properties: { innerHTML: ICONS.minus }, attributes: { title: "字体变小" }, styles: { paddingBottom: "7px" }, }, ], }, ], }); // 隐藏 Zotero 大纲按钮 if (getPref("disableZoteroOutline")) { doc.getElementById("viewOutline")!.style.display = "none"; } // 添加工具栏 doc .getElementById("sidebarContainer")! .insertBefore(toolbar, doc.getElementById("sidebarContent")!); treeContainer.appendChild(dropIndicator); createTreeNodes(data, treeContainer.querySelector("#root-list")!, doc); doc.querySelector("#sidebarContent")?.appendChild(treeContainer); return treeContainer; } export function renderBookmarkTree( reader: _ZoteroTypes.ReaderInstance, doc: Document, data: BookmarkNode[] | null, ) { const dropIndicator = ztoolkit.UI.createElement(doc, "div", { classList: ["bookmark-drop-indicator"], }); const toolbar = ztoolkit.UI.createElement(doc, "div", { namespace: "html", id: "j-bookmark-toolbar", classList: ["j-hidden"], // 默认隐藏 children: [ { tag: "button", id: "j-bookmark-add", classList: ["j-bookmark-toolbar-button", "toolbar-button"], properties: { innerHTML: ICONS.add }, attributes: { title: getString("bookmark-add") }, }, { tag: "button", id: "j-bookmark-delete", classList: ["j-bookmark-toolbar-button", "toolbar-button"], properties: { innerHTML: ICONS.del }, attributes: { title: getString("bookmark-delete") }, }, ], }); const bookmarkContainer = ztoolkit.UI.createElement(doc, "div", { id: "jasminum-bookmarks", classList: ["hidden"], // 默认隐藏 namespace: "html", children: [ { tag: "div", namespace: "html", id: "j-bookmark-viewer", classList: ["bookmark-view"], attributes: { tabindex: "-1", "data-tabstop": "1", role: "tabpanel", "aria-labelledby": "j-bookmark-button", }, children: [ { tag: "ul", namespace: "html", id: "bookmark-root-list", classList: ["bookmark-list"], }, ], }, ], }); // 添加工具栏 doc .getElementById("sidebarContainer")! .insertBefore(toolbar, doc.getElementById("sidebarContent")!); bookmarkContainer.appendChild(dropIndicator); createBookmarkNodes( data, bookmarkContainer.querySelector("#bookmark-root-list")!, doc, ); doc.querySelector("#sidebarContent")?.appendChild(bookmarkContainer); return bookmarkContainer; } export async function addOutlineToReader(reader: _ZoteroTypes.ReaderInstance) { const doc = reader._iframeWindow!.document; if (doc.querySelector("#j-outline-button")) { ztoolkit.log("Outline is already added, skip."); return; } // 等待元素加载 await wait.waitUtilAsync( () => { return doc.querySelector("#sidebarContainer div.start") ? true : false; }, 5, // 减少图标出现延迟感 5000, ); ztoolkit.log("Sidebar container is ready."); addButton(doc); addBookmarkButton(doc); // 同时添加书签按钮 const joutline = await getOutlineFromPDF(reader); if (!joutline) { ztoolkit.log("No outline to add."); } ztoolkit.log("++joutline", joutline); const bookmarks = await loadBookmarksFromJSON(reader._item); ztoolkit.log("++bookmarks", bookmarks); // Load baseFontSize from JSON for outline const outlineInfo = await loadOutlineInfoFromJSON(reader._item); const outlineBaseFontSize = outlineInfo?.baseFontSize ?? DEFAULT_BASE_FONT_SIZE; // Load baseFontSize from JSON for bookmark const bookmarkInfo = await loadBookmarkInfoFromJSON(reader._item); const bookmarkBaseFontSize = bookmarkInfo?.baseFontSize ?? DEFAULT_BOOKMARK_FONT_SIZE; registerOutlineCSS(doc); registerThemeChange(reader._iframeWindow!); // Apply dynamic font size for both outline and bookmark updateOutlineFontSize(doc, outlineBaseFontSize); updateBookmarkFontSize(doc, bookmarkBaseFontSize); renderTree(reader, doc, joutline); renderBookmarkTree(reader, doc, bookmarks); initEventListener(reader, doc); } export async function registerOutline(tabID: string) { if (!tabID) { ztoolkit.log(`Tab ID is not valid. %{tabID}`); return; } await Zotero.Reader.init(); const reader = Zotero.Reader.getByTabID(tabID as string); try { await reader._initPromise; ztoolkit.log("Init " + reader._isReaderInitialized); // Only pdf if (reader._item.attachmentContentType != "application/pdf") { ztoolkit.log("Only support PDF reader."); return; } // This should add a waiting process. // @ts-ignore - not typed const doc = reader._iframeWindow?.document; // ztoolkit.log("registerOutline", new Date().toISOString()); await wait.waitUtilAsync( () => { return doc && doc.getElementById("sidebarToggle") ? true : false; }, 5, 5000, ); // ztoolkit.log("registerOutline", new Date().toISOString()); ztoolkit.log("Sidebar toggle button is ready."); // Sidebar is already opened, add outline. if (doc && doc.getElementById("sidebarContainer")) { addOutlineToReader(reader); } // Click toggle button to open sidebar. doc ?.getElementById("sidebarToggle") ?.addEventListener("click", (ev: Event) => { ztoolkit.log("outline is added by toggle click"); addOutlineToReader(reader); }); } catch (e) { Zotero.debug( "********************* outline add error *********************", ); ztoolkit.log("Error in registerOutline", e); ztoolkit.log(`tabID: ${tabID}`); ztoolkit.log(`reader: ${reader}`); } } ================================================ FILE: src/modules/outline/outline.ts ================================================ import { wait } from "zotero-plugin-toolkit"; import { version } from "../../../package.json"; import { getString } from "../../utils/locale"; import { outline_css, ICONS } from "./style"; // 2 : Add base font size = 12 export const OUTLINE_SCHEMA = 2; export const DEFAULT_BASE_FONT_SIZE = 12; // Default base font size for level-1 // Register custom CSS for Jasminum outline export function registerOutlineCSS(doc: Document) { ztoolkit.log("** Register css"); ztoolkit.UI.appendElement( { tag: "style", namespace: "html", attributes: { type: "text/css" }, properties: { textContent: outline_css, }, }, doc.querySelector("head")!, ); } // Update font size dynamically based on baseFontSize export function updateOutlineFontSize(doc: Document, baseFontSize: number) { const styleId = "jasminum-dynamic-font-size"; let styleElement = doc.getElementById(styleId) as HTMLStyleElement; if (!styleElement) { styleElement = doc.createElement("style"); styleElement.id = styleId; styleElement.type = "text/css"; doc.querySelector("head")!.appendChild(styleElement); } // Calculate font sizes: level-1 = base, level-2 = base-1, level-3+ = base-2 const level1Size = baseFontSize; const level2Size = baseFontSize - 1; const level3PlusSize = baseFontSize - 2; const dynamicCSS = ` .level-1 { font-size: ${level1Size}px !important; } .level-2 { font-size: ${level2Size}px !important; } .level-3, .level-4, .level-5, .level-6, .level-7 { font-size: ${level3PlusSize}px !important; } `; styleElement.textContent = dynamicCSS; ztoolkit.log(`Updated font size: base=${baseFontSize}px`); } // Register for theme update export function registerThemeChange(win: Window) { win ?.matchMedia("(prefers-color-scheme: dark)")! .addEventListener("change", (e: MediaQueryListEvent) => { if (e.matches) { win.document.documentElement.setAttribute("data-theme", "dark"); } else { win.document.documentElement.setAttribute("data-theme", "light"); } }); // Init theme for outline tree. // 窗口启动时为黑暗主题,将书签主题设置为黑暗模式 if (win.matchMedia("(prefers-color-scheme: dark)")!.matches === true) { win.document.documentElement.setAttribute("data-theme", "dark"); } } // Add outline button and outline tree. export function addButton(doc: Document) { if (doc.querySelector("#sidebarContainer div.start") === null) { ztoolkit.log("Sidebar toolbar button is missing."); } ztoolkit.UI.appendElement( { tag: "button", namespace: "html", id: "j-outline-button", classList: ["toolbar-button"], properties: { innerHTML: ICONS.outline }, attributes: { title: getString("outline"), tabindex: "-1", role: "tab", "aria-selected": "false", "aria-controls": "j-outline-viewer", }, // listeners: [ // { // type: "click", // listener: (e) => { // ztoolkit.log("Button.click"); // ztoolkit.log(e); // const d = (e.target! as HTMLButtonElement).ownerDocument; // const viewer = d.getElementById("j-outline-viewer")?.parentElement; // // 显示工具栏 // d // .getElementById("j-outline-toolbar") // ?.classList.toggle("j-outline-hidden", false); // if (!viewer?.classList.contains("hidden")) { // ztoolkit.log("Already display"); // } else { // // 按钮的激活状态 // d // .getElementById("viewThumbnail") // ?.classList.toggle("active", false); // d // .getElementById("viewOutline") // ?.classList.toggle("active", false); // d // .getElementById("viewAnnotations") // ?.classList.toggle("active", false); // d // .getElementById("j-outline-button") // ?.classList.toggle("active", true); // // 书签内容显示 // d // .getElementById("thumbnailsView") // ?.parentElement?.classList.toggle("hidden", true); // d // .getElementById("annotationsView") // ?.classList.toggle("hidden", true); // d // .getElementById("outlineView") // ?.parentElement?.classList.toggle("hidden", true); // viewer?.classList.toggle("hidden", false); // ztoolkit.log("Display jasminum outline."); // } // }, // }, // ], }, doc.querySelector("#sidebarContainer div.start")!, ); } // 有 JSON 文件优先读取JSON文件 // 然后再获取PDF自带书签 export async function getOutlineFromPDF( reader: _ZoteroTypes.ReaderInstance, ): Promise { const item = reader._item; // 优先从JSON缓存中读取书签信息 const outlineJson = await loadOutlineFromJSON(item); if (outlineJson) return outlineJson; // 如果上面没有返回Outline信息,重新读取 await wait.waitUtilAsync( () => { return (reader._primaryView as _ZoteroTypes.Reader.PDFView) ._iframeWindow && (reader._primaryView as _ZoteroTypes.Reader.PDFView)._iframeWindow! .PDFViewerApplication.pdfDocument ? true : false; }, 200, 5000, ); ztoolkit.log("PDFViewerApplication is ready"); const PDFViewerApplication = ( reader._primaryView as _ZoteroTypes.Reader.PDFView )._iframeWindow!.PDFViewerApplication; await PDFViewerApplication.init; const pdfDocument = PDFViewerApplication.pdfDocument; if (!pdfDocument) { ztoolkit.log("No pdfDocument"); return null; } // @ts-ignore - Not typed const originOutline: PdfOutlineNode[] = await pdfDocument.getOutline2(); if (originOutline.length == 0) return null; ztoolkit.log(originOutline); async function convert( node: PdfOutlineNode, level = 0, ): Promise { level += 1; const title = node.title; // Default position const outlineNode: OutlineNode = { level, title, page: 1, x: 100, y: 100, children: [], }; // Some pdf missing dest, position instead. if (node.location && "dest" in node.location) { // @ts-ignore - Not typed const page = await pdfDocument.getPageIndex(node.location.dest); outlineNode.page = page; } else if (node.location && "position" in node.location) { outlineNode.page = node.location.position.pageIndex + 1; outlineNode.x = node.location.position.rects[0][0]; outlineNode.y = node.location.position.rects[0][1]; } if (node.items.length > 0) { outlineNode.children = await Promise.all( node.items.map((n) => convert(n, level)), ); } return outlineNode; } const outline = await Promise.all( originOutline.map((node) => convert(node, 0)), ); await saveOutlineToJSON(item, outline); return outline; } export function getOutlineFromPage(): OutlineNode[] { function loop(ul: Element): OutlineNode[] { const lis = Array.from( ul.querySelectorAll(":scope > li.tree-item"), )! as Element[]; return lis.map((li) => { const titleSpan = li.querySelector("span.node-title")!; const nodeDiv = li.querySelector("div.tree-node")!; return { level: parseInt(nodeDiv.getAttribute("level")!), title: titleSpan.textContent!, page: parseInt(nodeDiv.getAttribute("page")!), x: parseFloat(nodeDiv.getAttribute("x")!), y: parseFloat(nodeDiv.getAttribute("y")!), children: li.classList.contains("has-children") ? loop(li.querySelector("ul")!) : [], collapsed: li.classList.contains("collapsed"), }; }); } const reader = Zotero.Reader.getByTabID( ztoolkit.getGlobal("Zotero_Tabs").selectedID, ); const rootUL = reader._iframeWindow!.document.querySelector("#root-list "); if (!rootUL) return []; return loop(rootUL); } // 注意SCHEMA // 注意打开PDF时,默认打开书签 export async function saveOutlineToJSON( item?: Zotero.Item, outline?: OutlineNode[], baseFontSize?: number, ) { if (!outline) { outline = getOutlineFromPage(); } if (!item) { const reader = Zotero.Reader.getByTabID( ztoolkit.getGlobal("Zotero_Tabs").selectedID, ); item = reader._item; } // Get current baseFontSize if not provided if (baseFontSize === undefined) { const currentInfo = await loadOutlineInfoFromJSON(item); baseFontSize = currentInfo?.baseFontSize ?? DEFAULT_BASE_FONT_SIZE; } const outlineInfo: OutlineInfo = { info: { itemID: item.id, schema: OUTLINE_SCHEMA, jasminumVersion: version, baseFontSize: baseFontSize, }, outline: outline, }; const outlineStr = JSON.stringify(outlineInfo); const outlinePath = PathUtils.join( Zotero.DataDirectory.dir, "storage", item.key, "jasminum-outline.json", ); await Zotero.File.putContentsAsync(outlinePath, outlineStr); ztoolkit.log("Save outline to JSON"); } function migrateOutlineInfo( raw: any, fromSchema: number, ): { outline: OutlineNode[]; baseFontSize: number } { let outline: OutlineNode[] = raw.outline ?? []; let baseFontSize = DEFAULT_BASE_FONT_SIZE; // v1 → v2: add baseFontSize if (fromSchema < 2) { baseFontSize = raw.info?.baseFontSize ?? DEFAULT_BASE_FONT_SIZE; } // Future v2 → v3 migrations go here return { outline, baseFontSize }; } // 加载时要考虑JSON文件的版本信息,如果版本低,要重新从原文件加载信息 export async function loadOutlineInfoFromJSON( item: Zotero.Item, ): Promise<{ outline: OutlineNode[]; baseFontSize: number } | null> { const outlinePath = PathUtils.join( Zotero.DataDirectory.dir, "storage", item.key, "jasminum-outline.json", ); const isFileExist = await IOUtils.exists(outlinePath); if (!isFileExist) { ztoolkit.log(`Outline json is missing: ${outlinePath}`); return null; } else { const content = (await Zotero.File.getContentsAsync(outlinePath)) as string; const tmp = JSON.parse(content); const fileSchema = tmp.info?.schema ?? 1; if (fileSchema < OUTLINE_SCHEMA) { // Migrate old outline data instead of discarding const migrated = migrateOutlineInfo(tmp, fileSchema); await saveOutlineToJSON(item, migrated.outline, migrated.baseFontSize); return migrated; } else { const outlineInfo = JSON.parse(content) as OutlineInfo; return { outline: outlineInfo.outline, baseFontSize: outlineInfo.info.baseFontSize ?? DEFAULT_BASE_FONT_SIZE, }; } } } export async function loadOutlineFromJSON( item: Zotero.Item, ): Promise { const info = await loadOutlineInfoFromJSON(item); return info?.outline ?? null; } export function createTreeNodes( nodes: OutlineNode[] | null, parentElement: HTMLElement, doc: Document, ) { if (nodes === null || nodes.length == 0) { ztoolkit.UI.appendElement( { tag: "div", namespace: "html", classList: ["empty-outline-prompt"], properties: { innerHTML: `请点击上方按钮${ICONS.add}创建书签` }, }, parentElement, ); } else { nodes.forEach((node) => { const li = ztoolkit.UI.createElement(doc, "li", { namespace: "html", classList: node.children && node.children.length > 0 ? ["tree-item", "has-children"] : ["tree-item"], children: [ { tag: "div", namespace: "html", classList: ["tree-node", `level-${node.level}`], attributes: { draggable: "true", level: node.level, x: node.x, y: node.y, page: node.page, }, children: [ { tag: "span", namespace: "html", classList: ["expander"], properties: { innerHTML: node.children && node.children.length > 0 ? node.collapsed === false ? ICONS.down : ICONS.right : " ", }, }, { tag: "div", namespace: "html", classList: ["node-content"], children: [ { tag: "span", namespace: "html", classList: ["node-title"], properties: { textContent: node.title }, attributes: { title: `${node.title}, Page: ${node.page}`, }, }, ], }, ], }, ], }); // Collapsed node if (node.collapsed) { li.classList.add("collapsed"); } // Add children node if (node.children && node.children.length > 0) { const ul = ztoolkit.UI.createElement(doc, "ul", { namespace: "html", classList: ["tree-list"], }); createTreeNodes(node.children, ul, doc); li.appendChild(ul); } // Now append the node to the parentElement. parentElement.appendChild(li); return li; }); } } ================================================ FILE: src/modules/outline/style.ts ================================================ export const ICONS = { outline: ``, bookmark: ``, expand: ``, collapse: ``, add: ``, del: ``, save: ``, down: ``, right: ``, plus: ``, minus: ``, }; export const outline_css = ` :root { /* Light mode variables */ --background-color: #f5f5f5; --container-bg: white; --text-color: #333; --heading-color: #2c3e50; --border-color: #ddd; --button-bg: #e8f4fd; --button-hover-bg: #e8f4fd; --node-hover-bg: #f0f0f0; --selected-node-bg: #e8f4fd; --shadow-color: rgba(0, 0, 0, 0.1); --dragover-bg: rgba(52, 152, 219, 0.1); --drop-indicator-color: #3498db; } #j-outline-viewer { max-width: 1000px; margin: 0 auto 37px auto; background: var(--container-bg); border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; box-shadow: 0 2px 10px var(--shadow-color); font-family: Arial, sans-serif; line-height: 1.6; color: var(--text-color); padding: 2px 8px 8px 8px; transition: background-color 0.3s, color 0.3s; } [data-theme="dark"] { /* Dark mode variables */ --background-color: #1a1a1a; --container-bg: #2c2c2c; --text-color: #e0e0e0; --heading-color: #90caf9; --border-color: #444; --button-bg: #2196f3; --button-hover-bg: #1976d2; --node-hover-bg: #3e3e3e; --selected-node-bg: #2a4055; --shadow-color: rgba(0, 0, 0, 0.3); --dragover-bg: rgba(33, 150, 243, 0.2); --drop-indicator-color: #64b5f6; } .j-hidden { display: none !important;} #j-outline-toolbar { display: inline-flex; gap: 6px; padding: 4px 4px 4px 8px; border-top: 1px solid #ddd; border-bottom: 1px solid #ddd; } .j-outline-toolbar-button { padding: 0; margin: 0; border: none; background: none; cursor: pointer; } .j-outline-toolbar-button svg { display: block; width: 24px; height: 24px; } button:hover.j-outline-toolbar-button { background: var(--button-hover-bg); } .tree-container { border: 1px solid var(--border-color); border-radius: 4px; padding: 15px; background: var(--container-bg); width: 350px; overflow: auto; transition: background 0.3s, border-color 0.3s; } .tree-list { list-style-type: none; padding-left: 0; position: relative; } .tree-list li { margin: 1px 0; position: relative; } .tree-list ul { list-style-type: none; padding-left: 25px; padding-top: 2px; position: relative; } .tree-node { display: flex; align-items: center; padding: 1px; border-radius: 2px; cursor: pointer; transition: background 0.2s, border-left-color 0.2s; border-left: 2px solid transparent; border-left-width: 2px; position: relative; } .tree-node:hover { background: var(--node-hover-bg); } .node-selected { background: var(--selected-node-bg); } .tree-node.dragging { opacity: 0.5; } .dragover { background-color: var(--dragover-bg); } .drop-indicator { position: absolute; height: 2px; background-color: var(--drop-indicator-color); left: 0; right: 0; pointer-events: none; display: none; transition: background-color 0.3s; } .drop-indicator.visible { display: block; } .drop-indicator::before { content: ""; position: absolute; width: 6px; height: 6px; border-radius: 50%; background-color: var(--drop-indicator-color); left: -3px; top: -2px; transition: background-color 0.3s; } .drop-indicator.top { top: 0; } .drop-indicator.bottom { bottom: 0; } .drop-indicator.middle { top: 50%; box-shadow: 0 0 3px var(--shadow-color); } .expander { width: 20px; height: 20px; cursor: pointer; margin-right: 2px; margin-left: -6px; text-align: center; line-height: 10px; flex-shrink: 0; } .node-content { flex-grow: 1; overflow: hidden; white-space: nowrap; display: flex; } .node-title { display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: 100%; } .node-edit { padding: 2px; border: 1px solid var(--border-color); border-radius: 3px; font-family: inherit; font-size: inherit; width: 100%; background-color: var(--container-bg); color: var(--text-color); } /* Rainbow hierarchy indicators for different levels - maintained in both themes */ .level-1 { font-size: 12px; border-left-color: #ff5252; /* Red */ } .level-2 { font-size: 11px; border-left-color: #ff9800; /* Orange */ } .level-3 { font-size: 10px; border-left-color: #ffeb3b; /* Yellow */ } .level-4 { font-size: 10px; border-left-color: #4caf50; /* Green */ } .level-5 { font-size: 10px; border-left-color: #2196f3; /* Blue */ } .level-6 { font-size: 10px; border-left-color: #673ab7; /* Purple */ } .level-7 { font-size: 10px; border-left-color: #e91e63; /* Pink */ } .collapsed > ul { display: none; } .hidden { display: none } /* 书签相关样式 */ #j-bookmark-toolbar { display: inline-flex; gap: 6px; padding: 4px 4px 4px 8px; border-top: 1px solid #ddd; border-bottom: 1px solid #ddd; } .j-bookmark-toolbar-button { padding: 0; margin: 0; border: none; background: none; cursor: pointer; } .j-bookmark-toolbar-button svg { display: block; width: 24px; height: 24px; } button:hover.j-bookmark-toolbar-button { background: var(--button-hover-bg); } #j-bookmark-viewer { max-width: 1000px; margin: 0 auto 37px auto; background: var(--container-bg); border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; box-shadow: 0 2px 10px var(--shadow-color); font-family: Arial, sans-serif; line-height: 1.6; color: var(--text-color); padding: 2px 8px 8px 8px; transition: background-color 0.3s, color 0.3s; } .bookmark-list { list-style-type: none; padding-left: 0; position: relative; } .bookmark-list li { margin: 2px 0; position: relative; } .bookmark-node { display: flex; align-items: center; padding: 6px 8px; border-radius: 4px; cursor: pointer; transition: background 0.2s, border-left-color 0.2s; border-left: 3px solid #3498db; position: relative; font-size: 13px; } .bookmark-node:hover { background: var(--node-hover-bg); } .bookmark-selected { background: var(--selected-node-bg); } .bookmark-node.dragging { opacity: 0.5; } .bookmark-dragover { background-color: var(--dragover-bg); } .bookmark-drop-indicator { position: absolute; height: 2px; background-color: var(--drop-indicator-color); left: 0; right: 0; pointer-events: none; display: none; transition: background-color 0.3s; z-index: 1000; } .bookmark-drop-indicator.visible { display: block; } .bookmark-drop-indicator::before { content: ""; position: absolute; width: 6px; height: 6px; border-radius: 50%; background-color: var(--drop-indicator-color); left: -3px; top: -2px; transition: background-color 0.3s; } .bookmark-content { flex-grow: 1; overflow: hidden; white-space: nowrap; display: flex; } .bookmark-title { display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: 100%; } .empty-bookmark-prompt { text-align: center; color: #999; font-style: italic; padding: 20px; border: 2px dashed #ddd; border-radius: 4px; margin: 10px 0; } /* 书签颜色选择器 */ .bookmark-color-picker { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px; background: var(--bookmark-container-bg); border: 1px solid var(--bookmark-border-color); border-radius: 4px; } .bookmark-color-option { width: 20px; height: 20px; border-radius: 50%; cursor: pointer; border: 1px solid var(--bookmark-border-color); transition: transform 0.2s, border 0.2s; } .bookmark-color-option:hover { transform: scale(1.1); } .bookmark-color-option.selected { border: 2px solid var(--bookmark-text-color); } /* 书签编辑容器 */ .bookmark-edit-container { display: flex; flex-direction: column; gap: 8px; min-width: 200px; padding: 8px; background: var(--bookmark-container-bg); border: 1px solid var(--bookmark-border-color); border-radius: 4px; } .bookmark-edit-container input { padding: 4px 8px; border: 1px solid var(--bookmark-border-color); border-radius: 3px; background: var(--bookmark-container-bg); color: var(--bookmark-text-color); font-family: inherit; font-size: inherit; } /* 书签编辑分隔线 */ .bookmark-edit-separator { height: 1px; background: linear-gradient(90deg, transparent, var(--bookmark-border-color), transparent); margin: 4px 0; opacity: 0.6; } /* 底部功能栏 */ .jasminum-sidebar-bottom { padding: 8px 8px; border-top: var(--material-panedivider); z-index: 1; height: 37px; overflow: hidden; position: absolute; bottom: 0; width: 100%; display: flex; gap: 8px; background-color: var(--background-color); } `; ================================================ FILE: src/modules/preferences/main.ts ================================================ import { config } from "../../../package.json"; import { isMainlandChina } from "../../utils/http"; import { getString } from "../../utils/locale"; import { getPref, setPref } from "../../utils/prefs"; import { updateTranslators, bestSpeedBaseUrl } from ".././translators"; import type { PluginPrefsMap } from "../../utils/prefs"; import { onShowTable } from "./translators"; export function registerPrefsPane() { Zotero.PreferencePanes.register({ pluginID: config.addonID, src: `chrome://${config.addonRef}/content/preferences-main.xhtml`, label: getString("plugin-name"), image: `chrome://${config.addonRef}/content/icons/icon.png`, }); } /** * This function is called when the prefs window is opened See addon/chrome/content/preferences.xul onpaneload * @param _window Preference window */ export async function onPrefsWindowLoad(_window: Window) { if (!addon.data.prefs) { addon.data.prefs = { window: _window, }; } else { addon.data.prefs.window = _window; } updatePrefsUI(addon.data.prefs.window.document); bindPrefEvents(addon.data.prefs.window.document); } /** * Initialize platform specific preferences */ export async function initPrefs() { ztoolkit.log("init some prefs"); if (addon.data.env == "development") { setPref("firstRun", true); } if (getPref("firstRun")) { // For Zotero 6 migratePrefs("extensions.zotero.jasminum."); // For Zotero 7 migratePrefs("extensions.jasminum."); const inMainlandChina = await isMainlandChina(); setPref("isMainlandChina", inMainlandChina); setPref("firstRun", false); } if (!getPref("pdfMatchFolder")) { setPref( "pdfMatchFolder", Services.dirsvc.get("DfltDwnld", Ci.nsIFile).path, ); } if ( !getPref("translatorSource") || getPref("translatorSource") === "https://ftp.linxingzhong.top/translators_CN" ) { setPref("translatorSource", await bestSpeedBaseUrl()); } const translatortUpdateTime = getPref("translatorUpdateTime"); if ( typeof translatortUpdateTime !== "string" || /\D/.test(translatortUpdateTime) ) { Zotero.Prefs.clear(`${config.prefsPrefix}.translatorUpdateTime`); setPref("translatorUpdateTime", "0"); } } /** * Keep preferences startswith extensions.jasminum, clear deprecated preferences. * This function should be called only once when updating from old version extension. * @param prefix prefix with following dot */ function migratePrefs(prefix: string) { ztoolkit.log(`migrate prefs with prefix ${prefix}`); const acceptPrefsMap: Record = { firstrun: "firstRun", /* tools */ zhnamesplit: "autoSplitName", ennamesplit: "splitEnName", language: "language", /* retrieve metadata */ autoupdate: "autoUpdateMetadata", namepattern: "namePattern", namepatternCustom: "namePatternCustom", metadataSource: "metadataSource", /* match pdf */ pdfMatchFolder: "pdfMatchFolder", /* update translators */ autoUpdateTranslators: "autoUpdateTranslators", translatorSource: "translatorSource", }; function isPrefKey(key: string): key is keyof typeof acceptPrefsMap { return key in acceptPrefsMap; } const oldPrefs = Services.prefs.getBranch(prefix).getChildList(""); for (const oldPrefKey of oldPrefs) { const oldFullKey = `${prefix}${oldPrefKey}`; const prefValue = Zotero.Prefs.get(oldFullKey); if (prefValue !== undefined) { if (isPrefKey(oldPrefKey)) { const newPrefKey = acceptPrefsMap[oldPrefKey]; // New preference key is compatible with old preference value setPref(newPrefKey, prefValue as PluginPrefsMap[keyof PluginPrefsMap]); ztoolkit.log( `Migrate preference ${oldFullKey} -> ${config.prefsPrefix}.${newPrefKey}, ${prefValue}`, ); } else { Zotero.Prefs.clear(oldFullKey); } } } } /** * Initialize UI elements on prefs window with addon.data.prefs.window.document */ async function updatePrefsUI(doc: Document) { const namePatterns: Record = { auto: 1, "{%t}_{%g}": 2, "{%t}": 3, custom: 4, }; ( doc.querySelector( "#zotero-prefpane-jasminum-namepattern-menulist", ) as XULMenuListElement ).selectedIndex = namePatterns[getPref("namePattern")] - 1; } function bindPrefEvents(doc: Document) { /* PDF file name patttern */ doc .getElementById(`zotero-prefpane-${config.addonRef}-namepattern-menulist`) ?.addEventListener("click", (event: Event) => { const pName = "namePattern"; const value = (event.target as XULMenuItemElement).getAttribute("value")!; const customInput = doc.getElementById( `zotero-prefpane-${config.addonRef}-namepatternCustom-input`, ); const input = doc.getElementById( `zotero-prefpane-${config.addonRef}-namepattern-input`, ); const isCustom = value === "custom"; if (isCustom) setPref("namePattern", "custom"); customInput?.classList.toggle("hidden", !isCustom); input?.classList.toggle("hidden", isCustom); setPref(pName, value); }); /* Update translators */ doc .getElementById(`zotero-prefpane-${config.addonRef}-force-update`) ?.addEventListener("click", async (event) => { const button = event.target as HTMLButtonElement; button.disabled = true; if (addon.data.translators.updating) { ztoolkit.log("Chinese translators are under updating."); addon.data.prefs?.window.alert( getString("info-translators-cn-updaing"), ); } else { await updateTranslators(true); } addon.data.prefs?.window.setTimeout(() => { button.disabled = false; }, 3000); }); doc .querySelector(`#zotero-prefpane-${config.addonRef}-open-translator-table`) ?.addEventListener("click", async (event) => { onShowTable(); }); doc .getElementById(`zotero-prefpane-${config.addonRef}-best-speed-button`) ?.addEventListener("click", async (event) => { const button = event.target as HTMLButtonElement; button.disabled = true; try { const bestUrl = await bestSpeedBaseUrl(); setPref("translatorSource", bestUrl); addon.data.prefs?.window.alert( getString("info-best-speed-source-updated", { args: { source: bestUrl }, }), ); } catch (error) { ztoolkit.log(`select best speed source failed: ${error}`); addon.data.prefs?.window.alert( getString("info-best-speed-source-failed"), ); } finally { button.disabled = false; } }); // metadata source dropdown // doc // .querySelector(`#zotero-prefpane-${config.addonRef}-metadata-source-button`) // ?.addEventListener("click", (e) => { // e.stopPropagation(); // 阻止事件冒泡 // const pvalues = (getPref("metadataSource") as string).split(", "); // doc.querySelectorAll("checkbox.metadata-drop-item")!.forEach((e: any) => { // e.checked = pvalues.includes(e.getAttribute("value")!); // }); // doc.querySelector("#metadata-source-dropdown")?.classList.toggle("show"); // }); // doc // .querySelector("#metadata-source-dropdown") // ?.addEventListener("click", (e) => { // const checkbox = (e.target as HTMLElement).closest( // ".metadata-drop-item", // )!; // let pvalues = getPref("metadataSource").split(", ") || ["CNKI"]; // if (checkbox.getAttribute("checked") == "true") { // const checkedSource = checkbox.getAttribute("value")!; // if (!pvalues.includes(checkedSource)) { // pvalues.push(checkedSource); // } // } else { // pvalues = pvalues.filter( // (option) => option !== checkbox.getAttribute("value")!, // ); // } // setPref("metadataSource", pvalues.join(", ")); // }); doc .querySelector( `#zotero-prefpane-${config.addonRef}-pdf-match-folder-button`, ) ?.addEventListener("click", async (e) => { const path = await new ztoolkit.FilePicker( getString("select-download-folder"), "folder", [], ).open(); if (path) setPref("pdfMatchFolder", path); }); // doc // .querySelector( // `#zotero-prefpane-${config.addonRef}-install-wps-plugin-button`, // ) // ?.addEventListener("click", async (e) => { // ztoolkit.getGlobal("window").alert("等待更新"); // }); } ================================================ FILE: src/modules/preferences/translators.ts ================================================ import { isWindowAlive } from "../../utils/window"; import { getLastUpdatedFromFile, getLastUpdatedMap } from "../translators"; import { config } from "../../../package.json"; import { getString } from "../../utils/locale"; async function onWindowLoad(_window: Window) { addon.data.translators.window = _window; await updateRowData(); addon.data.translators.rows = addon.data.translators.allRows; const columns = [ { dataKey: "filename", label: getString("th-filename"), fixedWidth: false, }, { dataKey: "label", label: getString("th-label"), fixedWidth: false, }, { dataKey: "localUpdateTime", label: getString("th-local-update-time"), fixedWidth: true, width: 145, }, { dataKey: "remoteUpdateTime", label: getString("th-remote-update-time"), fixedWidth: true, width: 145, }, ]; addon.data.translators.helper = new ztoolkit.VirtualizedTable( addon.data.translators.window, ) .setContainerId("table-container") .setProp({ id: "translators-table", columns, showHeader: true, staticColumns: false, }) .setProp("getRowCount", () => addon.data.translators.rows.length) .setProp( "getRowData", (index: number) => addon.data.translators.rows[index], ) .setProp("onColumnSort", (columnIndex, ascending) => { // columnIndex from sort event is always valid, so assert its type const sortKey = columns[columnIndex].dataKey as keyof TableRow; addon.data.translators.rows.sort((a, b) => { return ascending > 0 ? a[sortKey].localeCompare(b[sortKey]) : b[sortKey].localeCompare(a[sortKey]); }); updateTableUI(); }) .render(); updateTableUI(); } async function updateRowData() { const map = await getLastUpdatedMap(addon.data.env !== "development"); ztoolkit.log("updateRowData", map); const rows: TableRow[] = []; for (const [filename, { label, lastUpdated }] of Object.entries(map)) { rows.push({ filename, label, localUpdateTime: (await getLastUpdatedFromFile(filename)) || "--", remoteUpdateTime: lastUpdated, }); } addon.data.translators.allRows = rows; } async function updateTableUI() { return new Promise((resolve) => { addon.data.translators.helper?.render(undefined, () => { resolve(); }); }); } function bindEvents(doc: Document) { doc.getElementById("github-link")?.addEventListener("click", (event) => { Zotero.launchURL("https://github.com/l0o0/translators_CN"); }); const searchBox = doc.getElementById("search-box"); searchBox?.addEventListener("command", async (event) => { ztoolkit.log("search", event); const value = (event.target as XULTextBoxElement).value; if (!value) { addon.data.translators.rows = addon.data.translators.allRows; } else { addon.data.translators.rows = addon.data.translators.allRows.filter( (row) => { function ignoreCaseIncludes(str: string, search: string) { return str.toLowerCase().includes(search.toLowerCase()); } return ( ignoreCaseIncludes(row.filename, value) || ignoreCaseIncludes(row.label, value) ); }, ); } await updateTableUI(); ztoolkit.log(`Updated table for search: ${value}`); }); searchBox?.focus(); doc .getElementById("request-new-translator") ?.addEventListener("click", (event) => { Zotero.launchURL( "https://github.com/l0o0/translators_CN/issues/new?template=T3_new_translator.yaml", ); }); doc .getElementById("report-translator-bug") ?.addEventListener("click", (event) => { Zotero.launchURL( "https://github.com/l0o0/translators_CN/issues/new?template=T1_bug.yaml", ); }); } export async function onShowTable() { if (isWindowAlive(addon.data.translators.window)) { addon.data.translators.window!.focus(); await updateRowData(); await updateTableUI(); } else { const windowArgs = { _initPromise: Zotero.Promise.defer(), }; const win = Zotero.getMainWindow().openDialog( `chrome://${config.addonRef}/content/preferences-translators.xhtml`, "_blank", "chrome,centerscreen,resizable", windowArgs, ); await windowArgs._initPromise.promise; addon.data.translators.window = win!; await updateRowData(); onWindowLoad(addon.data.translators.window); bindEvents(addon.data.translators.window!.document); } } ================================================ FILE: src/modules/progress.ts ================================================ import { ElementProps, TagElementProps, } from "zotero-plugin-toolkit/dist/tools/ui"; import { getString } from "../utils/locale"; export class Progress { public progressWindow: Window | null; private statusIcons: Record = {}; constructor() { this.progressWindow = null; this.statusIcons = { waiting: "chrome://jasminum/content/icons/loading-loop.svg", processing: "chrome://jasminum/content/icons/loading-loop.svg", multiple_results: "chrome://jasminum/content/icons/loading-loop.svg", success: "chrome://jasminum/content/icons/check.svg", fail: "chrome://jasminum/content/icons/cross.svg", }; } public async openProgressWindow(): Promise { ztoolkit.log(`Open progress window.`); const win = Services.wm.getMostRecentWindow("navigator:browser") as Window; const htmlUrl = "chrome://jasminum/content/progress.xhtml"; const chromeArgs = "chrome,centerscreen,width=960,height=400,dialog=yes,resizable=no,status=no"; const windowArgs = { _initPromise: Zotero.Promise.defer() }; if (win) { this.progressWindow = win.openDialog(htmlUrl, "", chromeArgs, windowArgs); this.progressWindow!.onbeforeunload = (e) => { this.progressWindow = null; addon.taskRunner.tasks = []; }; // For close button in header bar this.progressWindow!.onclose = (e) => { this.progressWindow = null; addon.taskRunner.tasks = []; }; let t = 0; // Wait for window while ( t < 500 && this.progressWindow!.document.readyState !== "complete" ) { // @ts-ignore -- Delay is not typed. await ztoolkit.getGlobal("Zotero").Promise.delay(10); t += 1; } await windowArgs._initPromise.promise; } else { ztoolkit.log(`Maybe this is an error. No main window found.`); // return Services.ww.openWindow(null, htmlUrl, "", chromeArgs, io); } } private createSearchResultProps( task: Task, searchResults: (ScrapeSearchResult | AttachmentSearchResult)[], ): TagElementProps { return { tag: "div", classList: ["search-results-container"], id: `search-results-container-${task.id}`, children: [ { namespace: "html", tag: "button", classList: ["confirm-button"], properties: { innerText: "确认", }, attributes: { "data-task-id": task.id }, }, { tag: "div", classList: ["search-results"], id: `search-results-${task.id}`, children: searchResults.map((result, index) => ({ tag: "div", classList: ["search-result"], children: [ { tag: "input", properties: { type: "radio", name: `task-${task.id}`, }, attributes: { "data-task-id": `${task.id}`, "data-result-index": `${index}`, }, }, { tag: "div", classList: ["info"], children: [ { tag: "span", classList: ["source"], properties: { innerText: `来源: ${result.source}` }, }, { tag: "span", classList: ["title"], properties: { innerText: `${result.title}` }, }, ], }, ], })), }, ], }; } // Add new task to progress window. public async addTaskToProgressWindow(task: Task): Promise { if (task.silent === true) return; if (this.progressWindow == null) { await this.openProgressWindow(); } ztoolkit.log("Add task to progress window."); const taskNodeProps: ElementProps = { classList: ["task"], children: [ { tag: "div", classList: ["task-header"], id: `task-header-${task.id}`, }, ], attributes: { "data-task-id": task.id }, }; const searchContainer: TagElementProps = { tag: "div", classList: ["search-results-container"], id: `search-results-container-${task.id}`, properties: { style: "display: none;" }, children: [ { namespace: "html", tag: "button", classList: ["confirm-button"], properties: { innerText: "确认" }, attributes: { "data-task-id": task.id }, }, ], }; // const taskHeaderChildren: TagElementProps[] = [ { tag: "img", classList: ["task-status"], id: `task-status-${task.id}`, properties: { src: this.statusIcons[task.status] }, }, { tag: "span", classList: ["task-title"], properties: { innerText: task.item.getField("title") }, }, ]; // if (task.searchResult && task.searchResult.length > 0) { // } taskNodeProps.children![0].children = taskHeaderChildren; taskNodeProps.children?.push(searchContainer); const taskNode = ztoolkit.UI.createElement( this.progressWindow!.document, "div", taskNodeProps, ); this.progressWindow!.document.querySelector("#task-list")?.appendChild( taskNode, ); } // Convert [text](url) and bare URLs in text to clickable elements private linkifyMessage(doc: Document, message: string): DocumentFragment { const fragment = doc.createDocumentFragment(); // Match [text](url) first, then bare URLs const linkRegex = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)|(https?:\/\/[^\s]+)/g; const lines = message.split("\n"); lines.forEach((line, lineIndex) => { let lastIndex = 0; let match: RegExpExecArray | null; linkRegex.lastIndex = 0; while ((match = linkRegex.exec(line)) !== null) { if (match.index > lastIndex) { fragment.appendChild( doc.createTextNode(line.slice(lastIndex, match.index)), ); } const link = doc.createElement("a"); link.setAttribute("href", "#"); if (match[1]) { // [text](url) format link.textContent = match[1]; link.setAttribute("data-url", match[2]); } else { // Bare URL link.textContent = match[3]; link.setAttribute("data-url", match[3]); } fragment.appendChild(link); lastIndex = match.index + match[0].length; } if (lastIndex < line.length) { fragment.appendChild(doc.createTextNode(line.slice(lastIndex))); } if (lineIndex < lines.length - 1) { fragment.appendChild(doc.createElement("br")); } }); return fragment; } // Update task status icon. Display error msgs when task fails. public updateTaskStatus(task: Task, status: string): void { if (this.progressWindow) { ztoolkit.log(`Progress windows update task status: ${task.id} ${status}`); this.progressWindow.document .querySelector(`#task-status-${task.id}`) ?.setAttribute("src", this.statusIcons[status]); // Display a popover with error msg. if (status == "fail") { const doc = this.progressWindow.document; // Create wrapper for hover area const wrapper = doc.createElement("span"); wrapper.className = "task-msg-wrapper"; // Create notify icon const icon = ztoolkit.UI.createElement(doc, "img", { id: `task-msg-${task.id}`, classList: ["task-msg"], properties: { src: "chrome://jasminum/content/icons/notify.svg", }, }); // Create popover container const popover = doc.createElement("div"); popover.className = "task-msg-popover"; popover.id = `task-msg-popover-${task.id}`; if (task.message) { popover.appendChild(this.linkifyMessage(doc, task.message)); } // Handle link clicks with Zotero.launchURL popover.addEventListener("click", (e) => { const target = e.target as HTMLElement; if (target.tagName === "A") { e.preventDefault(); e.stopPropagation(); const url = target.getAttribute("data-url"); if (url) { Zotero.launchURL(url); } } }); // Show popover on hover wrapper.addEventListener("mouseenter", () => { // Close other popovers first doc.querySelectorAll(".task-msg-popover.visible").forEach((p) => { p.classList.remove("visible"); }); doc.querySelectorAll(".task-msg.active").forEach((i) => { i.classList.remove("active"); }); popover.classList.add("visible"); icon.classList.add("active"); }); // Close popover on click outside doc.addEventListener("click", (e) => { const target = e.target as HTMLElement; if ( !target.closest(".task-msg-popover") && !target.closest(".task-msg-wrapper") ) { popover.classList.remove("visible"); icon.classList.remove("active"); } }); wrapper.appendChild(icon); wrapper.appendChild(popover); doc .querySelector(`#task-header-${task.id} > span.task-title`) ?.appendChild(wrapper); } } } public updateTaskSearchResult( task: Task, searchResults: (ScrapeSearchResult | AttachmentSearchResult)[], ): void { if (this.progressWindow) { ztoolkit.log(searchResults); const props = this.createSearchResultProps(task, searchResults); const taskSearchNode = ztoolkit.UI.createElement( this.progressWindow.document, "div", props, ); const toggle = ztoolkit.UI.createElement( this.progressWindow.document, "span", { classList: ["toggle-icon"], id: `toggle-icon-${task.id}`, properties: { innerText: "▼" }, }, ); // Replace the old search result node with the new one. this.progressWindow.document .querySelector(`#search-results-container-${task.id}`)! .replaceWith(taskSearchNode); this.progressWindow.document .querySelector(`#task-header-${task.id}`)! .appendChild(toggle); } } } ================================================ FILE: src/modules/services/cnki.ts ================================================ import { requestDocument } from "../../utils/http"; import { DocTools, jsonToFormUrlEncoded, text2HTMLDoc } from "../../utils/http"; import { getPref } from "../../utils/prefs"; import { ScraperTask } from "../../utils/task"; /** * Create post data for CNKI search. * @param searchOption * @returns */ function createSearchPostOptions(searchOption: SearchOption) { let url; const headers = { Host: "kns.cnki.net", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0", Accept: "*/*", "Accept-Language": "zh-CN,en-US;q=0.9,en;q=0.8", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "X-Requested-With": "XMLHttpRequest", Origin: "https://kns.cnki.net", Referer: `https://kns.cnki.net/kns8s/defaultresult/index?crossids=YSTT4HG0%2CLSTPFY1C%2CJUP3MUPD%2CMPMFIG1A%2CWQ0UVIAA%2CBLZOG7CK%2CPWFIRAGL%2CEMRPGLPA%2CNLBO1Z6R%2CNN3FJMUV&korder=SU&kw=`, "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", }; // SU may find more results than TI. SU %= | TI %= let searchExp: string; if (searchOption.title.includes(" ")) { // 过滤掉短的主题词,可以避免出现大量无关结果 const titleParts = searchOption.title .split(" ") .filter((i) => i.length > 4); searchExp = "(TI %= " + titleParts.map((_i) => `'${_i}'`).join(" % ") + " OR SU %= " + titleParts.join("+") + ")"; } else { searchExp = `TI %= '${searchOption.title}'`; } if (searchOption.author) searchExp = searchExp + ` AND AU='${searchOption.author}'`; ztoolkit.log("Search expression: ", searchExp); const searchExpAside = searchExp.replace(/'/g, "'"); let queryJson; if (getPref("isMainlandChina")) { ztoolkit.log("CNKI in mainland China."); url = "https://kns.cnki.net/kns8s/brief/grid"; queryJson = { boolSearch: "true", QueryJson: { Platform: "", Resource: "CROSSDB", Classid: "WD0FTY92", Products: "", QNode: { QGroup: [ { Key: "Subject", Title: "", Logic: 0, Items: [ { Key: "Expert", Title: "", Logic: 0, Field: "EXPERT", Operator: 0, Value: searchExp, Value2: "", }, ], ChildItems: [], }, { Key: "ControlGroup", Title: "", Logic: 0, Items: [], ChildItems: [], }, ], }, ExScope: "1", SearchType: 4, Rlang: "CHINESE", KuaKuCode: "YSTT4HG0,LSTPFY1C,JUP3MUPD,MPMFIG1A,WQ0UVIAA,BLZOG7CK,PWFIRAGL,EMRPGLPA,NLBO1Z6R,NN3FJMUV", SearchFrom: 1, }, pageNum: "1", pageSize: "20", sortField: "", sortType: "", dstyle: "listmode", productStr: "YSTT4HG0,LSTPFY1C,RMJLXHZ3,JQIRZIYA,JUP3MUPD,1UR4K4HZ,BPBAFJ5S,R79MZMCB,MPMFIG1A,WQ0UVIAA,NB3BWEHK,XVLO76FD,HR1YT1Z9,BLZOG7CK,PWFIRAGL,EMRPGLPA,J708GVCE,ML4DRIDX,NLBO1Z6R,NN3FJMUV,", aside: `(${searchExpAside})`, searchFrom: "资源范围:总库;++中英文扩展;++时间范围:更新时间:不限;++", CurPage: "1", }; } else { ztoolkit.log("Using CNKI oversea."); url = "https://chn.oversea.cnki.net/kns/Brief/GetGridTableHtml"; headers.Host = "www.cnki.net"; headers.Referer = "https://www.cnki.net/kns/defaultresult/index"; headers.Origin = "https://www.cnki.net"; headers.Accept = "text/html, */*; q=0.01"; headers["Accept-Language"] = "zh-CN,zh;q=0.9"; queryJson = { IsSearch: "true", QueryJson: { Platform: "", DBCode: "CFLS", KuaKuCode: "CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFN", QNode: { QGroup: [ { Key: "Subject", Title: "", Logic: 4, Items: [ { Key: "Expert", Title: "", Logic: 0, Name: "", Operate: "", Value: searchExp, ExtendType: 12, ExtendValue: "中英文对照", Value2: "", BlurType: "", }, ], ChildItems: [], }, { Key: "ControlGroup", Title: "", Logic: 1, Items: [], ChildItems: [], }, ], }, ExScope: 1, CodeLang: "", }, PageName: "AdvSearch", DBCode: "CFLS", KuaKuCodes: "CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFN", CurPage: "1", RecordsCntPerPage: "20", CurDisplayMode: "listmode", CurrSortField: "", CurrSortFieldType: "desc", IsSentenceSearch: "false", Subject: "", }; } // ztoolkit.log(queryJson); // ztoolkit.log(jsonToFormUrlEncoded(queryJson)); return { url: url, data: jsonToFormUrlEncoded(queryJson), headers: headers, }; } async function getRefworksText( searchResult: ScrapeSearchResult, ): Promise { const headers = { Accept: "text/plain, */*; q=0.01", "Accept-Language": "zh-CN,en-US;q=0.7,en;q=0.3", "Content-Type": "application/x-www-form-urlencoded", Host: "kns.cnki.net", Origin: "https://www.cnki.net", Priority: "u=0", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0", Referer: searchResult.url, }; const isMainlandChina = getPref("isMainlandChina"); if (getPref("isMainlandChina")) { // "1": row's sequence in search result page, defualt 1; "0": index of page in search result pages, defualt 0. const platform = "NZKPT"; const apiUrl = "https://kns.cnki.net/dm8/API/GetExport"; let responseText: string; let postData = isMainlandChina ? `filename=${searchResult.exportID}&uniplatform=${platform}` : `filename=${searchResult.dbname}!${searchResult.filename}!1!0`; postData += "&displaymode=GBTREFER%2Celearning%2CEndNote"; const resp = await Zotero.HTTP.request("POST", apiUrl, { body: postData, headers: headers, cookieSandbox: await addon.data.myCookieSandbox.getCNKIHomeCookieBox(), timeout: 10000, successCodes: [200, 403], }); ztoolkit.log(`Endnote reference text from CNKI: ${resp.responseText}`); responseText = resp.responseText; if (resp.status === 403) { ztoolkit.log( "CNKI access forbidden (403). This is likely due to missing or invalid cookies.", ); const respJson = JSON.parse(resp.responseText); ztoolkit.log("Retrying CNKI search after updating cookies..."); headers["Referer"] = respJson.message; const resp2 = await Zotero.HTTP.request("POST", apiUrl, { headers: headers, body: postData, cookieSandbox: await addon.data.myCookieSandbox.passCaptchaToCookieBox( respJson.message, "CNKI:Home", ), timeout: 10000, successCodes: [200, 403], }); responseText = resp2.responseText; } const returnJson = JSON.parse(responseText); if (returnJson.code != 1) { return null; } else { const endnoteRef = returnJson.data.find( (i: Record) => i.key === "EndNote", ); if (endnoteRef) { return endnoteRef.value[0].replace(/
/g, "\n"); } else { return null; } } } else { ztoolkit.log("CNKI oversea export reference."); const apiUrl = "https://chn.oversea.cnki.net/kns/Manage/APIGetExport"; // TODO: implement oversea export return null; } } async function getSnapshotItem( item: Zotero.Item, ): Promise { const regx = new RegExp( "/(kns8?s?|kcms2?)/(article/abstract\\?|detail/detail\\.aspx\\?)", "i", ); if (item.itemType == "webpage" && regx.test(item.getField("url"))) { const attachmentItem = Zotero.Items.get(item.getAttachments()).find( (attachment) => { return ( attachment.isSnapshotAttachment() && regx.test(attachment.getField("url")) ); }, ); if (attachmentItem === undefined) return undefined; const filePath = await attachmentItem.getFilePathAsync(); if (filePath) return attachmentItem; } return undefined; } // Update addtional information to the item. // Citations from CNKI, Use keyword: CNKICite async function updateItem( item: Zotero.Item | null, searchResult: ScrapeSearchResult, ): Promise { if (item) { if (searchResult.citation) { ztoolkit.ExtraField.setExtraField( item, "CNKICite", `${searchResult.citation}`, ); } if (searchResult.netFirst) { ztoolkit.ExtraField.setExtraField( item, "Status", "advance online publication", ); } // Remove unmatched Zotero fields note. if (item.getNotes().length > 0) { item.getNotes().forEach(async (nid) => { const nItem = Zotero.Items.get(nid); await nItem.eraseTx(); }); } if (!item.getField("date") && searchResult.date) { item.setField("date", searchResult.date); } } return item; } export class CNKI implements ScrapeService { async search( searchOption: SearchOption, ): Promise { ztoolkit.log("serch options: ", searchOption); const postOption = createSearchPostOptions(searchOption); let responseText: string; const resp = await Zotero.HTTP.request("POST", postOption.url, { headers: postOption.headers, body: postOption.data, cookieSandbox: await addon.data.myCookieSandbox.getCNKIHomeCookieBox(), timeout: 10000, successCodes: [200, 403], }); ztoolkit.log("CNKI search response: ", resp); responseText = resp.responseText; if (resp.status === 403) { ztoolkit.log( "CNKI search access forbidden (403). This is likely due to missing or invalid cookies.", ); const respJson = JSON.parse(resp.responseText); ztoolkit.log("Retrying CNKI search after updating cookies..."); await addon.data.myCookieSandbox.passCaptchaToCookieBox( respJson.message, "CNKI:Home", ); postOption.headers["Referer"] = respJson.message; ztoolkit.log("Refer", postOption.headers); const resp2 = await Zotero.HTTP.request("POST", postOption.url, { headers: postOption.headers, body: postOption.data, cookieSandbox: await addon.data.myCookieSandbox.getCNKIHomeCookieBox(), timeout: 10000, successCodes: [200, 403], }); ztoolkit.log("CNKI retry search response: ", resp2); responseText = resp2.responseText; } ztoolkit.log("CNKI final search response: ", responseText); const searchDoc = text2HTMLDoc(responseText); const resultRows = searchDoc.querySelectorAll( "table.result-table-list > tbody > tr", ); ztoolkit.log(`CNKI search result: ${resultRows.length}`); if (resultRows.length == 0) { ztoolkit.log("CNKI no items found."); return null; } else { const resultData = Array.from(resultRows).map((r) => { const dt = new DocTools(r as HTMLElement); let url = dt.attr("a.fz14", "href")!; // Missing host in CNKI oversea. if (!url.startsWith("http")) { url = "https://chn.oversea.cnki.net" + url; } const title = ` ${dt.innerText("td.seq")} ${dt.innerText("td.data")} ${dt.innerText("td.name a")} ${dt.innerText("td.author").replace(" ", ",")} ${dt.innerText("td.source")} ${dt.innerText("td.date")}`; return { source: "CNKI", title: title, articleTitle: dt.innerText("td.name a"), url: url, date: Zotero.Date.strToISO(dt.innerText("td.date")) || "", netFirst: dt.innerText("td.name > b.marktip"), citation: dt.innerText("td.quote"), exportID: dt.attr("td.seq input", "value"), dbname: dt.attr("td.operat > [data-dbname]", "data-dbname"), filename: dt.attr("td.operat > [data-dbname]", "data-filename"), }; }); return resultData; } } async translate( searchResult: ScrapeSearchResult, libraryID: number, saveAttachments: false, ): Promise { let translatedItems: Zotero.Item[] = []; let isWebTranslated = true; try { const doc = await requestDocument(searchResult.url, { headers: { Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", Referer: "https://kns.cnki.net/kns8s/AdvSearch", "Accept-Language": "zh-CN,en-US;q=0.7,en;q=0.3", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0", }, cookieSandbox: await addon.data.myCookieSandbox.getCNKIHomeCookieBox(), }); ztoolkit.log(`Document title: ${doc.title}`); if (doc.title != "知网节超时验证" && doc.title != "captcha") { // @ts-ignore - Translate is not typed. const translator = new Zotero.Translate.Web(); // CNKI.js // If the loading of translators fails, the following code might return nothing. translator.setTranslator("5c95b67b-41c5-4f55-b71a-48d5d7183063"); translator.setDocument(doc); translatedItems = await translator.translate({ libraryID: libraryID, saveAttachments: saveAttachments, }); } else { isWebTranslated = false; } } catch (e) { ztoolkit.log(`CNKI web translation failed: ${e}`); addon.taskRunner.runningTask?.addMsg(`CNKI web translation failed: ${e}`); isWebTranslated = false; } // Another translation for CNKI. if (isWebTranslated == false) { try { ztoolkit.log("知网网页出现验证码或其他异常,准备获取其他格式文献信息"); const refworksText = await getRefworksText(searchResult); if (!refworksText) { ztoolkit.log("CNKI reference text is null."); addon.taskRunner.runningTask?.addMsg("CNKI reference text is null."); return []; } ztoolkit.log("Formated Refworks text: ", refworksText); const translate = new Zotero.Translate.Import(); translate.setTranslator("7b6b135a-ed39-4d90-8e38-65516671c5bc"); translate.setString(refworksText); translatedItems = await translate.translate({ libraryID: libraryID, saveAttachments: false, }); } catch (e) { ztoolkit.log(`CNKI refwork translation failed: ${e}`); throw `CNKI refwork translation failed: ${e}`; } } return translatedItems; } // CNKI webpage item or snapshot item. async searchSnapshot( task: ScraperTask, ): Promise { ztoolkit.log("Start to search for snapshot"); let webpageItem: Zotero.Item; let attachmentItem: Zotero.Item | undefined; let searchResults: ScrapeSearchResult[] | null = null; if (task.item.isTopLevelItem()) { webpageItem = task.item; attachmentItem = await getSnapshotItem(task.item); } else { // Snapshot item must have an valid parent item? webpageItem = task.item.parentItem!; attachmentItem = task.item; } // Find snapshot attachment, if (attachmentItem) { const filePath = (await attachmentItem.getFilePathAsync()) as string; // Maybe we can find some usefull data from the snapshot page. const doc = text2HTMLDoc( (await Zotero.File.getContentsAsync(filePath)) as string, attachmentItem.getField("url"), ); const dt = new DocTools(doc); // http://x.cnki.net/search/common/testlunbo?dbcode=CJFQ&tablename=CJFDAUTO&filename=ZWBH202405039&filesourcetype=1 const noteUrl = dt.attr("li[title='记笔记'].btn-note > a", "href"); // https://aiplus.cnki.net/aiplus/direct?cid=Pe2nFq1PBOM11SpCErZ-LwM1UHjV0uMR_icN4IXwgidjURR2ddM6CTa9OS-R4yps7kfD7g5Wa4sKEufH3KeS74nDa1x0Roidi_RcpyaNH-4!&mimetype=XML const aiUrl = dt.attr("li.btn-cnki-ai > a", "href"); const noteParams = new URLSearchParams(noteUrl.split("?")[1]); const aiParams = new URLSearchParams(aiUrl.split("?")[1]); searchResults = [ { source: "CNKI", title: attachmentItem.getField("title"), url: attachmentItem.getField("url"), dbcode: noteParams.get("dbcode"), dbname: noteParams.get("tablename"), filename: noteParams.get("filename"), exportID: aiParams.get("cid"), }, ]; ztoolkit.log("Found searchResult in snapshot page", searchResults[0]); } // Found nothing in the snapshot page. Use CNKI search. if (searchResults === null) { const searchOption: SearchOption = { title: webpageItem.getField("title").replace(/ - 中国知网$/g, ""), }; searchResults = await this.search(searchOption); ztoolkit.log("Found searchResult from CNKI search", searchResults); } return searchResults || null; } } ================================================ FILE: src/modules/services/index.ts ================================================ import { getArgsFromPattern } from "../../utils/pattern"; import { getPDFTitle } from "../../utils/pdfParser"; import { getPref } from "../../utils/prefs"; import { ScraperTask } from "../../utils/task"; import { isChineseTopAttachment, isChinsesSnapshot } from "../../utils/detect"; import { CNKI } from "./cnki"; // import { PubScholar } from "./pubscholar"; import { Yiigle } from "./yiigle"; import { compareTwoStrings } from "string-similarity"; const cnki = new CNKI(); // const pubscholar = new PubScholar(); const yiigle = new Yiigle(); async function getSearchOption( item: Zotero.Item, ): Promise { let namepattern = getPref("namePattern"); // Get title from pdf page content. // 1: title from PDF, 2: {%t}_{%g} if (namepattern == "auto") { let title = undefined; try { title = await getPDFTitle(item.id); } catch (e) { ztoolkit.log(`Pdf parsing error ${e}`); } if (title) return { title }; return getArgsFromPattern(item.attachmentFilename, "{%t}_{%g}"); } else { if (namepattern == "custom") namepattern = getPref("namePatternCustom"); return getArgsFromPattern(item.attachmentFilename, namepattern); } } export async function metaSearch( task: ScraperTask, options?: any, ): Promise { // const scrapeServices = getPref("metadataSource").split(", ") || ["CNKI"]; if (!isChineseTopAttachment(task.item) && !isChinsesSnapshot(task.item)) { ztoolkit.log("No Chinese attachment or snapshot items found. Stop search."); return; } ztoolkit.log("search task", task); task.status = "processing"; // Searching by different scrape services let scrapeSearchResults: ScrapeSearchResult[] = []; if (task.type == "attachment") { const searchOption = await getSearchOption(task.item); task.addMsg( `Region: ${getPref("isMainlandChina") ? "Mainland China" : "Overseas"}`, ); task.addMsg(`Search pattern: ${getPref("namePattern")}`); task.addMsg(`Search option: ${JSON.stringify(searchOption)}`); if (searchOption) { const cnkiSearchResult = await cnki.search(searchOption); ztoolkit.log("cnki results", cnkiSearchResult); if (cnkiSearchResult) { task.addMsg(`Found ${cnkiSearchResult.length} results from CNKI`); scrapeSearchResults = scrapeSearchResults.concat(cnkiSearchResult); } // const pubscholarSearchResult = await pubscholar.search(searchOption); // ztoolkit.log("pubscholar results", pubscholarSearchResult); // if (pubscholarSearchResult) { // task.addMsg( // `Found ${pubscholarSearchResult.length} results from PubScholar`, // ); // scrapeSearchResults = scrapeSearchResults.concat( // pubscholarSearchResult, // ); // } const yiigleSearchResult = await yiigle.search(searchOption); ztoolkit.log("yiigle results", yiigleSearchResult); if (yiigleSearchResult) { task.addMsg(`Found ${yiigleSearchResult.length} results from Yiigle`); scrapeSearchResults = scrapeSearchResults.concat(yiigleSearchResult); } // Filter search results const filteredResults1 = scrapeSearchResults.filter((result) => { return (result.articleTitle as string).includes(searchOption.title); }); const filteredResults2 = scrapeSearchResults.filter((result) => { const score = compareTwoStrings( searchOption.title, result.articleTitle as string, ); ztoolkit.log(`Similarity score for "${result.articleTitle}": ${score}`); return ( !(result.articleTitle as string).includes(searchOption.title) && score > parseFloat(getPref("similarityThresholdForMetaData")) ); }); scrapeSearchResults = filteredResults1.concat(filteredResults2); task.addMsg( `After filtering, ${scrapeSearchResults.length} results left.`, ); } else { task.addMsg("Filename parsing error"); task.status = "fail"; } } else if (task.type == "snapshot") { const tmp = await cnki.searchSnapshot!(task); if (tmp) scrapeSearchResults = scrapeSearchResults.concat(tmp); } ztoolkit.log("all results: ", scrapeSearchResults); if (scrapeSearchResults.length == 0) { task.addMsg("No search results"); task.status = "fail"; } else if (scrapeSearchResults.length > 1) { task.status = "multiple_results"; } task.searchResults = scrapeSearchResults; } export async function metaTranslate(task: ScraperTask): Promise { if (task.searchResults.length === 0) { task.addMsg("No search results found."); task.status = "fail"; } try { const resultIndex = task.resultIndex || 0; // default is 0 task.resultIndex = resultIndex; const searchResult = task.searchResults[resultIndex]; const libraryID = task.item.libraryID; ztoolkit.log(`start translate for search result: ${searchResult.title}`); let translatedItems: Zotero.Item[] = []; try { switch (searchResult.source) { case "CNKI": ztoolkit.log("translated by CNKI"); translatedItems = await cnki.translate( searchResult, libraryID, false, ); break; // case "PubScholar": // ztoolkit.log("translated by PubScholar"); // newItem = await pubscholar.translate(task, false); // break; case "中华医学": ztoolkit.log("translated by Yiigle"); translatedItems = await yiigle.translate( searchResult, libraryID, false, ); break; default: break; } ztoolkit.log(translatedItems); } catch (e) { ztoolkit.log(`Translation error: ${e}`); task.addMsg(`Translation error: ${e}`); } if (translatedItems.length === 1) { // if (addon.data.env != "development") const translatedItem = await globalItemFix(task.item, translatedItems[0]); if (task.type == "attachment") { task.item.parentID = translatedItem.id; } else if (task.type == "snapshot") { if (task.item.isTopLevelItem()) { ztoolkit.log("Translate snapshot item for webpage item"); const tmpJSON = translatedItem.toJSON(); task.item.fromJSON(tmpJSON); await translatedItem.eraseTx(); } else { ztoolkit.log("Translate snapshot attachment item"); const oldParentItem = task.item.parentItem!; const collectionIDs = oldParentItem.getCollections(); task.item.parentID = translatedItem.id; // When parent item is erased, the attachment item will be erased. Set new parent item before the old parent will be earsed. await task.item.saveTx(); await oldParentItem.eraseTx(); translatedItem.setCollections(collectionIDs); await translatedItem.saveTx(); } } await task.item.saveTx(); task.status = "success"; } else if (translatedItems.length > 1) { task.addMsg( `Multiple items (${translatedItems.length}) translated, please check details.`, ); task.status = "fail"; } else { task.addMsg("Translation error"); task.status = "fail"; } } catch (e) { task.addMsg(`ERROR: ${e}`); task.status = "fail"; } } // Need to update data in item returned by translator. async function globalItemFix( oldItem: Zotero.Item, newItem: Zotero.Item, ): Promise { if (Zotero.Prefs.get("extensions.zotero.automaticTags", true)) { // Keyword tag type is automatic. ztoolkit.log("update auto tags"); newItem.setTags( newItem.getTags().map((t: { tag: string; type?: number }) => ({ tag: t.tag, type: 1, })), ); } else { // Remove automatic tags ztoolkit.log("remove all tags"); newItem.removeAllTags(); } // Preserve collections oldItem.getCollections().forEach((cid) => newItem!.addToCollection(cid)); await newItem.saveTx(); return newItem; } ================================================ FILE: src/modules/services/pubscholar.ts ================================================ import { requestDocument } from "../../utils/http"; import { DocTools, text2HTMLDoc } from "../../utils/http"; import { ScraperTask } from "../../utils/task"; const BASE_URL = "https://pubscholar.cn"; /** * Parse search results from PubScholar response. */ function parseSearchResults(doc: Document): ScrapeSearchResult[] { // TODO: Update selector based on actual PubScholar page structure const resultRows = doc.querySelectorAll(".result-item"); if (resultRows.length === 0) { ztoolkit.log("PubScholar: no items found."); return []; } return Array.from(resultRows).map((r) => { const dt = new DocTools(r as HTMLElement); // TODO: Update selectors to match PubScholar's HTML structure const title = dt.innerText(".result-title") || ""; const url = dt.attr(".result-title a", "href") || ""; const author = dt.innerText(".result-author") || ""; const source = dt.innerText(".result-source") || ""; const date = dt.innerText(".result-date") || ""; return { source: "PubScholar", title: `${title} ${author} ${source} ${date}`, url: url.startsWith("http") ? url : `${BASE_URL}${url}`, date: Zotero.Date.strToISO(date) || "", }; }); } /** * Build a Zotero item from PubScholar detail page metadata. */ async function createItemFromMetadata( metadata: Record, libraryID: number, ): Promise { // TODO: Map PubScholar metadata fields to Zotero item fields // Example: // const item = new Zotero.Item("journalArticle"); // item.libraryID = libraryID; // item.setField("title", metadata.title); // item.setField("date", metadata.date); // ... // await item.saveTx(); // return item; return null; } export class PubScholar implements ScrapeService { async search( searchOption: SearchOption, ): Promise { ztoolkit.log("PubScholar search options: ", searchOption); let query = searchOption.title; if (searchOption.author) { query += ` ${searchOption.author}`; } // TODO: Implement PubScholar search API call // Step 1: Build search URL and parameters const searchUrl = `${BASE_URL}/api/search`; // Step 2: Send HTTP request // const resp = await Zotero.HTTP.request("POST", searchUrl, { // headers: { // "Content-Type": "application/json", // "User-Agent": "Mozilla/5.0 ...", // }, // body: JSON.stringify({ q: query, page: 1, pageSize: 20 }), // timeout: 10000, // }); // Step 3: Parse response // const doc = text2HTMLDoc(resp.responseText); // const results = parseSearchResults(doc); // return results.length > 0 ? results : null; return null; } async translate( searchResult: ScrapeSearchResult, libraryID: number, saveAttachments: false, ): Promise { ztoolkit.log(`PubScholar translate: ${searchResult.title}`); // TODO: Implement PubScholar translation // Strategy 1: Use Zotero Web Translator if a matching translator exists // try { // const doc = await requestDocument(searchResult.url, { // headers: { ... }, // }); // const translator = new Zotero.Translate.Web(); // translator.setTranslator("TRANSLATOR_ID"); // translator.setDocument(doc); // const items = await translator.translate({ // libraryID: task.item.libraryID, // saveAttachments: saveAttachments, // }); // if (items.length === 1) return items[0]; // } catch (e) { // ztoolkit.log(`PubScholar web translation failed: ${e}`); // } // Strategy 2: Fetch metadata from detail page and build item manually // const metadata = await this.fetchDetailMetadata(searchResult.url); // return createItemFromMetadata(metadata, task.item.libraryID); return []; } } ================================================ FILE: src/modules/services/yiigle.ts ================================================ import { compareTwoStrings } from "string-similarity"; import { DocTools, requestDocument } from "../../utils/http"; const { HiddenBrowser } = ChromeUtils.importESModule( "chrome://zotero/content/HiddenBrowser.mjs", ); export class Yiigle implements ScrapeService { async search( searchOption: SearchOption, ): Promise { ztoolkit.log("Yiigle search started."); const url = `https://www.yiigle.com/Paper/Search?type=&q=${encodeURIComponent(searchOption.title)}&searchType=pt`; ztoolkit.log("Yiigle search URL: " + url); // @ts-ignore not typed const browser = new HiddenBrowser(); const extractArticleData = (node: HTMLElement): ScrapeSearchResult => { const dt = new DocTools(node); const title = dt.attr("a[title].el-link--default", "title"); const url = dt.attr('a[href*="rs.yiigle.com/cmaid/"]', "href"); // 3. 提取引用量(兼容PC/移动端DOM结构) const citation = parseInt(dt.innerText("span > samp", 2)) || 0; // 4. 提取articleID(从URL末尾截取数字) const articleIDMatch = url.match(/\/(\d+)$/); // 匹配 /xxx 最后一段的数字 const articleID = articleIDMatch ? articleIDMatch[1] : ""; // 期刊类型 const jtype = dt.innerText( "div.s_searchResult_li_top.el-row.el-row--flex > span.w_span.hidden-sm-and-down:not([style*='display: none'])", 0, ); // 作者等信息 const infoText = dt .innerText("div.s_searchResult_li_author.el-row", 0) .replaceAll("\n", ""); // 返回标准化对象 const result: ScrapeSearchResult = { source: "中华医学", title: ` ${jtype} ${title} ${infoText}`, url: url, articleID: articleID, articleTitle: title, }; if (citation > 0) { result.citation = citation; } return result; }; try { await browser.load(url); await browser.waitForDocument({ allowInteractiveAfter: 5000 }); setTimeout(() => { ztoolkit.log("1秒延迟到了!"); }, 1000); const doc = await browser.getDocument(); ztoolkit.log(`Yiigle search document title: ${doc.title}`); const items = doc.querySelectorAll("div.s_searchResult_li.el-row"); ztoolkit.log(`Yiigle search: found ${items.length} items.`, items); if (items.length === 0) { ztoolkit.log("Yiigle search: no results found."); return null; } else { return Array.from(items).map((item) => extractArticleData(item as HTMLElement), ); } } catch (error) { ztoolkit.log("Yiigle search error: " + error); } finally { browser.destroy(); } return null; } async translate( searchResult: ScrapeSearchResult, libraryID: number, saveAttachments: false, ): Promise { ztoolkit.log("Yiigle translate started."); const doc = await requestDocument(searchResult.url, { headers: { Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Encoding": "gzip, deflate, br", "Accept-Language": "zh-CN,en-US;q=0.9,en;q=0.8", Referer: "https://www.yiigle.com/", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0", }, }); ztoolkit.log(`Document title: ${doc.title}`); const translator = new Zotero.Translate.Web(); translator.setTranslator("f5189d31-18ea-4e84-bdec-f1d0e75b818b"); translator.setDocument(doc); const translatedItems = await translator.translate({ libraryID: libraryID, saveAttachments: saveAttachments, }); return translatedItems; } } ================================================ FILE: src/modules/styles.ts ================================================ import { get } from "http"; import { getString } from "../utils/locale"; import { findWindow, observeWindowLoad, waitElmLoaded } from "../utils/window"; import { setDashPattern } from "pdf-lib"; function injectToDocument(doc: Document) { const labelId = "zotero-chinese-styles-link"; // Already injected if (doc.getElementById(labelId)) { ztoolkit.log("Chinese styles link already injected"); return; } function injectToParent() { // ztoolkit.log("Injecting Chinese styles link to preferences"); waitElmLoaded(doc, "#styleManager-buttons", 8000).then(() => { // ztoolkit.log("Preferences loaded, injecting link"); const firstChild = doc.querySelector( "#styleManager-buttons > :nth-child(1)", ); const secondChild = doc.querySelector( "#styleManager-buttons > :nth-child(2)", ); // ztoolkit.log(firstChild?.tagName); if (!firstChild || !secondChild) return; if (firstChild.tagName === "button") { const hbox_copy = secondChild .querySelector("hbox")! .cloneNode(true) as HTMLElement; hbox_copy .querySelector("label")! .setAttribute("value", getString("get-Chinese-styles")); const button = doc.createElement("button"); button.style.padding = "0px"; button.id = labelId; button.setAttribute("label", getString("get-Chinese-styles")); button.addEventListener("click", function (event) { Zotero.launchURL("https://zotero-chinese.com/styles/"); event.preventDefault(); }); button.appendChild(hbox_copy); secondChild.insertAdjacentElement("beforebegin", button); } else if (firstChild.tagName === "label") { // For Zotero 7 const label = doc.createElement("label"); label.id = labelId; label.classList.add("zotero-text-link"); label.setAttribute("is", "zotero-text-link"); label.setAttribute("role", "link"); label.textContent = getString("get-Chinese-styles"); label.addEventListener("click", function (event) { Zotero.launchURL("https://zotero-chinese.com/styles/"); event.preventDefault(); }); firstChild.removeAttribute("flex"); firstChild.style.marginRight = "12px"; firstChild.insertAdjacentElement("afterend", label); } ztoolkit.log("Chinese styles link injected"); }); } const isCitePaneSelected = doc.querySelector( "richlistitem[value='zotero-prefpane-cite'][selected='true']", ); // If cite pane is selected, insert immediately if (isCitePaneSelected) { injectToParent(); } else { const navigation = doc.getElementById("prefs-navigation"); if (!navigation) return; function onSelect(event: Event) { // Inject link only one time in window lifetime navigation!.removeEventListener("select", onSelect); injectToParent(); } navigation.addEventListener("select", onSelect); } } /** * Inject a link to the Chinese styles page into the preferences window. */ export async function injectStylesLink() { const prefsUri = "chrome://zotero/content/preferences/preferences.xhtml"; const existingWindow = findWindow(prefsUri); if (existingWindow) { injectToDocument(existingWindow.document); } // Wait for preference window loaded next time observeWindowLoad(prefsUri, (win) => injectToDocument(win.document)); } ================================================ FILE: src/modules/tools.ts ================================================ import { config } from "../../package.json"; import { isChineseTopItem } from "./../utils/detect"; import { getString } from "../utils/locale"; import { getPref } from "../utils/prefs"; import { CNKI } from "../modules/services/cnki"; import { findAttachmentsInFolder } from "./attachments/localMatch"; import { actionAfterImport } from "./attachments"; // 中国稀有姓氏统计小组发布于小红书ID4975028282 // https://www.xiaohongshu.com/discovery/item/67c017cb000000001203db3d const compoundSurnames = [ /* A */ "奥屯", /* B */ "百里", "比干", "单于", /* C */ "陈留", "成公", "成功", "叱干", "褚师", "淳于", /* D */ "达奚", "第二", "第五", "第伍", "第一", "丁若", "东方", "东里", "东门", "东野", "豆卢", "独孤", "端木", "段干", /* E */ "尔朱", /* F */ "伏羲", "状阳", "傅阳", /* G */ "高堂", "高阳", "哥舒", "葛天", "公乘", "公上", "公孙", "公羊", "公冶", "共工", "古野", "关龙", "毌丘", /* H */ "韩城", "贺兰", "贺楼", "贺若", "赫连", "呼延", "胡母", "胡毋", "斛律", "华原", "皇甫", "皇父", /* K */ "可汗", /* J */ "即墨", "夹谷", "揭阳", /* L */ "令狐", "闾丘", "闾邱", /* M */ "马服", "万矣", "墨台", "默台", "母丘", "木易", "慕容", /* N */ "南宫", "南门", "女娲", /* O */ "欧侯", "欧阳", /* P */ "濮阳", "蒲察", /* Q */ "漆雕", "亓官", "綦连", "綦毋", "气伏", "青阳", "屈男", "屈突", /* S */ "上官", "申徒", "申屠", "石抹", "士孙", "侍其", "水丘", "司城", "司空", "司寇", "司马", "司徒", "司星", "澹台", /* T */ "拓跋", "太史", "太叔", "徒单", "涂山", "脱脱", /* W */ "完颜", "闻人", "武城", "毋丘", /* X */ "西门", "夏侯", "夏后", "鲜于", "相里", "轩辕", /* Y */ "延陵", "羊舌", "耶律", "宇文", "尉迟", "乐正", /* Z */ "宰父", "长孙", "钟离", "诸葛", "术虎", "主父", "祝融", "颛孙", "颛项", "子车", "宗正", "宗政", /* 璧联姓 */ "邓李", "刘付", "陆费", "吴刘", ]; export async function splitName(item: Zotero.Item): Promise { const creators = item.getCreators(); for (const creator of creators) { if (creator.fieldMode === 0 && creator.firstName !== "") continue; if ( /\p{Unified_Ideograph}/u.test(`${creator.lastName}${creator.firstName}`) ) { const fullName = creator.lastName; const surname = compoundSurnames.find((surname) => creator.lastName.startsWith(surname), ); if (fullName.includes("·")) { const nameParts = fullName.split("·"); creator.lastName = nameParts.shift()!; creator.firstName = nameParts.join("·"); } else if (surname) { creator.lastName = surname; creator.firstName = fullName.slice(surname.length); } else { creator.lastName = fullName.charAt(0); creator.firstName = fullName.slice(1); } creator.fieldMode = 0; } else if (getPref("splitEnName") && /[a-z]/i.test(creator.lastName)) { const nameParts = creator.lastName.split(/\s+/g); if (nameParts.length > 1) { creator.lastName = nameParts.pop()!; creator.firstName = nameParts.join(" "); creator.fieldMode = 0; } } } item.setCreators(creators); await item.saveTx(); } export async function mergeName(item: Zotero.Item): Promise { const creators = item.getCreators(); for (const creator of creators) { if ( /\p{Unified_Ideograph}/u.test(`${creator.firstName}${creator.lastName}`) ) { if ( // Chinese Name in One field. creator.fieldMode === 1 && creator.lastName.length - 2 === creator.lastName.indexOf(" ") ) { creator.lastName = creator.lastName.split(" ").reverse().join(""); } else { // 由于拆分后信息丢失,难以判断少数民族的姓氏,这里的条件是充分不必要的 const delimiter = creator.firstName.includes("·") ? "·" : ""; creator.lastName = `${creator.lastName}${delimiter}${creator.firstName}`; } creator.firstName = ""; creator.fieldMode = 1; } else if (getPref("splitEnName") && /[a-z]/i.test(creator.lastName)) { creator.lastName = `${creator.firstName} ${creator.lastName}`.trimStart(); creator.firstName = ""; creator.fieldMode = 1; } } item.setCreators(creators); await item.saveTx(); } export async function getCNKICite(item: Zotero.Item): Promise { const cnki = new CNKI(); const searchOption = { title: item.getField("title"), author: item.getCreators()[0].lastName + item.getCreators()[0].firstName, }; let cite = ""; const searchResults = await cnki.search(searchOption); if (searchResults && searchResults.length > 0) { cite = searchResults[0].citation as string; ztoolkit.log(`CNKI citation: ${cite}`); if (cite) { ztoolkit.ExtraField.setExtraField(item, "CNKICite", cite); } } return cite; } export async function updateCNKICite(items: Zotero.Item[]) { const items2 = items.filter((i) => isChineseTopItem(i)); if (items2.length > 0) { let popupWin; for (let i = 0; i < items2.length; i++) { const cite = await getCNKICite(items2[i]); if (i == 0) { popupWin = new ztoolkit.ProgressWindow(config.addonName, { closeOnClick: true, closeTime: 1500, }) .createLine({ text: `${getString("citation")}:${cite ? cite : "0"} ${items[i].getField("title")}`, type: "default", icon: `chrome://${config.addonRef}/content/icons/cite.png`, }) .show(); } else { popupWin?.changeLine({ text: `${getString("citation")}:${cite ? cite : "0"} ${items[i].getField("title")}`, type: "default", icon: `chrome://${config.addonRef}/content/icons/cite.png`, }); } } } else { ztoolkit.log("No Chinese items to update citation."); new ztoolkit.ProgressWindow(config.addonName, { closeOnClick: true, closeTime: 3500, }) .createLine({ text: getString("no-chinese-item-for-citation"), type: "default", icon: `chrome://${config.addonRef}/content/icons/cite.png`, }) .show(); } } async function renameAttachmentFromParent(attachmentItem: Zotero.Item) { if ( !attachmentItem.isAttachment() || attachmentItem.isTopLevelItem() || attachmentItem.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_URL ) { throw `Item ${attachmentItem.id} is not a child file attachment in ZoteroPane_Local.renameAttachmentFromParent()`; } const filePath = await attachmentItem.getFilePathAsync(); if (!filePath) return; const parentItemID = attachmentItem.parentItemID as number; const parentItem = await Zotero.Items.getAsync(parentItemID); let newName = Zotero.Attachments.getFileBaseNameFromItem(parentItem); const extRE = /\.[^.]+$/; const origFilename = PathUtils.filename(filePath); const ext = origFilename.match(extRE); if (ext) { newName = newName + ext[0]; } const origFilenameNoExt = origFilename.replace(extRE, ""); const renamed = await attachmentItem.renameAttachmentFile( newName, false, true, ); if (renamed !== true) { ztoolkit.log(`Could not rename file (${renamed})`); } // If the attachment title matched the filename, change it now const origTitle = attachmentItem.getField("title"); if ([origFilename, origFilenameNoExt].includes(origTitle)) { attachmentItem.setField("title", newName); await attachmentItem.saveTx(); } } export async function importAttachmentsFromFolder(): Promise { let msgType = "default"; let msg: string = ""; if (addon.data.isImportingAttachments) { Zotero.getMainWindow().alert(getString("importing-attachments-is-running")); return; } try { const folder = getPref("pdfMatchFolder"); const collectionID = Zotero.getActiveZoteroPane().getSelectedCollection()!.id; const attachmentFilenames = await findAttachmentsInFolder(folder); ztoolkit.log(collectionID, attachmentFilenames); if (attachmentFilenames.length === 0) { msg = getString("no-attachments-found"); msgType = "success"; } else { for (const filename of attachmentFilenames) { const importOptions: _ZoteroTypes.Attachments.OptionsFromFile = { collections: [collectionID], file: filename, }; await Zotero.Attachments.importFromFile(importOptions); ztoolkit.log(`${filename} imported.`); await actionAfterImport(filename); } msg = getString("import-attachments-success"); msgType = "success"; } } catch (e) { ztoolkit.log(e); msg = String(e); msgType = "fail"; } finally { addon.data.isImportingAttachments = false; new ztoolkit.ProgressWindow(config.addonName, { closeOnClick: true, closeTime: 1500, }) .createLine({ text: msg, type: msgType, icon: `chrome://${config.addonRef}/content/icons/icon.png`, }) .show(); } } /** * 分类或选中的条目查找附件,从本地或远程下载。 * 注意,此处会过滤掉已有附件的条目。 * TODO: Exclude some attachment file types. */ export async function handleAttachmentMenu(menuType: "collection" | "item") { let selectedItems: Zotero.Item[] = []; if (menuType === "item") { selectedItems = Zotero.getActiveZoteroPane().getSelectedItems(); } else if (menuType === "collection") { const collectionID = Zotero.getActiveZoteroPane().getSelectedCollection()?.id; if (!collectionID) return; selectedItems = Zotero.Collections.get(collectionID).getChildItems(); } else { return; } const targetItemTypes = [ "journalArticle", "thesis", "book", "bookSection", "conferencePaper", "report", "patent", ]; const noAttachmentItems = selectedItems.filter((item) => { if (!item.isRegularItem() || !targetItemTypes.includes(item.itemType)) return false; // Exclude snapshot attachments const aItems = item .getAttachments() .filter((i) => !Zotero.Items.get(i).isSnapshotAttachment()); ztoolkit.log(aItems); return aItems.length === 0; }); if (noAttachmentItems.length === 0) { new ztoolkit.ProgressWindow(config.addonName, { closeOnClick: true, closeTime: 1500, }) .createLine({ text: getString("no-item-need-attachment"), type: "default", icon: `chrome://${config.addonRef}/content/icons/cite.png`, }) .show(); } else { for (const item of noAttachmentItems) { await addon.taskRunner.createAndAddTask(item, "local"); } } } ================================================ FILE: src/modules/translators.ts ================================================ import { getString } from "../utils/locale"; import { getPref, setPref } from "../utils/prefs"; export async function bestSpeedBaseUrl() { const baseUrls = [ "https://oss.wwang.de/translators_CN", "https://www.wieke.cn/translators_CN", "https://ftp.zotero-chinese.com/translators_CN", ]; const testUrl = async ( url: string, ): Promise<{ url: string; time: number }> => { const startTime = Date.now(); try { await Zotero.HTTP.request("HEAD", `${url}/data/translators.json`, { timeout: 5000, }); const time = Date.now() - startTime; ztoolkit.log(`${url} response time: ${time}ms`); return { url, time }; } catch (error) { ztoolkit.log(`${url} request failed: ${error}`); return { url, time: Infinity }; } }; const results = await Promise.all(baseUrls.map(testUrl)); const fastest = results.reduce((prev, curr) => curr.time < prev.time ? curr : prev, ); ztoolkit.log(`use fastest base url: ${fastest.url} (${fastest.time}ms)`); return fastest.url; } /** * Get lastUpdated time from translator file * @param filename translator filename with extension * @returns lastUpdated time or false if failed */ export async function getLastUpdatedFromFile( filename: string, ): Promise { const desPath = PathUtils.join( Zotero.DataDirectory.dir, "translators", filename, ); const isFileExist = await IOUtils.exists(desPath); if (isFileExist === false) { ztoolkit.log(`get lastUpdated from file ${desPath} failed: file not exist`); return false; } try { // Assert source is a string in try block const source = (await Zotero.File.getContentsAsync(desPath)) as string; const infoRe = /^\s*{[\S\s]*?}\s*?[\r\n]/; const metaData = JSON.parse(infoRe.exec(source)![0]); ztoolkit.log( `get lastUpdated from file ${desPath}: ${metaData.lastUpdated}`, ); return metaData.lastUpdated; } catch (error) { ztoolkit.log(`get lastUpdated from file ${desPath} failed: ${error}`); return false; } } export async function getLastUpdatedMap( refresh = true, ): Promise { const cachePath = PathUtils.join( Zotero.DataDirectory.dir, "translators_CN.json", ); if (refresh === false && (await IOUtils.exists(cachePath))) { const contents = await Zotero.File.getContentsAsync(cachePath, "utf8"); ztoolkit.log(`translator data has been loaded from cache: ${cachePath}`); return JSON.parse(contents as string); } try { const baseUrl = getPref("translatorSource"); const contents = await Zotero.File.getContentsFromURLAsync( `${baseUrl}/data/translators.json`, ); ztoolkit.log(`translator data has been loaded from remote: ${baseUrl}`); await Zotero.File.putContentsAsync(cachePath, contents); return JSON.parse(contents); } catch (event) { ztoolkit.log(`getTranslatorsData failed: ${event}`); return {}; } } async function mendTranslators() { // Detect Endnote XML translator, if it's missing, it means the translators are broken, try to reset them. // Return False if missing. const endNoteTranslator = await Zotero.Translators.get( "eb7059a4-35ec-4961-a915-3cf58eb9784b", ); // 727 is the number of translators at the time of writing if ( !getPref("firstRun") && !getPref("translatorsMended") && !endNoteTranslator ) { ztoolkit.log( "jasminum has been installed, and translators seems to be missing, try to reset them", ); const reset = await Zotero.Schema.resetTranslators(); ztoolkit.log(`reset translators ${reset ? "successfully" : "failed"}`); setPref("translatorsMended", true); } } /** * Download outdated translators from the source, with 12 hours interval by default. * * TODO: Download error when file is read-only in windows. * @param force Whether ignore the time interval and force to download */ export async function updateTranslators(force = false): Promise { if (addon.data.translators.updating) { ztoolkit.log("translators are updating, skip this update"); return false; } try { addon.data.translators.updating = true; return await _updateTranslators(force); } catch (error) { return false; } finally { addon.data.translators.updating = false; } } async function _updateTranslators(force = false): Promise { await Zotero.Schema.schemaUpdatePromise; await mendTranslators(); let needUpdate = false; const lastUpdateTime = parseInt(getPref("translatorUpdateTime")); const now = Date.now(); if (force == true || lastUpdateTime === undefined) { ztoolkit.log( `need to update translators, force: ${force}, lastUpdateTime: ${lastUpdateTime}`, ); needUpdate = true; } else { if (now - lastUpdateTime > 1000 * 60 * 60 * 12) { ztoolkit.log( "need to update translators, it has been over 12 hours since the last update", ); needUpdate = true; } else { ztoolkit.log( "no need to update translators, it has been less than 12 hours since the last update", ); } } if (needUpdate === false) return false; const translatorData = await getLastUpdatedMap(needUpdate); const baseUrl = getPref("translatorSource"); ztoolkit.log(`update translators from base: ${baseUrl}`); const popupWin = new ztoolkit.ProgressWindow(getString("plugin-name"), { closeOnClick: true, closeTime: -1, }) .createLine({ text: getString("update-translators-start"), type: "default", progress: 0, }) .show(); const progressStep = 100 / Object.keys(translatorData).length; let progress = 0; let successCounts = 0; let skipCounts = 0; let failCounts = 0; const translatorUpdateTasks = Object.keys(translatorData).map( async (filename) => { let type = "default", text = ""; const localUpdateTime = await getLastUpdatedFromFile(filename); const remoteUpdateTime = translatorData[filename].lastUpdated; if ( localUpdateTime === false || new Date(remoteUpdateTime) > new Date(localUpdateTime) ) { try { const url = `${baseUrl}/${filename}`; const code = await Zotero.File.getContentsFromURLAsync(url); const desPath = PathUtils.join( Zotero.DataDirectory.dir, "translators", filename, ); await IOUtils.writeUTF8(desPath, code); type = "success"; text = getString("update-successfully", { args: { name: filename }, }); successCounts += 1; } catch (error) { type = "fail"; text = getString("update-failed", { args: { name: filename }, }); failCounts += 1; ztoolkit.log(`update translator ${filename} failed: ${error}`); } } else { skipCounts += 1; type = "default"; text = getString("update-skipped", { args: { name: filename }, }); ztoolkit.log(`translator ${filename} is already up to date, skipped`); } progress += progressStep; popupWin.changeLine({ type, text, progress, }); }, ); await Promise.all(translatorUpdateTasks); // @ts-ignore Translators is missing await Zotero.Translators.reinit({ fromSchemaUpdate: false }); setPref("translatorUpdateTime", now.toString()); popupWin.changeLine({ text: getString("update-translators-complete", { args: { successCounts, failCounts, skipCounts }, }), type: "default", progress: 100, }); popupWin.startCloseTimer(3000); ztoolkit.log( `translators updated at ${new Date(now)}, success: ${successCounts}, skip: ${skipCounts}, fail: ${failCounts}`, ); return true; } ================================================ FILE: src/modules/workers/index.ts ================================================ import { test, addOutlineToPDF } from "./outline"; self.onmessage = async (e) => { console.log("Minimal Worker收到:", e.data); const data = e.data; if (data && data.action === "test") { const result = test(data.title); self.postMessage({ action: "testReturn", jobID: data.jobID, status: "success", result, }); } else if (data && data.action === "addOutline") { const { filePath, outlineNodes } = data; await addOutlineToPDF(filePath, outlineNodes); self.postMessage({ action: "addOutlineReturn", jobID: data.jobID, status: "success", }); } }; ================================================ FILE: src/modules/workers/outline.ts ================================================ import { PDFArray, PDFDict, PDFDocument, PDFHexString, PDFName, PDFNull, PDFNumber, PDFPageLeaf, PDFRef, } from "pdf-lib"; export function test(title: string) { const startTimestamp = Date.now(); let result = title; for (let i = 0; i < 100; i++) { result = result .split("") .map((c) => String.fromCharCode(c.charCodeAt(0) + 1)) .join(""); } const endTimestamp = Date.now(); const time = endTimestamp - startTimestamp; return { result, time }; } function prepareData( outlineNodes: OutlineNode[], pdfDoc: PDFDocument, ): [OutlineNode[], number] { let counts = 0; outlineNodes.forEach((node: OutlineNode) => { node.ref = pdfDoc.context.nextRef(); if (node.children && node.children.length > 0) { node.children = prepareData(node.children, pdfDoc)[0]; counts = counts + 1; } }); return [outlineNodes, counts]; } function createOutlineItem( pdfDoc: PDFDocument, node: OutlineNode, parentRef: PDFRef, prev: PDFRef | null, next: PDFRef | null, page: PDFRef, ) { const outlineItemDictMap = new Map(); outlineItemDictMap.set(PDFName.Title, PDFHexString.fromText(node.title)); outlineItemDictMap.set(PDFName.Parent, parentRef); if (node.children && node.children.length > 0) { outlineItemDictMap.set(PDFName.of("First"), node.children[0].ref); outlineItemDictMap.set( PDFName.of("Last"), node.children[node.children.length - 1].ref, ); outlineItemDictMap.set( PDFName.of("Count"), PDFNumber.of(node.children.length), ); } if (prev != null) { outlineItemDictMap.set(PDFName.of("Prev"), prev); } if (next != null) { outlineItemDictMap.set(PDFName.of("Next"), next); } // Set the destination const array = PDFArray.withContext(pdfDoc.context); array.push(page); array.push(PDFName.of("XYZ")); array.push(PDFNumber.of(node.x)); // X array.push(PDFNumber.of(node.y)); // Y array.push(PDFNull); // Zoom outlineItemDictMap.set(PDFName.of("Dest"), array); const outlineItem = PDFDict.fromMapWithContext( outlineItemDictMap, pdfDoc.context, ); pdfDoc.context.assign(node.ref, outlineItem); console.log(`Outline item dict: ${node.level}, ${node.title}`); } function createOutlineDict( outlineNodes: OutlineNode[], counts: number, pdfDoc: PDFDocument, ): PDFDict { const outlinesDictMap = new Map(); outlinesDictMap.set(PDFName.Type, PDFName.of("Outlines")); outlinesDictMap.set(PDFName.of("First"), outlineNodes[0].ref!); outlinesDictMap.set( PDFName.of("Last"), outlineNodes[outlineNodes.length - 1].ref!, ); outlinesDictMap.set(PDFName.of("Count"), PDFNumber.of(counts)); return PDFDict.fromMapWithContext(outlinesDictMap, pdfDoc.context); } export async function addOutlineToPDF( pdfPath: string, outlineNodes: OutlineNode[], ) { const pdfBytes = await IOUtils.read(pdfPath); const pdfDoc = await PDFDocument.load(pdfBytes); // PDF Page reference const pageRefs: PDFRef[] = []; pdfDoc.catalog.Pages().traverse((kid, ref) => { if (kid instanceof PDFPageLeaf) pageRefs.push(ref); }); const rootRef = pdfDoc.context.nextRef(); const [preparedOutlineNodes, totalCounts] = prepareData(outlineNodes, pdfDoc); // Create outline item dict const outlinesDict = createOutlineDict( preparedOutlineNodes, totalCounts, pdfDoc, ); //Pointing the "Outlines" property of the PDF's "Catalog" to the first object of your outlines pdfDoc.catalog.set(PDFName.of("Outlines"), rootRef); //First 'Outline' object. Refer to table H.3 in Annex H.6 of PDF Specification doc. pdfDoc.context.assign(rootRef, outlinesDict); console.log("Prepared outline nodes: ", preparedOutlineNodes); // Add outline item dict const loop = (nodes: OutlineNode[]) => { nodes.forEach((node: OutlineNode, idx: number) => { // Create outline item dict createOutlineItem( pdfDoc, node, node.level === 1 ? rootRef : node.ref, idx > 0 ? nodes[idx - 1].ref : null, idx < nodes.length - 1 ? nodes[idx + 1].ref : null, pageRefs[node.page - 1], ); if (node.children && node.children.length > 0) { const children = node.children; loop(children); } }); }; loop(preparedOutlineNodes); const pdfBytesWithOutline = await pdfDoc.save(); await IOUtils.write(pdfPath, pdfBytesWithOutline); console.log("Add outline to pdf complete."); } ================================================ FILE: src/modules/wps.ts ================================================ async function unZip(filename: string, outDir: string) { ztoolkit.log(outDir, filename); const zipFile = Zotero.File.pathToFile(filename); // @ts-ignore -- Not typed. const zipReader = Components.classes[ "@mozilla.org/libjar/zip-reader;1" ].createInstance(Components.interfaces.nsIZipReader); zipReader.open(zipFile); // Extract files const entries = zipReader.findEntries("*"); const subfolders = new Set(); const entryFiles: any = {}; while (entries.hasMore()) { const entry = entries.getNext(); // Unix Mac Windows, path seperator. const pathParts = entry.split(/[/\\]/); if (pathParts.length > 1) subfolders.add(PathUtils.join(outDir, pathParts.slice(0, -1))); if (entry.endsWith("/") || entry.endsWith("\\")) { continue; } entryFiles[entry] = PathUtils.join(outDir, pathParts); } for (const e of subfolders) { ztoolkit.log("Create subfolder: " + e); await IOUtils.makeDirectory(e, { ignoreExisting: true }); ztoolkit.log(`${await IOUtils.exists(e)}`); } Object.keys(entryFiles).forEach((e) => { ztoolkit.log(e, entryFiles[e]); zipReader.extract(e, Zotero.File.pathToFile(entryFiles[e])); }); zipReader.close(); } export async function downloadWpsPlugin() { const baseDir = PathUtils.join(Zotero.DataDirectory.dir, "jasminum"); const wpsFolder = PathUtils.join(baseDir, "wps"); const unzipFolder = PathUtils.join(wpsFolder, "unzip"); const zipFilename = PathUtils.join(wpsFolder, "wps.zip"); await IOUtils.makeDirectory(unzipFolder, { ignoreExisting: true, createAncestors: true, }); const wpsUrl = "https://ftp.linxingzhong.top/"; const tmpContent = await Zotero.File.getContentsFromURLAsync(wpsUrl); await Zotero.File.putContentsAsync(zipFilename, tmpContent); ztoolkit.log("WPS plugins download complete"); await unZip(zipFilename, unzipFolder); ztoolkit.log("Unzip completed. " + unzipFolder); } export async function installWpsPlugin() { let runStatus: true | Error; if (Zotero.isWin) { runStatus = await Zotero.Utilities.Internal.exec("安装.exe", []); } else { runStatus = await Zotero.Utilities.Internal.exec("python", ["install.py"]); } if (runStatus == true) { ztoolkit.log("Install completed."); } else { ztoolkit.log("Install errors", runStatus); } } ================================================ FILE: src/utils/cookiebox.ts ================================================ export class MyCookieSandbox { public searchCookieBox: Zotero.CookieSandbox | null = null; // public attachmentCookieBox: Zotero.CookieSandbox | null = null; // public refCookieBox: Zotero.CookieSandbox | null = null; userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36"; baseUrl = "https://www.cnki.net"; private _CNKIHomeCookieBox: Zotero.CookieSandbox | null = null; private _cnkiHomeCookieLastUpdateTime: number = 0; private _initPromise: Promise | null = null; private _captchaPromise: Promise | null = null; private static readonly COOKIE_EXPIRE_MS = 5 * 60 * 1000; // 10 minutes constructor() { this._CNKIHomeCookieBox = null; } public async getCookieBoxFromUrl( url: string, hintText: string = "请完成验证码,验证成功后,点击此按钮", ): Promise { // @ts-ignore - Not typed. const cookieSandbox = new Zotero.CookieSandbox(); ztoolkit.log("Opening URL in viewer: " + url); const win = Zotero.openInViewer(url, { cookieSandbox: cookieSandbox, }) as any as Window; return new Promise((resolve, reject) => { let promiseSettled = false; let cookieRetrieved = false; win.addEventListener("close", function () { ztoolkit.log("Window closed"); if (!promiseSettled) { promiseSettled = true; if (cookieRetrieved) { ztoolkit.log("Cookie sandbox returned successfully"); resolve(cookieSandbox); } else { ztoolkit.log("Window closed without retrieving cookies"); reject(new Error(`用户关闭窗口,未完成验证: ${url}`)); } } }); win.addEventListener("load", function () { ztoolkit.log("Window loaded, adding button"); const buttonContainer = ztoolkit.UI.createElement(win.document, "box", { namespace: "html", attributes: { id: "captcha-button-container" }, styles: { position: "fixed", top: "10px", right: "10px", zIndex: "10000", padding: "15px", backgroundColor: "white", border: "3px solid red", borderRadius: "8px", boxShadow: "0 4px 8px rgba(0,0,0,0.3)", cursor: "pointer", userSelect: "none", transition: "left 0.3s ease, right 0.3s ease", }, }); let isOnRight = true; const titleLabel = ztoolkit.UI.createElement(win.document, "label", { namespace: "html", attributes: { value: "茉莉花提示:" }, styles: { fontWeight: "bold", color: "black", fontSize: "14px", marginBottom: "5px", display: "block", }, }); const hintLabel = ztoolkit.UI.createElement( win.document, "description", { namespace: "html", properties: { textContent: hintText }, styles: { color: "black", fontSize: "12px", marginBottom: "10px", lineHeight: "1.5", maxWidth: "250px", whiteSpace: "normal", wordWrap: "break-word", }, }, ); const positionHint = ztoolkit.UI.createElement( win.document, "description", { namespace: "html", properties: { textContent: "(双击此框可切换左右位置)" }, styles: { color: "#666", fontSize: "10px", marginBottom: "8px", fontStyle: "italic", }, }, ); const button = ztoolkit.UI.createElement(win.document, "button", { namespace: "html", properties: { textContent: "确认完成验证" }, styles: { fontSize: "12px", padding: "4px", cursor: "pointer", backgroundColor: "#4CAF50", background: "#4CAF50", color: "black", border: "none", borderRadius: "5px", width: "50%", fontWeight: "bold", }, }); button.addEventListener("mouseover", function () { if (!button.disabled) { button.style.backgroundColor = "#45a049"; button.style.background = "#45a049"; } }); button.addEventListener("mouseout", function () { if (!button.disabled) { button.style.backgroundColor = "#4CAF50"; button.style.background = "#4CAF50"; } }); button.addEventListener("click", function () { try { const uri = Services.io.newURI(url); const cookies = cookieSandbox.getCookiesForURI(uri); ztoolkit.log("Cookies retrieved from sandbox.", cookies); if (cookies) { for (const name in cookies) { ztoolkit.log(` ${name} = ${cookies[name]}`); } cookieRetrieved = true; if (!promiseSettled) { promiseSettled = true; resolve(cookieSandbox); ztoolkit.log("Promise resolved with cookieSandbox"); } win.close(); ztoolkit.log("Cookies retrieved successfully."); } else { ztoolkit.log("未找到 cookies"); button.setAttribute("label", "✗ 未找到 Cookie"); button.style.backgroundColor = "#f44336"; button.style.color = "white"; hintLabel.textContent = "未找到 Cookie,请确保已完成验证"; hintLabel.style.color = "#f44336"; } } catch (e: any) { ztoolkit.log("获取 cookie 时出错: " + e); button.setAttribute("label", "✗ 出错了"); button.style.backgroundColor = "#f44336"; button.style.color = "white"; hintLabel.textContent = "出错了: " + e.message; hintLabel.style.color = "#f44336"; } }); buttonContainer.appendChild(titleLabel); buttonContainer.appendChild(hintLabel); buttonContainer.appendChild(positionHint); buttonContainer.appendChild(button); buttonContainer.addEventListener("dblclick", function (e) { if ( e.target === button || (e.target as HTMLElement).closest("button") ) { return; } if (isOnRight) { buttonContainer.style.right = "auto"; buttonContainer.style.left = "10px"; isOnRight = false; ztoolkit.log("Button moved to left"); } else { buttonContainer.style.left = "auto"; buttonContainer.style.right = "10px"; isOnRight = true; ztoolkit.log("Button moved to right"); } }); const browserBox = win.document.getElementById("browser"); if (browserBox) { browserBox.appendChild(buttonContainer); } else { win.document.documentElement.appendChild(buttonContainer); } ztoolkit.log("Button with position toggle added successfully"); }); }); } public async getCNKIHomeCookieBox(): Promise { const now = Date.now(); const isExpired = now - this._cnkiHomeCookieLastUpdateTime > MyCookieSandbox.COOKIE_EXPIRE_MS; // If cookie exists and not expired, return directly if (this._CNKIHomeCookieBox != null && !isExpired) { return this._CNKIHomeCookieBox; } // Cookie expired or missing, reset for re-initialization if (isExpired && this._CNKIHomeCookieBox != null) { ztoolkit.log("CNKI Home cookie expired, re-initializing..."); this._CNKIHomeCookieBox = null; this._initPromise = null; } if (!this._initPromise) { ztoolkit.log("homeCookieBox 为空,开始初始化..."); this._initPromise = this.getCookieBoxFromUrl( "https://kns.cnki.net/kns8s/defaultresult/index?crossids=YSTT4HG0%2CLSTPFY1C%2CJUP3MUPD%2CMPMFIG1A%2CWQ0UVIAA%2CBLZOG7CK%2CPWFIRAGL%2CEMRPGLPA%2CNLBO1Z6R%2CNN3FJMUV&korder=SU&kw=%E7%A7%91%E7%A0%94%E8%AE%BA%E6%96%87%E9%98%85%E8%AF%BB", "请等待知网网页正常打开后,再点击下方按钮关闭", ).then((cookieSandbox) => { this._CNKIHomeCookieBox = cookieSandbox; this._cnkiHomeCookieLastUpdateTime = Date.now(); }); } await this._initPromise; // 保险起见,再次检查是否成功获取到 cookieSandbox if (this._CNKIHomeCookieBox == null) { ztoolkit.log("homeCookieBox 还是为空,又开始初始化..."); this._CNKIHomeCookieBox = await this.getCookieBoxFromUrl( "https://kns.cnki.net/kns8s/defaultresult/index?crossids=YSTT4HG0%2CLSTPFY1C%2CJUP3MUPD%2CMPMFIG1A%2CWQ0UVIAA%2CBLZOG7CK%2CPWFIRAGL%2CEMRPGLPA%2CNLBO1Z6R%2CNN3FJMUV&korder=SU&kw=%E7%A7%91%E7%A0%94%E8%AE%BA%E6%96%87%E9%98%85%E8%AF%BB", "请等待知网网页正常打开后,再点击下方按钮关闭", ); this._cnkiHomeCookieLastUpdateTime = Date.now(); } return this._CNKIHomeCookieBox!; } async passCaptchaToCookieBox( url: string, cookieType: | "CNKI:Search" | "CNKI:Attachment" | "CNKI:Reference" | "CNKI:Home", ): Promise { // 如果已经有验证码窗口在运行,等待它完成 if (this._captchaPromise) { ztoolkit.log( "Captcha window is already running, waiting for it to complete...", ); return this._captchaPromise; } this._captchaPromise = this.getCookieBoxFromUrl(url).then( (cookieSandbox) => { // 根据 cookieType 设置对应的 cookieSandbox switch (cookieType) { case "CNKI:Home": addon.data.myCookieSandbox._CNKIHomeCookieBox = cookieSandbox; addon.data.myCookieSandbox._cnkiHomeCookieLastUpdateTime = Date.now(); break; // 其他类型... } ztoolkit.log("Cookies passed to addon CookieSandbox."); return cookieSandbox; }, ); // 在 Promise 完成后清空,无论成功还是失败 this._captchaPromise.finally(() => { this._captchaPromise = null; ztoolkit.log("Captcha promise cleared, ready for next captcha request"); }); return this._captchaPromise; } } ================================================ FILE: src/utils/detect.ts ================================================ // 这里有许多类型判断,判断不同的条目类型 /** * 主要检测知网等其他数据库下载的附件文件名是否至少有3个汉字 * Created by DeepSeek * @param filename * @returns */ const CHINESE_FILENAME_REGEX = /^(?=(.*?\p{Unified_Ideograph}){3})(?=(.*\p{Unified_Ideograph}){3}).+\.(pdf|caj|kdh|nh)$/iu; export function isChineseAttachmentFilename(filename: string): boolean { return CHINESE_FILENAME_REGEX.test(filename); } /** * Return true when item is a top level Chinese PDF/CAJ item. */ export function isChineseTopAttachment(item: Zotero.Item): boolean { return ( item.isAttachment() && item.isTopLevelItem() && isChineseAttachmentFilename(item.attachmentFilename) ); } /** * 检测是否是中文的顶层条目 * @param item * @returns */ export function isChineseTopItem(item: Zotero.Item): boolean { return ( item.isRegularItem() && item.isTopLevelItem() && /\p{Unified_Ideograph}/iu.test(item.getField("title")) ); } /** * CNKI Snapshot attachment item,注意是附件条目 * CNKI Webpage top level item. 注意是网页类型条目 * @param item * @returns */ export function isChinsesSnapshot(item: Zotero.Item): boolean { return ( (item.isSnapshotAttachment() && item.getField("title").includes("- 中国知网")) || (item.isTopLevelItem() && item.itemType == "webpage" && item.getField("title").includes("- 中国知网")) ); } ================================================ FILE: src/utils/http.ts ================================================ function jsonToFormUrlEncoded(json: any) { return Object.keys(json) .map( (key) => encodeURIComponent(key) + "=" + encodeURIComponent( typeof json[key] === "object" ? JSON.stringify(json[key]) : json[key], ), ) .join("&"); } async function requestDocument( url: string, options?: { method?: string; body?: string; headers?: any; responseType?: string; responseCharset?: string; successCodes?: number[] | false; cookieSandbox?: Zotero.CookieSandbox; }, ): Promise { const xhr = await Zotero.HTTP.request(options?.method || "GET", url, { ...options, responseType: "document", }); let doc = xhr.response; if (doc && !doc.location) { doc = Zotero.HTTP.wrapDocument(doc, xhr.responseURL); } return doc; } function text2HTMLDoc(text: string, url?: string): Document { let doc = new DOMParser().parseFromString(text, "text/html"); if (url) { doc = Zotero.HTTP.wrapDocument(doc, url); } return doc; } // Detect user is in mainland China. // Except 中国台湾,中国香港,中国澳门 async function isMainlandChina(): Promise { const mainlandChina = [ "浙江省", "江苏省", "广东省", "山东省", "河南省", "四川省", "湖北省", "河北省", "湖南省", "安徽省", "辽宁省", "福建省", "陕西省", "黑龙江省", "吉林省", "山西省", "江西省", "云南省", "贵州省", "内蒙古自治区", "广西壮族自治区", "西藏自治区", "宁夏回族自治区", "新疆维吾尔自治区", "北京市", "天津市", "上海市", "重庆市", ]; const html = await requestDocument("https://ip.chinaz.com/", { method: "GET", }); const targets = Zotero.Utilities.xpath( html, "//div[contains(text(), '您的本机IP地址')]", ); if (targets.length > 0) { const targetContent = targets[0].textContent; return mainlandChina.some((p) => targetContent?.includes("归属地:" + p)); } return true; } /** * A simple HTML selector and attribute extractor. */ class DocTools { private node: Document | Element; constructor(node: Document | Element) { this.node = node; } attr(selector: string, attr: string, index?: number): string { const elm = this.choose(selector, index); return elm && elm.hasAttribute(attr) ? elm.getAttribute(attr)!.trim() : ""; } text(selector: string, index?: number): string { const elm = this.choose(selector, index); return elm && elm.textContent ? elm.textContent!.trim() : ""; } innerText(selector: string, index?: number): string { const elm = this.choose(selector, index); return elm && elm.textContent ? elm.textContent.trim() : ""; } choose(selector: string, index?: number): Element | null { if (index === undefined) { return this.node.querySelector(selector); } else { const items = this.node.querySelectorAll(selector); if (index >= 0) { return items.item(index); } else { return items.item(items.length + index); } } } } export { requestDocument, jsonToFormUrlEncoded, isMainlandChina, DocTools, text2HTMLDoc, }; ================================================ FILE: src/utils/locale.ts ================================================ import { config } from "../../package.json"; export { initLocale, getString, getLocaleID }; /** * Initialize locale data */ function initLocale() { const l10n = new ( typeof Localization === "undefined" ? ztoolkit.getGlobal("Localization") : Localization )([`${config.addonRef}-addon.ftl`], true); addon.data.locale = { current: l10n, }; } /** * Get locale string, see https://firefox-source-docs.mozilla.org/l10n/fluent/tutorial.html#fluent-translation-list-ftl * @param localString ftl key * @param options.branch branch name * @param options.args args * @example * ```ftl * # addon.ftl * addon-static-example = This is default branch! * .branch-example = This is a branch under addon-static-example! * addon-dynamic-example = { $count -> [one] I have { $count } apple *[other] I have { $count } apples } * ``` * ```js * getString("addon-static-example"); // This is default branch! * getString("addon-static-example", { branch: "branch-example" }); // This is a branch under addon-static-example! * getString("addon-dynamic-example", { args: { count: 1 } }); // I have 1 apple * getString("addon-dynamic-example", { args: { count: 2 } }); // I have 2 apples * ``` */ function getString(localString: string): string; function getString(localString: string, branch: string): string; function getString( localeString: string, options: { branch?: string | undefined; args?: Record }, ): string; function getString(...inputs: any[]) { if (inputs.length === 1) { return _getString(inputs[0]); } else if (inputs.length === 2) { if (typeof inputs[1] === "string") { return _getString(inputs[0], { branch: inputs[1] }); } else { return _getString(inputs[0], inputs[1]); } } else { throw new Error("Invalid arguments"); } } function _getString( localeString: string, options: { branch?: string | undefined; args?: Record } = {}, ): string { const localStringWithPrefix = `${config.addonRef}-${localeString}`; const { branch, args } = options; const pattern = addon.data.locale?.current.formatMessagesSync([ { id: localStringWithPrefix, args }, ])[0]; if (!pattern) { return localStringWithPrefix; } if (branch && pattern.attributes) { for (const attr of pattern.attributes) { if (attr.name === branch) { return attr.value; } } return pattern.attributes[branch] || localStringWithPrefix; } else { return pattern.value || localStringWithPrefix; } } function getLocaleID(id: string) { return `${config.addonRef}-${id}`; } ================================================ FILE: src/utils/pattern.ts ================================================ export function getArgsFromPattern( filename: string, pattern: string, ): SearchOption | null { // Make query parameters from filename const prefix = filename .replace(/\.\w+$/, "") // 删除文件后缀 .replace(/\.ashx$/g, "") // 删除末尾.ashx字符 .replace(/^_|_$/g, "") // 删除前后的下划线 .replace(/[((]\d+[))]$/, "") // 删除重复下载时文件名出现的数字编号 (1) (1) .trim(); // 当文件名模板为"{%t}_{%g}",文件名无下划线_时,将文件名认定为标题 if (pattern === "{%t}_{%g}" && !prefix.includes("_")) { return { author: "", title: prefix, }; } const patternSepArr: string[] = pattern.split(/{%[^}]+}/); const patternSepRegArr: string[] = patternSepArr.map((x) => x.replace(/([[^$.|?*+()])/g, "\\$&"), ); const patternMainArr: string[] | null = pattern.match(/{%[^}]+}/g); //文件名中的作者姓名字段里不能包含下划线,请使用“&,,”等字符分隔多个作者,或仅使用第一个作者名(加不加“等”都行)。 const patternMainRegArr = patternMainArr!.map((x) => x.replace( /.+/, /{%y}/.test(x) ? "(\\d+)" : /{%g}/.test(x) ? "([^_]+)" : "(.+)", ), ); const regStrInterArr = patternSepRegArr.map((_, i) => [ patternSepRegArr[i], patternMainRegArr[i], ]); const patternReg = new RegExp( // eslint-disable-next-line prefer-spread [].concat .apply([], regStrInterArr as never) .filter(Boolean) .join(""), "g", ); const prefixMainArr = patternReg.exec(prefix); // 文件名识别结果为空,跳出警告弹窗 if (prefixMainArr === null) { return null; } const titleIdx = patternMainArr!.indexOf("{%t}"); const authorIdx = patternMainArr!.indexOf("{%g}"); const titleRaw = titleIdx != -1 ? prefixMainArr[titleIdx + 1] : ""; const authors = authorIdx != -1 ? prefixMainArr[authorIdx + 1] : ""; const authorArr = authors.split(/[,,&]/); let author = authorArr[0]; if (authorArr.length == 1) { //删除名字后可能出现的“等”字,此处未能做到识别该字是否属于作者姓名。 //这种处理方式的问题:假如作者名最后一个字为“等”,例如:“刘等”,此时会造成误删。 //于是对字符数进行判断,保证删除“等”后,至少还剩两个字符,尽可能地避免误删。 author = author.endsWith("等") && author.length > 2 ? author.substring(0, author.length - 1) : author; } //为了避免文件名中的标题字段里存在如下两种情况而导致的搜索失败: //原标题过长,文件名出现“_省略_”; //原标题有特殊符号(如希腊字母、上下标)导致的标题变动,此时标题也会出现“_”。 //于是只取用标题中用“_”分割之后的最长的部分作为用于搜索的标题。 //这种处理方式的问题:假如“最长的部分”中存在知网改写的部分,也可能搜索失败。 //不过这只是理论上可能存在的情形,目前还未实际遇到。 let title: string; // Zotero.debug(titleRaw); // if (/_/.test(titleRaw)) { // //getLongestText函数,用于拿到字符串数组中的最长字符 // //摘自https://stackoverflow.com/a/59935726 // const getLongestText = (arr) => arr.reduce( // (savedText, text) => (text.length > savedText.length ? text : savedText), // '', // ); // title = getLongestText(titleRaw.split(/_/)); // } else { // title = titleRaw; // } // 去除_省略_ "...", 多余的 _ 换为空格 // 标题中含有空格,查询时会启用模糊模式 title = titleRaw.replace("_省略_", " ").replace("...", " "); title = title.replace(/_/g, " "); return { author: author, title: title, }; } ================================================ FILE: src/utils/pdfParser.ts ================================================ async function getPDFTitle(itemID: number): Promise { // @ts-ignore - PDFWorker is not typed const recognizerData = await Zotero.PDFWorker.getRecognizerData(itemID, true); ztoolkit.log("recognizerData: ", debugDoc(recognizerData)); const pdfData = recognizerDataToPdfData(recognizerData); ztoolkit.log("pdfData: ", pdfData); const docType = detectDocType(pdfData); ztoolkit.log("docType: ", docType); /* * 更好的做法是,仅将属性名称语义化的 PDF 数据传递给 get*() 函数, * 由函数内部根据各文献类型的排版特点对数据进行重新组织。 */ switch (docType) { case "article": return getArticleTitle(pdfData); case "thesis": return getThesisTitle(pdfData); } return ""; } function isValidTitle(line: PdfParagraph): boolean { return ( line.classList.length === 0 && line.text.length > 3 && hasCJK(line.text) ); } function getThesisTitle(data: PdfData): string { const contextLine = findParagraphInPagesReversed(data.pages, (pages) => findParagraphAfter(pages.paragraphs, keyPatterns.thesis["bfore-title"]), ); const maxSizeLine = findMaxSizeParagraph( data.pages.flatMap((page) => page.paragraphs), ); return (contextLine?.text ?? maxSizeLine?.text ?? "").replace( /^(论文)?(颗|题)目((.+?))?:?/, "", ); } function getArticleTitle(data: PdfData): string { let mainPage = data.pages[0]; if (/《.+》网络首发论文/.test(mainPage.text)) { ztoolkit.log("CNKI advanced online article"); mainPage = data.pages[1]; } return (findMaxSizeParagraph(mainPage.paragraphs)?.text ?? "").replace( new RegExp(`[${footnoteMarkers}]+$`), "", ); } function findParagraphInPages( pages: PdfPage[], finder: ( page: PdfPage, index: number, pages: PdfPage[], ) => PdfParagraph | undefined, ): PdfParagraph | undefined { for (let i = pages.length - 1; i >= 0; i--) { const paragraph = finder(pages[i], i, pages); if (paragraph !== undefined) { return paragraph; } } return undefined; } function findParagraphInPagesReversed( pages: PdfPage[], finder: ( page: PdfPage, index: number, pages: PdfPage[], ) => PdfParagraph | undefined, ): PdfParagraph | undefined { for (let i = 0; i < pages.length; i++) { const paragraph = finder(pages[i], i, pages); if (paragraph !== undefined) { return paragraph; } } return undefined; } function findParagraphAfter(paragraphs: PdfParagraph[], patterns: RegExp[]) { return paragraphs.findLast((paragraph, index, paragraphs) => { const anchorParagraph: PdfParagraph | undefined = paragraphs[index - 1]; return ( isValidTitle(paragraph) && anchorParagraph && patterns.some((regexp) => regexp.test(anchorParagraph.text)) ); }); } function findParagraphBefore(paragraphs: PdfParagraph[], patterns: RegExp[]) { return paragraphs.find((paragraph, index, paragraphs) => { const anchorParagraph: PdfParagraph | undefined = paragraphs[index + 1]; return ( isValidTitle(paragraph) && anchorParagraph && patterns.some((regexp) => regexp.test(anchorParagraph.text)) ); }); } function findMaxSizeParagraph(paragraphs: PdfParagraph[]) { let candidateParagraph: PdfParagraph | undefined; for (const paragraph of paragraphs) { if (isValidTitle(paragraph)) { if ( !candidateParagraph || parseFloat(paragraph.fontSize) > parseFloat(candidateParagraph.fontSize) ) { candidateParagraph = paragraph; } } } return candidateParagraph; } function detectDocType(data: PdfData): DocType { const hitsCounter = { article: 0, thesis: 0, book: 0, }; if (data.totalPages > 10) { pageLoop: for (const page of data.pages) { for (const paragraph of page.paragraphs) { if (breakMarks.some((regexp) => regexp.test(paragraph.text))) { ztoolkit.log("stop at paragraph: ", paragraph.text); break pageLoop; } typeLoop: for (const key in docTypePatterns) { const docType = key as DocType; for (const pattern of docTypePatterns[docType]) { if (pattern.test(paragraph.text)) { ztoolkit.log(paragraph.text, "hits", pattern); hitsCounter[docType]++; if (hitsCounter[docType] > 3) { return docType; } break typeLoop; } } } } } } ztoolkit.log(hitsCounter); return Object.values(hitsCounter).some((count) => count > 0) ? Object.keys(hitsCounter) .map((key) => key as DocType) .reduce((a, b) => (hitsCounter[a] > hitsCounter[b] ? a : b)) : "article"; } function sortLines(lines: PdfLine[]): PdfLine[] { return lines.sort((lineA, lineB) => { if (lineA.baseline == lineB.baseline) { return lineA.xMin - lineB.xMin; } return lineA.baseline - lineB.baseline; }); } function recognizerDataToPdfData(data: RecognizerData): PdfData { return { metadata: data.metadata, totalPages: data.totalPages, pages: data.pages.map((page) => recognizerPageToPdfPage(page)), }; } function recognizerPageToPdfPage(page: RecognizerPage): PdfPage { const lines = page[2][0][0][0][4].map(recognizerLineToPdfLine); // 这里会将双烂排序的段落打乱,甚至将尾注混合在正文段落中,但我们不关心这部分信息。 // 以后如果需要获取更细粒度的信息,需要对行盒子的位置关系进行比较进行多次分组和排序。 // 我已经对此进行了测试,但复杂度和性能都不太好。 // 考虑AI识别的普遍应用,将来可能会有更好的解决方案。 const paragraphs = pdfLinesToPdfParagraphs(sortLines(lines)); return { width: page[0], height: page[1], text: paragraphs.map((paragraph) => paragraph.text).join("\n"), classList: [], paragraphs, }; } function pdfLinesToPdfParagraphs(lines: PdfLine[]): PdfParagraph[] { const paragraphs: PdfParagraph[] = []; for (let i = 0; i < lines.length; i++) { const preLine = lines[i - 1]; const curLine = lines[i]; const paragraph = paragraphs.at(-1); if (!paragraph) { paragraphs.push({ fontSize: curLine.fontSize, text: curLine.text, classList: [], lines: [curLine], }); } else { const fontSizeEqual = preLine.fontSize === curLine.fontSize; const semanticCoherence = /\S([、,:~,:&]|—{1,2})$/iu.test(preLine.text) || /^—{1,2}\S/iu.test(curLine.text); function typographicConsistency() { function getFontIndexies(words: PdfWord[]) { return Array.from(new Set(words.map((word) => word.fontIndex))); } if (hasCJK(preLine.text) && hasCJK(curLine.text)) { const preFontIndexies = getFontIndexies( preLine.words.filter((word) => hasCJK(word.text)), ); const curFontIndexies = getFontIndexies( curLine.words.filter((word) => hasCJK(word.text)), ); return curFontIndexies.some((fontIndex) => preFontIndexies.includes(fontIndex), ); } else if (!hasCJK(preLine.text) && !hasCJK(curLine.text)) { const preFontIndexies = getFontIndexies( preLine.words.filter((word) => !hasCJK(word.text)), ); const curFontIndexies = getFontIndexies( curLine.words.filter((word) => !hasCJK(word.text)), ); return curFontIndexies.some((fontIndex) => preFontIndexies.includes(fontIndex), ); } return false; } if ((fontSizeEqual || semanticCoherence) && typographicConsistency()) { paragraph.lines.push(curLine); } else { paragraph.fontSize = preLine.fontSize; let text = ""; if (paragraph.lines.length === 1) { text = paragraph.lines[0].text; } else if (paragraph.lines.length > 1) { paragraph.lines.reduce((pre, cur) => { let delimiter = ""; const wordAndWord = /\w$/.test(pre.text) && /^\w/.test(cur.text); const punctuationAndWord = /[!$%&\]);:,.>]$/iu.test(pre.text) && /^\w/.test(cur.text); if (wordAndWord || punctuationAndWord) { delimiter = " "; } text += `${pre.text}${delimiter}${cur.text}`; return cur; }); } paragraph.text = text; paragraphs.push({ fontSize: curLine.fontSize, text: curLine.text, classList: [], lines: [curLine], }); } } } return paragraphs; } function recognizerLineToPdfLine(line: RecognizerLine): PdfLine { const words = line[0].map(recognizerWordToPdfWord); return pdfWordsToPdfLine(words); } function pdfWordsToPdfLine(words: PdfWord[]): PdfLine { const CJKWords = words.filter((word) => hasCJK(word.text)); const fontSize = average( CJKWords.length ? CJKWords : words, (word) => word.fontSize, ).toFixed(2); return { xMin: Math.min(...words.map((word) => word.xMin)), yMin: Math.min(...words.map((word) => word.yMin)), xMax: Math.max(...words.map((word) => word.xMax)), yMax: Math.max(...words.map((word) => word.yMax)), fontSize, baseline: average(words, (word) => word.baseline), text: normalizeText( words.map((word) => `${word.text}${word.spaceAfter ? " " : ""}`).join(""), ), words, }; } function recognizerWordToPdfWord(word: RecognizerWord): PdfWord { return { xMin: word[0], yMin: word[1], xMax: word[2], yMax: word[3], fontSize: word[4], spaceAfter: Boolean(word[5]), baseline: word[6], rotation: Boolean(word[7]), underlined: Boolean(word[8]), bold: Boolean(word[9]), italic: Boolean(word[10]), colorIndex: word[11], fontIndex: word[12], text: word[13], }; } function average(arr: T[], callback: (arg: T) => number): number { return arr.reduce((sum, cur) => sum + callback(cur), 0) / arr.length; } function hasCJK(str: string) { return /\p{Unified_Ideograph}/u.test(str); } function xnor(input1: boolean, input2: boolean) { return (input1 && input2) || (!input1 && !input2); } function debugDoc(data: RecognizerData) { function parsePage(page: RecognizerPage) { return page[2][0][0][0][4].map((line) => { return line[0].map((word) => { return { xMin: word[0], yMin: word[1], xMax: word[2], yMax: word[3], fontSize: word[4], spaceAfter: Boolean(word[5]), baseline: word[6], rotation: Boolean(word[7]), underlined: Boolean(word[8]), bold: Boolean(word[9]), italic: Boolean(word[10]), colorIndex: word[11], fontIndex: word[12], text: word[13], }; }); }); } const pages = data.pages.map(parsePage); return pages.map((page) => page.map((line) => ({ fontSize: average(line, (word) => word.fontSize).toFixed(2), text: line .map((word) => `${word.text}${word.spaceAfter ? " " : ""}`) .join(""), baseLine: average(line, (word) => word.baseline), })), ); } const breakMarks = [ // 地址 /关键词[::]/, /^(?((\d*\.)?\s*[\p{Unified_Ideograph},;\s]+\d{6}\b)+)?/u, /^[[【〔[]]?收稿日期/, /^[[【〔[]]?\**通(信|讯)作者/, /(原|独)创性声明$/, /使用授权(书|声明)$/, /^目录$/, /^(中文)?摘要$/, ]; const keyPatterns: { [type in DocType]: { [className: string]: RegExp[]; }; } = { article: {}, thesis: { "before-title": [ /(?\p{Unified_Ideograph}*((硕|博)士)?(研究生)?.*([学宇字]位|毕业)论文)?$/u, /^(?\p{Unified_Ideograph}*(([硕博][士±])?(专业|[学宇字]术)|([硕博][士|±])(专业|[学宇字]术)?)[学宇字]位)?$/u, /^(?\p{Unified_Ideograph}*博士后研究工作报告)?$/u, // 陕西师范大学《气候变化和人类活动对祁连山草地演变影响程度的研究》 /^((专业|[学宇字]术)型)$/, // 广西师范学院《农户耕地撂荒影响因素研究》 /^论文题目([((]中英文[))])$/, ], }, book: {}, }; function patternsInType(type: DocType): RegExp[] { return Object.values(keyPatterns[type]).flat(); } const docTypePatterns: { [type in DocType]: RegExp[]; } = { article: [], thesis: [ ...patternsInType("thesis"), /(([学宇字]|院)校|单位)代码/, /保?密等?级/, /[学宇字]号/, /(研究生|([学宇字]位)?申请人)(姓名)?/, /所在([学宇字]院|单位)|培养单位/, /(指导教师|导师)(姓名)?/, /(专业|[学宇字]科)(领域)?(名称)?/, /(论文)?(答辩|提交|完成)(日期|时间)/, /答辩委员会/, ], book: [], }; const letterShapeMap = { /* Uppercase letters */ A: "A", B: "B", C: "C", D: "D", E: "E", F: "F", G: "G", H: "H", I: "I", J: "J", K: "K", L: "L", M: "M", N: "N", O: "O", P: "P", Q: "Q", R: "R", S: "S", T: "T", U: "U", V: "V", W: "W", X: "X", Y: "Y", Z: "Z", /* Lowercase letters */ a: "a", b: "b", c: "c", d: "d", e: "e", f: "f", g: "g", h: "h", i: "i", j: "j", k: "k", l: "l", m: "m", n: "n", o: "o", p: "p", q: "q", r: "r", s: "s", t: "t", u: "u", v: "v", w: "w", x: "x", y: "y", z: "z", /* Arabic numerals */ "0": "0", "1": "1", "2": "2", "3": "3", "4": "4", "5": "5", "6": "6", "7": "7", "8": "8", "9": "9", }; const footnoteMarkers = "**∗●Δ①②③④⑤⑥⑦⑧⑨➀➁➃➄➅➆➇➈"; function normalizeText(str: string) { str = Zotero.Utilities.trimInternal(str); for (const fullChar in letterShapeMap) { str = str.replace( new RegExp(fullChar, "g"), letterShapeMap[fullChar as keyof typeof letterShapeMap], ); } return ( str // eslint-disable-next-line no-control-regex .replace(/[\x00-\x1F\x7F\p{Private_Use}]/gu, "") .replace(/\s?([\p{Unified_Ideograph}—-])\s?/gu, "$1") .trim() ); } export { getPDFTitle }; ================================================ FILE: src/utils/prefs.ts ================================================ import { config } from "../../package.json"; export type PluginPrefsMap = _ZoteroTypes.Prefs["PluginPrefsMap"]; const PREFS_PREFIX = config.prefsPrefix; /** * Get preference value. * Wrapper of `Zotero.Prefs.get`. * @param key */ export function getPref(key: K) { return Zotero.Prefs.get(`${PREFS_PREFIX}.${key}`, true) as PluginPrefsMap[K]; } /** * Set preference value. * Wrapper of `Zotero.Prefs.set`. * @param key * @param value */ export function setPref( key: K, value: PluginPrefsMap[K], ) { return Zotero.Prefs.set(`${PREFS_PREFIX}.${key}`, value, true); } /** * Clear preference value. * Wrapper of `Zotero.Prefs.clear`. * @param key */ export function clearPref(key: string) { return Zotero.Prefs.clear(`${PREFS_PREFIX}.${key}`, true); } ================================================ FILE: src/utils/task.ts ================================================ import { metaSearch, metaTranslate } from "../modules/services"; import { getString } from "./locale"; import { attachmentSearch, importAttachment } from "../modules/attachments"; import { version } from "../../package.json"; // 创建 Deferred 的工厂函数 function createDeferred(): DeferredResult { let resolve!: (value: T) => void; let reject!: (reason?: any) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } export class ScraperTask implements ScraperTask { public id: string; public item: Zotero.Item; public type: ScraperTaskType; public message?: string; public silent?: false; public deferred?: DeferredResult; public resultIndex?: 0; private _status: TaskStatus; private _searchResults: ScrapeSearchResult[] = []; constructor(item: Zotero.Item, type: ScraperTaskType, silent?: false) { this.id = Zotero.Utilities.Internal.md5(item.id.toString()); this.item = item; this.type = type; this.silent = false; this._status = "waiting"; } // 添加消息的方法(不需要通过代理) addMsg(message: string) { if (this.message) { this.message = this.message + "\n" + message; } else { this.message = message; } } // 使用 setter 处理属性变更 set status(newStatus: TaskStatus) { const oldStatus = this._status; // if (oldStatus === newStatus) return; this._status = newStatus; ztoolkit.log( `task ${this.id} changes "status" from "${oldStatus}" to "${newStatus}"`, ); addon.data.progress.updateTaskStatus(this, newStatus); if (newStatus === "multiple_results") { this.deferred = createDeferred(); } } get status(): TaskStatus { return this._status; } set searchResults(results: ScrapeSearchResult[]) { this._searchResults = results; ztoolkit.log("searchResult changed"); if (results && results.length > 1) { addon.data.progress.updateTaskSearchResult(this, results); } } get searchResults() { return this._searchResults; } } export class AttachmentTask implements AttachmentTask { public id: string; public item: Zotero.Item; public type: AttachmentTaskType; public message?: string; public silent?: false; public deferred?: DeferredResult; public resultIndex?: 0; private _status: TaskStatus; private _searchResults: AttachmentSearchResult[] = []; constructor(item: Zotero.Item, type: AttachmentTaskType, silent?: false) { this.id = Zotero.Utilities.Internal.md5(item.id.toString()); this.item = item; this.type = type; this.silent = false; this._status = "waiting"; } // 添加消息的方法(不需要通过代理) addMsg(message: string) { if (this.message) { this.message = this.message + "\n" + message; } else { this.message = message; } } // 使用 setter 处理属性变更 set status(newStatus: TaskStatus) { const oldStatus = this._status; // if (oldStatus === newStatus) return; this._status = newStatus; ztoolkit.log( `task ${this.id} changes "status" from "${oldStatus}" to "${newStatus}"`, ); addon.data.progress.updateTaskStatus(this, newStatus); if (newStatus === "multiple_results") { this.deferred = createDeferred(); } } get status(): TaskStatus { return this._status; } set searchResults(results: AttachmentSearchResult[]) { this._searchResults = results; ztoolkit.log("searchResult changed"); if (results && results.length > 1) { addon.data.progress.updateTaskSearchResult(this, results); } } get searchResults() { return this._searchResults; } } export class TaskRunner { public runningTask: AttachmentTask | ScraperTask | null = null; public tasks: (AttachmentTask | ScraperTask)[] = []; getTaskType( task: AttachmentTask | ScraperTask | string, ): "metaScraper" | "attachmentScraper" { let taskType: string; if (typeof task === "string") { taskType = task; } else { taskType = task.type; } if (taskType == "attachment" || taskType == "snapshot") { return "metaScraper"; } else if (taskType == "local" || taskType == "remote") { return "attachmentScraper"; } else { throw new Error(`Unknown task type: ${taskType}`); } } createTask( item: Zotero.Item, type: ScraperTaskType | AttachmentTaskType, silent?: false, ): ScraperTask | AttachmentTask { const taskType = this.getTaskType(type); let task: ScraperTask | AttachmentTask; if (taskType === "attachmentScraper") { task = new AttachmentTask(item, type as AttachmentTaskType, silent); } else if (taskType === "metaScraper") { task = new ScraperTask(item, type as ScraperTaskType, silent); } else { throw new Error(`Unknown task type: ${type}`); } // Set the default index for silent tasks // If the task is silent, set the resultIndex to 0 if (silent) { task.resultIndex = 0; } return task; } async addTask( task: AttachmentTask | ScraperTask, ): Promise { if (this.getTaskById(task.id)) { ztoolkit.log(`Task with ID ${task.id} already exists.`); if (addon.data.progress.progressWindow) { addon.data.progress.progressWindow.alert( getString("task-already-exists", { args: { title: task.item.getField("title") }, }), ); } return; } this.tasks.push(task); await addon.data.progress.addTaskToProgressWindow(task); ztoolkit.log(`Task with ID ${task.id} added.`); await this.runTask(task); return task.id; } async createAndAddTask( item: Zotero.Item, type: ScraperTaskType | AttachmentTaskType, silent?: false, ): Promise { const task = this.createTask(item, type, silent); task.addMsg(getString("task-msg-header")); task.addMsg(`Zotero version: ${Zotero.version}`); task.addMsg(`Addon version: ${version}`); await this.addTask(task); return task.id; } getTaskById(id: string): Task | undefined { return this.tasks.find((task) => task.id === id); } async runTask(task: AttachmentTask | ScraperTask): Promise { this.runningTask = task; if (this.getTaskType(task) === "attachmentScraper") { this.runAttachmentTask(task as AttachmentTask); } else { this.runScrapeTask(task as ScraperTask); } this.runningTask = null; } async runScrapeTask(task: ScraperTask): Promise { // Implement the logic to run the scrape task ztoolkit.log(`Running scrape task with ID: ${task.id}`); try { await metaSearch(task); } catch (e) { ztoolkit.log(`Error in metaSearch: ${e}`); task.addMsg(`Error in metaSearch: ${e}`); task.status = "fail"; return; } // Wait for user select result. if (task.status === "multiple_results") { task.resultIndex = await task.deferred?.promise; } if (task.status != "fail") { await metaTranslate(task); } } async runAttachmentTask(task: AttachmentTask): Promise { await attachmentSearch(task); // Wait for user select result. if (task.status === "multiple_results") { task.resultIndex = await task.deferred?.promise; } if (task.status != "fail") { await importAttachment(task); } } resumeTask(taskID: string, resultIndex: number): void { const task = this.getTaskById(taskID); if (task?.deferred) { task.deferred.resolve(resultIndex); } } } ================================================ FILE: src/utils/wait.ts ================================================ /** * Wait until the condition is `true` or timeout. * The callback is triggered if condition returns `true`. * @param condition * @param callback * @param interval * @param timeout */ export function waitUntil( condition: () => boolean, callback: () => void, interval = 100, timeout = 10000, ) { const start = Date.now(); const intervalId = ztoolkit.getGlobal("setInterval")(() => { if (condition()) { ztoolkit.getGlobal("clearInterval")(intervalId); callback(); } else if (Date.now() - start > timeout) { ztoolkit.getGlobal("clearInterval")(intervalId); } }, interval); } /** * Wait async until the condition is `true` or timeout. * @param condition * @param interval * @param timeout */ export function waitUtilAsync( condition: () => boolean, interval = 100, timeout = 10000, ) { return new Promise((resolve, reject) => { const start = Date.now(); const intervalId = ztoolkit.getGlobal("setInterval")(() => { if (condition()) { ztoolkit.getGlobal("clearInterval")(intervalId); resolve(); } else if (Date.now() - start > timeout) { ztoolkit.getGlobal("clearInterval")(intervalId); reject(); } }, interval); }); } ================================================ FILE: src/utils/window.ts ================================================ import { config } from "../../package.json"; import { waitUtilAsync } from "./wait"; /** * Check if the window is alive. * Useful to prevent opening duplicate windows. * @param win */ export function isWindowAlive(win?: Window) { return win && !Components.utils.isDeadWrapper(win) && !win.closed; } /** * Ensures that a given promise resolves within a specified timeout. * If the promise does not resolve within the timeout, it rejects with an error. * @param promise - The promise to wait for. * @param timeout - The maximum time to wait in milliseconds. * @param message - The error message to reject with if the promise does not resolve within the timeout. */ export async function waitNoMoreThan( promise: Promise, timeout: number = 3000, message: string = "Timeout", ) { let resolved = false; return Promise.any([ promise.then((result) => { resolved = true; return result; }), // @ts-ignore - Promise delay is not typed. Zotero.Promise.delay(timeout).then(() => { if (resolved) return; throw new Error(message); }), ]); } export function findWindow(type: string) { const enumerator = Services.wm.getEnumerator(type); if (enumerator.hasMoreElements()) { // In this case, getNext will always return a window const win = enumerator.getNext() as Window; ztoolkit.log(`found window by type: ${type}, ${win.location.href}`); return win; } ztoolkit.log(`not found window by type: ${type}`); return null; } export function observeWindowLoad( uri: string, callback: (win: Window) => unknown, ) { // After the window opens, wait for it to load const loadObserver = function (event: Event) { event.originalTarget?.removeEventListener("load", loadObserver, false); const href = (event.target as Window)?.location.href; ztoolkit.log(`window loaded: ${href}`); if (href != uri) { return; } const win = event.target?.ownerGlobal; // Give window code time to run on load win?.setTimeout(function () { callback(win); }); }; // Ensure that the window is opened before listening for load const winObserver = { observe: function (subject: Window, topic: string, data: any) { if (topic != "domwindowopened") return; subject.addEventListener("load", loadObserver, false); }, } as nsIObserver; Services.ww.registerNotification(winObserver); // Unregister notifier when addon is disabled Zotero.Plugins.addObserver({ shutdown: ({ id }) => { if (id === config.addonID) Services.ww.unregisterNotification(winObserver); }, }); } export async function waitElmLoaded( doc: Document, selector: string, timeout = 10000, ): Promise { return new Promise((resolve, reject) => { waitUtilAsync(() => !!doc.querySelector(selector), 100, timeout) .then(() => { ztoolkit.log(`element ${selector} in ${doc.location.href} loaded`); resolve(true); }) .catch(() => { ztoolkit.log( `timeout waiting for element ${selector} in ${doc.location.href}`, ); reject(false); }); }); } ================================================ FILE: src/utils/ztoolkit.ts ================================================ import { ZoteroToolkit } from "zotero-plugin-toolkit"; import { config } from "../../package.json"; export { createZToolkit }; function createZToolkit() { const _ztoolkit = new ZoteroToolkit(); /** * Alternatively, import toolkit modules you use to minify the plugin size. * You can add the modules under the `MyToolkit` class below and uncomment the following line. */ // const _ztoolkit = new MyToolkit(); initZToolkit(_ztoolkit); return _ztoolkit; } function initZToolkit(_ztoolkit: ReturnType) { const env = __env__; _ztoolkit.basicOptions.log.prefix = `[${config.addonName}]`; _ztoolkit.basicOptions.log.disableConsole = false; _ztoolkit.UI.basicOptions.ui.enableElementJSONLog = true; _ztoolkit.UI.basicOptions.ui.enableElementDOMLog = true; _ztoolkit.basicOptions.debug.disableDebugBridgePassword = __env__ === "development"; _ztoolkit.basicOptions.api.pluginID = config.addonID; _ztoolkit.ProgressWindow.setIconURI( "default", `chrome://${config.addonRef}/content/icons/icon.png`, ); } import { BasicTool, unregister } from "zotero-plugin-toolkit"; import { UITool } from "zotero-plugin-toolkit"; class MyToolkit extends BasicTool { UI: UITool; constructor() { super(); this.UI = new UITool(this); } unregisterAll() { unregister(this); } } ================================================ FILE: test/CNKI_translator_test.js ================================================ urls = [ "https://kns.cnki.net/kcms2/article/abstract?v=WStw-PbchoxXklMd97G3EDrMY35-fvcPWOGNHjK8hkbbqADLh5NGc0AmBzjI4D8ZwjzI0VHODsFQlS8sdwe2eU_tJN9s3hzTpF8GC2jjom6r22HYP5NTbScHRtT5YFMOmmBgOTPkcgh2Bsw--3eXUh2HpUIUCy4q97DM744ETBHcmNUo8g9ZvnNyVq_LgaRN&uniplatform=NZKPT&language=CHS", "https://kns.cnki.net/kcms2/article/abstract?v=WStw-PbchowBkjKe5MjlmHuD-RasNVJ8dt-b6UD3KtSDnt1n7QxJkch8TZaKbJ8-7MQj0xUrYGr09gE16oyAIvXEr-CiiCdkjjgSqFVq36YzC43jvtJ2f8hTOpY1PGy1XXfcCGLSIRM1wzVCGndk4adUcZQZlQHK2d03J4NCfegvuPcn9U-_NhqwCcaqf5w6&uniplatform=NZKPT&language=CHS", "https://kns.cnki.net/kcms2/article/abstract?v=WStw-Pbchowjmw6mw3uKbpWLZ2thd4Ikj9aGW2c6LgczKWCuX2sbJIEMj6bB-0Myb9sYR96tSlb8Gk0R2Z5YBIcrMYuwuydhjwJOEbFIivRXFXmcPpCYR-7eh9QpC0Giq2tNOlOdx3rz-Z-fSV0yg_0xomFu3lvR2KqJ3Zf28JSRHc4XLMIunw9eaiJQVXkZ&uniplatform=NZKPT&language=CHS", "https://kns.cnki.net/kcms2/article/abstract?v=WStw-Pbchozhh8Fgt64WtzqgcMwtTHrBrYlh7Q4yySg7hnJ_gZrfncEA9PcKga4FKUf8K3eYz08N-kJkGU6a490pkki0kvUVkunG1MGAhnigUdsqHsG2wsWHE_qELcwKOaJxO6eY8DTnp1B33RIvWXn7MIYquE-yHHXjmoQOk2vu5RZvFrEQBYLhzKS-EGmA&uniplatform=NZKPT&language=CHS", "https://kns.cnki.net/kcms2/article/abstract?v=WStw-Pbchoza91b55tjwPPpLVRsheZOKpFgcAdm6LDyIObh40SwtF5bPj3e9eAkU_c4S8MDmOIWVqRv5OJ6eq-KGt0sSbL9666NHWAvybhtwrlr-ULB8eGMFZS_h01YjBrmxfsdYY0cXK4SYnSghWoce8plJZitosysJn497tMNlbBu19zoyZ5-G5ms_xGKc&uniplatform=NZKPT&language=CHS", "https://kns.cnki.net/kcms2/article/abstract?v=WStw-Pbchozs-rV1BnuxrpzoDrIrhHINoJIdxyeKIPB5uWktgKly2H6ZJQQUSjRUVoPUCHXoyZLBT2eDcH5MrDol9zfNqvscmrbJfOFiKe2bkCEET1Voc00whH0Bu4xrNDK0j5RUMqKj2JH-EELIoBDNoj1nmj6lcvxK69GmV48jw6V6GBOlfa5grOY_WfBB&uniplatform=NZKPT&language=CHS", "https://kns.cnki.net/kcms2/article/abstract?v=WStw-PbchozAYll_0WbZyNsWw69XWM7MujYLUcTpPk_wHBzcDHl6z8xIFKgbBP9GUZ9aGf3xAFtR4ludtNumAxX85oiU2rxkqQcMorrBsa9mvJm0YqwmGSbDSJixg-Sfl3jFOFbqE9hxs1wOIhddFbgjxBRi6P-nt1qdcyak53AgrKhuQ7zmthllMkRJhI4G&uniplatform=NZKPT&language=CHS", "https://kns.cnki.net/kcms2/article/abstract?v=WStw-PbchoxEbdsGcNHq4lrTk8lk2vE82akoFjaBTi997kL-xCmHHFG8ddNmO7SYE8ibGqs4um70WtUldjIAcPyXe_UQ9_FZ7CrfrL1LkRsBTlBS8dg1um9MAQ2PUKnlEYe7jGB4s2dVwVpuRiIYVChSfZwqfit-ESOlDTf3yUU1IGYJXEZjmjBuDiN1yYTE&uniplatform=NZKPT&language=CHS", "https://kns.cnki.net/kcms2/article/abstract?v=WStw-PbchozMxohk4EHQgPvZYZSpp2pf1Clf4BOwXKJW-wtnhxwYhRRBeURaHD2zPZ0ddjazGcg8BfOp4LAD4h9Cpn1ayUP2VlWH_6RkdOx6hI_uuWiYNJFz-yugVFEJvifSPBEHKkHRon3KEsfqvr-R83eYznYYIsa-pzPO4Jum2xGpudDoD5HuwW0UjJn6&uniplatform=NZKPT&language=CHS", "https://kns.cnki.net/kcms2/article/abstract?v=WStw-Pbchoy5-_CNHo0wZ1paiWwks_fDQzOTRX44Y6bF7RG-Fpwv4BP68IIvRyWLrA5ae8uTjpDS7CxdemHyUTxIOoha7kIy0vBR3XEZvBSztPhhfGdWk8RxOlqNUqh4v3n-eBRQq0c_YhcPyvuSjfz5Zga5iA4ijhKzLMwUrRsgc0Ad7kbLc0dtOpz9nz3V&uniplatform=NZKPT&language=CHS", ]; async function getDoc(url) { const xhr = await Zotero.HTTP.request("GET", url, { responseType: "document", }); let doc = xhr.response; if (doc && !doc.location) { doc = Zotero.HTTP.wrapDocument(doc, xhr.responseURL); } return doc; } async function translate(doc) { const translate = new Zotero.Translate.Web(); // CNKI translate.setTranslator("5c95b67b-41c5-4f55-b71a-48d5d7183063"); translate.setDocument(doc); const items = await translate.translate({ libraryID: false, // 不保存 saveAttachments: false, }); const item = items[0]; return item; } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function test(urls) { for (let i of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) { console.log("#####" + i); for (let url of urls) { await delay(3000); let d = await getDoc(url); let item = await translate(d); console.log(item.key, item.title, item.getField("publicationTitle")); } } } ================================================ FILE: test/expert_china.json ================================================ { "boolSearch": "true", "QueryJson": { "Platform": "", "Resource": "CROSSDB", "Classid": "WD0FTY92", "Products": "", "QNode": { "QGroup": [ { "Key": "Subject", "Title": "", "Logic": 0, "Items": [ { "Key": "Expert", "Title": "", "Logic": 0, "Field": "EXPERT", "Operator": 0, "Value": "TI+%+'黄瓜共表达'+and+AU='林行众'", "Value2": "" } ], "ChildItems": [] }, { "Key": "ControlGroup", "Title": "", "Logic": 0, "Items": [], "ChildItems": [] } ] }, "ExScope": "1", "SearchType": 4, "Rlang": "CHINESE", "KuaKuCode": "YSTT4HG0,LSTPFY1C,JUP3MUPD,MPMFIG1A,WQ0UVIAA,BLZOG7CK,PWFIRAGL,EMRPGLPA,NLBO1Z6R,NN3FJMUV", "SearchFrom": 1 }, "pageNum": "1", "pageSize": "20", "sortField": "", "sortType": "", "dstyle": "listmode", "productStr": "YSTT4HG0,LSTPFY1C,RMJLXHZ3,JQIRZIYA,JUP3MUPD,1UR4K4HZ,BPBAFJ5S,R79MZMCB,MPMFIG1A,WQ0UVIAA,NB3BWEHK,XVLO76FD,HR1YT1Z9,BLZOG7CK,PWFIRAGL,EMRPGLPA,J708GVCE,ML4DRIDX,NLBO1Z6R,NN3FJMUV,", "aside": "(TI+%+'黄瓜共表达'+and+AU='林行众')", "searchFrom": "资源范围:总库;++中英文扩展;++时间范围:更新时间:不限;++", "CurPage": "1" } ================================================ FILE: test/expert_oversea.json ================================================ { "IsSearch": "true", "QueryJson": { "Platform": "", "DBCode": "CFLS", "KuaKuCode": "CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFN", "QNode": { "QGroup": [ { "Key": "Subject", "Title": "", "Logic": 4, "Items": [ { "Key": "Expert", "Title": "", "Logic": 0, "Name": "", "Operate": "", "Value": "TI+%+'黄瓜共表达'+and+AU='林行众'", "ExtendType": 12, "ExtendValue": "中英文对照", "Value2": "", "BlurType": "" } ], "ChildItems": [] }, { "Key": "ControlGroup", "Title": "", "Logic": 1, "Items": [], "ChildItems": [] } ] }, "ExScope": 1, "CodeLang": "" }, "PageName": "AdvSearch", "DBCode": "CFLS", "KuaKuCodes": "CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFN", "CurPage": "1", "RecordsCntPerPage": "20", "CurDisplayMode": "listmode", "CurrSortField": "", "CurrSortFieldType": "desc", "IsSentenceSearch": "false", "Subject": "" } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "experimentalDecorators": true, "module": "commonjs", "target": "ES2016", "resolveJsonModule": true, "skipLibCheck": true, "strict": true }, "include": ["src", "typings", "node_modules/zotero-types"], "exclude": ["build", "addon"] } ================================================ FILE: typings/attachment.d.ts ================================================ interface AttachmentService { searchAttachments( task: AttachmentTask, ): Promise; importAttachment(task: AttachmentTask): Promise; } type AttachmentSearchResult = { title: string; filename: string; score?: number; url: string; source?: string; }; interface AttachmentTask extends Task { type: AttachmentTaskType; searchResults?: AttachmentSearchResult[]; } ================================================ FILE: typings/global.d.ts ================================================ declare const _globalThis: { [key: string]: any; Zotero: _ZoteroTypes.Zotero; ztoolkit: ZToolkit; addon: typeof addon; }; declare type ZToolkit = ReturnType< typeof import("../src/utils/ztoolkit").createZToolkit >; declare const ztoolkit: ZToolkit; declare const rootURI: string; declare const addon: import("../src/addon").default; declare const __env__: "production" | "development"; ================================================ FILE: typings/i10n.d.ts ================================================ // Generated by zotero-plugin-scaffold /* prettier-ignore */ /* eslint-disable */ // @ts-nocheck export type FluentMessageId = | 'CNKIcitation' | 'action-after-import' | 'action-after-import-desc' | 'attachment-folder-desc' | 'backup-label' | 'bookmark' | 'bookmark-add' | 'bookmark-delete' | 'citation' | 'confirm-close' | 'delete-label' | 'get-Chinese-styles' | 'github-link' | 'help-menu-addons' | 'help-menu-chinese' | 'help-menu-csl' | 'help-menu-translator' | 'help-menu-wiki' | 'how-to-update-translators' | 'import-attachments-success' | 'importing-attachments-is-running' | 'info-best-speed-source-failed' | 'info-best-speed-source-updated' | 'info-translators-cn-updaing' | 'item-section-example1-head-text' | 'item-section-example1-sidenav-tooltip' | 'item-section-example2-button-tooltip' | 'item-section-example2-head-text' | 'item-section-example2-sidenav-tooltip' | 'label-auto-split-name' | 'label-auto-update-translators' | 'label-autoupdate-metadata' | 'label-best-speed' | 'label-choose-folder' | 'label-choose-namepattern' | 'label-choose-source' | 'label-disableZoteroOutline' | 'label-enableBookmark' | 'label-install-wps-plugin-click' | 'label-isMainlandChina' | 'label-language' | 'label-metadata-source' | 'label-metadata-source-cnki' | 'label-metadata-source-cvip' | 'label-namepattern' | 'label-namepattern-auto' | 'label-namepattern-custom' | 'label-namepattern-info' | 'label-namepattern-t' | 'label-namepattern-tg' | 'label-pdf-match-folder' | 'label-rename' | 'label-split-en-name' | 'label-tools-info-1' | 'label-tools-info-2' | 'label-tools-linter' | 'label-translator-source' | 'label-translators-detail' | 'label-translators-detail-click' | 'label-translators-force-update' | 'label-wps' | 'label-wps-help' | 'label-zotero-chinese' | 'menu-metadata' | 'menu-tools' | 'menuitem-find-attachment' | 'menuitem-import-attachments' | 'menuitem-mergeName' | 'menuitem-retrieveMetadata' | 'menuitem-retrieveMetadataForBook' | 'menuitem-splitName' | 'menuitem-updateCNKICite' | 'namepattern-desc' | 'no-attachments-found' | 'no-chinese-item-for-citation' | 'no-item-need-attachment' | 'nothing-label' | 'outline' | 'outline-add' | 'outline-collapse-all' | 'outline-delete' | 'outline-delete-confirm' | 'outline-desc' | 'outline-edit-placeholder' | 'outline-empty-prompt' | 'outline-expand-all' | 'outline-save-to-pdf' | 'plugin-name' | 'pref-enable' | 'pref-group-about' | 'pref-group-attachment' | 'pref-group-bookmark' | 'pref-group-metadata' | 'pref-group-tools' | 'pref-group-translators' | 'pref-group-wps' | 'pref-help' | 'prefs-table-detail' | 'prefs-table-title' | 'report-translator-bug' | 'request-new-translator' | 'result-score' | 'result-source' | 'result-title' | 'search-box' | 'select-download-folder' | 'tabpanel-lib-tab-label' | 'tabpanel-reader-tab-label' | 'task-already-exists' | 'task-list' | 'task-msg-header' | 'th-filename' | 'th-label' | 'th-local-update-time' | 'th-remote-update-time' | 'title' | 'translatorSource-desc' | 'translators-dashboard' | 'update-failed' | 'update-skipped' | 'update-successfully' | 'update-translators-complete' | 'update-translators-start'; ================================================ FILE: typings/myzotero.d.ts ================================================ declare namespace Zotero { /** * Cookie 对象的内部存储结构 */ interface CookieData { /** Cookie 的值 */ value: string; /** 是否为 secure cookie */ secure: boolean; /** 是否为 host-only cookie */ hostOnly: boolean; } /** * Cookie 存储的内部结构 * 格式: { ".host": { "/path": { "cookieName": CookieData } } } */ interface CookieStorage { [host: string]: { [path: string]: { [name: string]: CookieData; }; }; } /** * getCookiesForURI 返回的简单 cookie 对象 * 格式: { "cookieName": "cookieValue" } */ interface CookieDict { [name: string]: string; } /** * Manage cookies in a sandboxed fashion */ class CookieSandbox { /** * Internal cookie storage * @internal */ _cookies: CookieStorage; /** * User agent string to use for sandboxed requests */ userAgent?: string; /** * Create a new CookieSandbox instance * * @param browser - Hidden browser object * @param uri - URI of page to manage cookies for (cookies for domains that are not subdomains of this URI are ignored) * @param cookieData - Cookies with which to initiate the sandbox * @param userAgent - User agent to use for sandboxed requests * * @example * ```typescript * // Create an empty sandbox * const sandbox = new Zotero.CookieSandbox(); * * // Create with initial cookies * const sandbox = new Zotero.CookieSandbox( * null, * "https://example.com", * "sessionId=abc123; userId=456" * ); * ``` */ constructor( browser?: any, uri?: string | Components.interfaces.nsIURI, cookieData?: string, userAgent?: string, ); /** * Clone this CookieSandbox * * @returns A deep copy of this CookieSandbox */ clone(): CookieSandbox; /** * Add cookies to this CookieSandbox based on a cookie header * * @param cookieString - Cookie header string (can contain multiple cookies separated by newlines) * @param uri - URI of the header origin. Used to verify same origin. If omitted, validation is not performed */ addCookiesFromHeader( cookieString: string, uri?: Components.interfaces.nsIURI, ): void; /** * Attach CookieSandbox to a specific browser * * @param browser - Browser element to attach to */ attachToBrowser(browser: any): void; /** * Attach CookieSandbox to a specific XMLHttpRequest * * @param ir - Interface requestor */ attachToInterfaceRequestor( ir: Components.interfaces.nsIInterfaceRequestor | any, ): void; /** * Set a cookie for a specified host * * @param cookiePair - A single cookie pair in the form "key=value" * @param host - Host to bind the cookie to * @param path - Cookie path (defaults to "/") * @param secure - Whether the cookie has the secure attribute set * @param hostOnly - Whether the cookie is a host-only cookie */ setCookie( cookiePair: string, host: string, path?: string, secure?: boolean, hostOnly?: boolean, ): void; /** * Returns a list of cookies that should be sent to the given URI * * @param uri - The URI to get cookies for (must be nsIURI object, not string) * @returns Object containing cookie name-value pairs, or null if no cookies found */ getCookiesForURI(uri: Components.interfaces.nsIURI): CookieDict | null; /** * Internal method to get cookies for a specific path * @internal */ _getCookiesForPath( cookies: CookieDict, cookiePaths: any, pathParts: string[], secure: boolean, isHost: boolean, ): boolean; } namespace CookieSandbox { /** * Initialize the CookieSandbox observer */ function init(): void; /** * Normalize the host string: lower-case, remove leading period, some more cleanup * * @param host - Host string to normalize * @returns Normalized host string */ function normalizeHost(host: string): string; /** * Normalize the path string * * @param path - Path string to normalize * @returns Normalized path string */ function normalizePath(path: string): string; /** * Generate a semicolon-separated string of cookie values from a cookie object * * @param cookies - Object containing key-value cookie pairs * @returns Cookie string in format "name1=value1; name2=value2" */ function generateCookieString(cookies: CookieDict): string; /** * Observer for managing cookies across different contexts */ namespace Observer { /** WeakMap of browsers tracked by CookieSandbox */ const trackedBrowsers: WeakMap; /** WeakMap of interface requestors tracked by CookieSandbox */ const trackedInterfaceRequestors: WeakMap; /** * Register the cookie observer */ function register(): void; /** * Observe HTTP events to manage cookies * * @param channel - HTTP channel * @param topic - Observer topic */ function observe(channel: any, topic: string): void; } } } ================================================ FILE: typings/notifier.d.ts ================================================ ================================================ FILE: typings/outline.d.ts ================================================ type OutlineNode = { level: number; title: string; page: number; x: number; y: number; children?: OutlineNode[]; collapsed?: boolean; ref?: PDFRef; }; type OutlineInfo = { info: Record & { baseFontSize?: number; // Base font size for level-1, default 12 }; outline: OutlineNode[]; }; // Reference of PDF object // type PdfRef = { // num: number; // gen: number; // tag?: string; // 可能是 "Page" 或 "Outline" // }; type PdfZoomMode = { name: string; // 缩放模式名称,例如 "Fit", "XYZ", "FitH", "FitV" args?: (number | null)[]; }; type PdfDest = { dest: [PDFRef, PdfZoomMode] }; type PdfPosition = { position: { pageIndex: number; rects: [number, number, number, number][] }; }; type PdfOutlineNode = { title: string; items: PdfOutlineNode[]; location: PdfDest | PdfPosition; // 没有遇到 PdfDest 的情况 }; // 书签相关类型定义 type BookmarkNode = { id: string; // 唯一标识符 title: string; page: number; x: number; y: number; order: number; // 用于排序,书签没有层级关系 createdAt: number; // 创建时间戳 color: string; // 书签颜色 }; type BookmarkInfo = { info: { itemID: number; schema: number; jasminumVersion: string; baseFontSize?: number; // outline 12, bookmark 13 }; bookmarks: BookmarkNode[]; }; ================================================ FILE: typings/pdfParser.d.ts ================================================ type RecognizerData = { metadata: { [key: string]: string; }; totalPages: number; pages: RecognizerPage[]; }; type PdfData = { metadata: { [key: string]: string; }; totalPages: number; pages: PdfPage[]; }; type RecognizerPage = { // pageWidth 0: number; // pageHeight 1: number; 2: [[[[0, 0, 0, 0, RecognizerLine[]]]]]; }; type PdfPage = { width: number; height: number; text: string; classList: string[]; paragraphs: PdfParagraph[]; }; type PdfParagraph = { fontSize: string; text: string; classList: string[]; lines: PdfLine[]; }; type RecognizerLine = [RecognizerWord[]]; type PdfLine = { xMin: number; yMin: number; xMax: number; yMax: number; fontSize: string; baseline: number; text: string; words: PdfWord[]; }; type RecognizerWord = [ // 0: xMin number, // 1: yMin number, // 2: xMax number, // 3: yMax number, // 4: fontSize number, // 5: spaceAfter 0 | 1, // 6: baseline number, // 7: rotation 0, // 8: underlined 0, // 9: bold 0 | 1, // 10: italic 0 | 1, // 11: colorIndex 0, // 12: fontIndex number, // 13: text string, ]; type PdfWord = { xMin: number; yMin: number; xMax: number; yMax: number; fontSize: number; spaceAfter: boolean; baseline: number; rotation: boolean; underlined: boolean; bold: boolean; italic: boolean; colorIndex: number; fontIndex: number; text: string; }; type DocType = "article" | "thesis" | "book"; ================================================ FILE: typings/prefs.d.ts ================================================ // Generated by zotero-plugin-scaffold /* prettier-ignore */ /* eslint-disable */ // @ts-nocheck // prettier-ignore declare namespace _ZoteroTypes { interface Prefs { PluginPrefsMap: { "firstRun": boolean; "translatorsMended": boolean; "autoSplitName": boolean; "splitEnName": boolean; "language": string; "autoUpdateMetadata": boolean; "namePattern": string; "namePatternCustom": string; "metadataSource": string; "isMainlandChina": boolean; "cnkiAttachmentCookie": string; "similarityThresholdForMetaData": string; "pdfMatchFolder": string; "actionAfterAttachmentImport": string; "similarityThreshold": string; "topMatchCount": number; "autoUpdateTranslators": boolean; "translatorUpdateTime": string; "translatorSource": string; "enableBookmark": boolean; "newNodeAsChild": boolean; "disableZoteroOutline": boolean; }; } } ================================================ FILE: typings/scrape.d.ts ================================================ interface ScrapeService { search(searchOption: SearchOption): Promise; searchSnapshot?(task: ScrapeTask): Promise; translate( searchResult: ScrapeSearchResult, libraryID: number, saveAttachments: false, ): Promise; translateSnapshot?(task: ScrapeTask): Promise; } type SearchOption = { author?: string; title: string; }; type ScrapeSearchResult = { source: string; title: string; url: string; [key: string]: string | number | null; }; type TaskStatus = | "waiting" | "processing" | "multiple_results" | "success" | "fail"; type ScraperTaskType = "attachment" | "snapshot"; type AttachmentTaskType = "local" | "remote"; interface Task { id: string; type: string; item: Zotero.Item; resultIndex?: 0; status: TaskStatus; silent?: boolean; message?: string; addMsg: (msg: string) => void; deferred?: DeferredResult; searchResults?: any[]; } interface ScrapeTask extends Task { type: ScraperTaskType; searchResults?: ScrapeSearchResult[]; } // 定义 Deferred 类型,用于等待用户输入,选择合适的结果索引 type DeferredResult = { promise: Promise; resolve: (value: T) => void; reject: (reason?: any) => void; }; ================================================ FILE: typings/translators.d.ts ================================================ type LastUpdatedMap = { [filename: string]: { label: string; lastUpdated: string }; }; type TableRow = { filename: string; label: string; localUpdateTime: string; remoteUpdateTime: string; }; ================================================ FILE: zotero-plugin.config.ts ================================================ import { defineConfig } from "zotero-plugin-scaffold"; import pkg from "./package.json"; export default defineConfig({ source: ["src", "addon"], dist: "build", name: pkg.config.addonName, id: pkg.config.addonID, xpiName: `${pkg.config.addonRef}_${pkg.version}`, namespace: pkg.config.addonRef, updateURL: `https://github.com/{{owner}}/{{repo}}/releases/download/release/${ pkg.version.includes("-") ? "update-beta.json" : "update.json" }`, xpiDownloadLink: "https://github.com/{{owner}}/{{repo}}/releases/download/v{{version}}/{{xpiName}}.xpi", build: { assets: ["addon/**/*.*"], define: { ...pkg.config, author: pkg.author, description: pkg.description, homepage: pkg.homepage, buildVersion: pkg.version, buildTime: "{{buildTime}}", }, prefs: { prefix: pkg.config.prefsPrefix, }, esbuildOptions: [ { // entryPoints: ["src/index.ts"], entryPoints: [ { in: "src/index.ts", out: pkg.config.addonRef }, { in: "src/modules/workers/index.ts", out: `${pkg.config.addonRef}-worker`, }, ], outdir: "build/addon/chrome/content/scripts", define: { __env__: `"${process.env.NODE_ENV}"`, }, bundle: true, target: "firefox115", // outfile: `build/addon/chrome/content/scripts/${pkg.config.addonRef}.js`, }, ], }, // If you need to see a more detailed log, uncomment the following line: // logLevel: "trace", });