[
  {
    "path": ".gitattributes",
    "content": "* text=auto eol=lf"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where the package manifests are located.\n# Please see the documentation for all configuration options:\n# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates\n\nversion: 2\nupdates:\n  - package-ecosystem: \"npm\" # See documentation for possible values\n    directory: \"/\" # Location of package manifests\n    schedule:\n      interval: \"weekly\"\n"
  },
  {
    "path": ".github/renovate.json",
    "content": "{\n  \"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\",\n  \"extends\": [\n    \"config:recommended\",\n    \":semanticPrefixChore\",\n    \":prHourlyLimitNone\",\n    \":prConcurrentLimitNone\",\n    \":enableVulnerabilityAlerts\",\n    \":dependencyDashboard\",\n    \"group:allNonMajor\",\n    \"schedule:weekly\"\n  ],\n  \"labels\": [\"dependencies\"],\n  \"packageRules\": [\n    {\n      \"matchPackageNames\": [\n        \"zotero-plugin-toolkit\",\n        \"zotero-types\",\n        \"zotero-plugin-scaffold\"\n      ],\n      \"schedule\": [\"at any time\"],\n      \"automerge\": true\n    }\n  ],\n  \"git-submodules\": {\n    \"enabled\": true\n  }\n}\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  push:\n    tags:\n      - v**\n  workflow_dispatch:   # 新增手动触发入口\n    inputs:\n      version:\n        description: 'Release version (格式 vX.Y.Z)'\n        required: true\n      skip-notification:\n        description: '跳过通知 (true/false)'\n        default: 'false'\n\npermissions:\n  contents: write\n  issues: write\n  pull-requests: write\n\njobs:\n  release:\n    runs-on: ubuntu-latest\n    env:\n      GITHUB_TOKEN: ${{ secrets.GitHub_TOKEN }}\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      # 手动触发时创建标签\n      - name: Create tag (manual)\n        if: ${{ github.event_name == 'workflow_dispatch' }}\n        run: |\n          git config --local user.email \"actions@github.com\"\n          git config --local user.name \"GitHub Actions\"\n          git tag ${{ inputs.version }}\n          git push origin ${{ inputs.version }}\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 22.14\n\n      - name: Install deps\n        run: npm install -f\n\n      - name: Build\n        run: npm run build\n\n      - name: Release to GitHub\n        run: npm run release\n\n      - name: Notify release\n        if: ${{ !contains(github.event.inputs.skip-notification, 'true') }}\n        uses: apexskier/github-release-commenter@v1\n        continue-on-error: true\n        with:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          comment-template: |\n            :rocket: _This ticket has been resolved in {release_tag}. See {release_link} for release notes._\n"
  },
  {
    "path": ".gitignore",
    "content": "build\nlogs\nnode_modules\npnpm-lock.yaml\nyarn.lock\n.DS_Store\n.env\n.scaffold\ntmp/\n"
  },
  {
    "path": ".prettierignore",
    "content": ".vscode\nbuild\nlogs\nnode_modules\npackage-lock.json\nyarn.lock\npnpm-lock.yaml\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\n    \"dbaeumer.vscode-eslint\",\n    \"esbenp.prettier-vscode\",\n    \"macabeus.vscode-fluent\"\n  ]\n}\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n  // 使用 IntelliSense 了解相关属性。\n  // 悬停以查看现有属性的描述。\n  // 欲了解更多信息，请访问: https://go.microsoft.com/fwlink/?linkid=830387\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"name\": \"Start\",\n      \"runtimeExecutable\": \"npm\",\n      \"runtimeArgs\": [\"run\", \"start\"]\n    },\n    {\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"name\": \"Build\",\n      \"runtimeExecutable\": \"npm\",\n      \"runtimeArgs\": [\"run\", \"build\"]\n    }\n  ]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"[javascript]\": {\n    \"editor.defaultIndentSize\": 2,\n    \"editor.tabSize\": 2\n  },\n  \"[typescript]\": {\n    \"editor.defaultIndentSize\": 2,\n    \"editor.tabSize\": 2\n  },\n  \"files.eol\": \"\\n\",\n  \"editor.formatOnType\": false,\n  \"editor.formatOnSave\": true,\n  \"editor.codeActionsOnSave\": {\n    \"source.fixAll.eslint\": \"explicit\"\n  },\n  \"eslint.validate\": [\"javascript\", \"typescript\"],\n  \"prettier.requireConfig\": true,\n  \"commentTranslate.hover.enabled\": true,\n  \"editor.detectIndentation\": false, // 关键：禁止自动检测\n  \"prettier.tabWidth\": 2,\n  \"eslint.options\": {\n    \"overrideConfig\": {\n      \"rules\": {\n        \"indent\": \"off\" // 强制禁用 ESLint 缩进规则\n      }\n    }\n  }\n}\n"
  },
  {
    "path": ".vscode/toolkit.code-snippets",
    "content": "{\n  \"appendElement - full\": {\n    \"scope\": \"javascript,typescript\",\n    \"prefix\": \"appendElement\",\n    \"body\": [\n      \"appendElement({\",\n      \"\\ttag: '${1:div}',\",\n      \"\\tid: '${2:id}',\",\n      \"\\tnamespace: '${3:html}',\",\n      \"\\tclassList: ['${4:class}'],\",\n      \"\\tstyles: {${5:style}: '$6'},\",\n      \"\\tproperties: {},\",\n      \"\\tattributes: {},\",\n      \"\\t[{ '${7:onload}', (e: Event) => $8, ${9:false} }],\",\n      \"\\tcheckExistanceParent: ${10:HTMLElement},\",\n      \"\\tignoreIfExists: ${11:true},\",\n      \"\\tskipIfExists: ${12:true},\",\n      \"\\tremoveIfExists: ${13:true},\",\n      \"\\tcustomCheck: (doc: Document, options: ElementOptions) => ${14:true},\",\n      \"\\tchildren: [$15]\",\n      \"}, ${16:container});\"\n    ]\n  },\n  \"appendElement - minimum\": {\n    \"scope\": \"javascript,typescript\",\n    \"prefix\": \"appendElement\",\n    \"body\": \"appendElement({ tag: '$1' }, $2);\"\n  },\n  \"register Notifier\": {\n    \"scope\": \"javascript,typescript\",\n    \"prefix\": \"registerObserver\",\n    \"body\": [\n      \"registerObserver({\",\n      \"\\t notify: (\",\n      \"\\t\\tevent: _ZoteroTypes.Notifier.Event,\",\n      \"\\t\\ttype: _ZoteroTypes.Notifier.Type,\",\n      \"\\t\\tids: string[],\",\n      \"\\t\\textraData: _ZoteroTypes.anyObj\",\n      \"\\t) => {\",\n      \"\\t\\t$0\",\n      \"\\t}\",\n      \"});\"\n    ]\n  }\n}\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published\n    by the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <http://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<http://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=center>\n\n![Jasminum](./addon/chrome/content/icons/icon.png)\n\n# 茉莉花 Jasminum\n\n[![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)\n\n</div>\n</br>\n\n简体中文 | [English](doc/README-en.md)\n\n## 1. 基础功能\n\n- 中文PDF元数据抓取\n- 中文转换器下载，转换器来源于 Zotero中文社区 [translators_CN](https://github.com/l0o0/translators_CN)\n- 中文引用格式下载，引用格式来源于项目 Zotero中文社区 [styles](https://github.com/zotero-chinese/styles)\n- 小工具\n  - 语言设置\n  - 中文姓名拆分与合并\n\n## 2.使用教程\n\n### 2.1 元数据抓取\n\n目前支持仅支持从**中国知网**获取元数据，后续考虑会添加其他数据来源。\n\n在 Zotero 中添加中文附件后，右键附件，在菜单栏选择`茉莉花抓取` -> `抓取期刊元数据`，在弹出窗口可以看到元数据抓取的结果。\n如果有多个搜索结果，需要你手动选择最匹配的结果，再点击确认，即可完成抓取。\n\n![alt text](doc/images/image2.png)\n\n### 2.3 本地附件匹配功能\n\n在使用 Zotero Connector 在浏览器上抓取中文期刊时（尤其是中国知网），经常出现元数据抓取成功而附件无法下载自动的异常，当你手动下载期刊附件（PDF/CAJ）后，可以方便地用此功能来将下载的附件与元数据匹配。\n\n右键期刊条目，`小工具` -> `在下载文件夹中查找附件`，该功能会自动在当前`下载目录`中寻找与当前条目匹配的附件，匹配规划是**根据期刊标题与文件名的匹配度**。\n\n`下载目录`默认是系统的下载目录，Windows系统默认是`C:\\Users\\用户名\\Downloads`，Mac系统默认是`/Users/用户名/Downloads`，Linux系统默认是`/home/用户名/Downloads`。也可以在`设置`中修改下载目录。\n\n下载目录中匹配成功的附件默认会移动到备份目录中`下载目录/jasminum-backup`中，在设置中还可以选择\n\n- 删除匹配成功的附件。匹配到元数据的附件已经保存到Zotero中，可以放心删除下载目录中的附件（个人建议删除，避免下载目录中附件过多）。\n- 无须处理。即使匹配成功，附件还是会在下载目录中，当然Zotero已经保存了一份。\n\n### 2.3 PDF大纲\n\n在 PDF 阅读窗口的左侧边栏中，点击茉莉花书签按钮，即可看到书签大纲窗口。\n\n![alt text](doc/images/image.png)\n\n最上方的5个按钮，功能分别是：\n\n- 展开所有书签\n- 折叠所有书签\n- 添加书签\n- 删除书签\n- 将书签内容保存到PDF（默认只以配置文件的形式保存到本地）\n\n**键盘快捷键导航**\n\n- 键盘↑，上一个书签（跳过折叠内容）\n- 键盘↓，下一个书签（跳过折叠内容）\n- 键盘←或→，展开或折叠节点\n- 空格键，编辑书签内容\n- [，将书签移到上一级（作为原上级节点的下一个相邻节点）\n- ]，将书签移到下一级（自动将相邻的上一个节点作为上级节点）\n- \\，创建新节点（默认作为选中节点的子节点）\n- Delete 或 Backspace，删除节点\n\n## 3. ❤️致谢\n\n特别感谢 [jiaojiaodubai](https://github.com/jiaojiaodubai) 同学，长期以来对 [translators_CN](https://github.com/l0o0/translators_CN) 和 本项目 的贡献。\n"
  },
  {
    "path": "addon/bootstrap.js",
    "content": "/* eslint-disable no-undef */\n\n/**\n * Most of this code is from Zotero team's official Make It Red example[1]\n * or the Zotero 7 documentation[2].\n * [1] https://github.com/zotero/make-it-red\n * [2] https://www.zotero.org/support/dev/zotero_7_for_developers\n */\n\nvar chromeHandle;\n\nfunction install(data, reason) {}\n\nasync function startup({ id, version, resourceURI, rootURI }, reason) {\n  await Zotero.initializationPromise;\n\n  // String 'rootURI' introduced in Zotero 7\n  if (!rootURI) {\n    rootURI = resourceURI.spec;\n  }\n\n  var aomStartup = Components.classes[\n    \"@mozilla.org/addons/addon-manager-startup;1\"\n  ].getService(Components.interfaces.amIAddonManagerStartup);\n  var manifestURI = Services.io.newURI(rootURI + \"manifest.json\");\n  chromeHandle = aomStartup.registerChrome(manifestURI, [\n    [\"content\", \"__addonRef__\", rootURI + \"chrome/content/\"],\n  ]);\n\n  /**\n   * Global variables for plugin code.\n   * The `_globalThis` is the global root variable of the plugin sandbox environment\n   * and all child variables assigned to it is globally accessible.\n   * See `src/index.ts` for details.\n   */\n  const ctx = {\n    rootURI,\n  };\n  ctx._globalThis = ctx;\n\n  Services.scriptloader.loadSubScript(\n    `${rootURI}/chrome/content/scripts/__addonRef__.js`,\n    ctx,\n  );\n  Zotero.__addonInstance__.hooks.onStartup();\n}\n\nasync function onMainWindowLoad({ window }, reason) {\n  Zotero.__addonInstance__?.hooks.onMainWindowLoad(window);\n}\n\nasync function onMainWindowUnload({ window }, reason) {\n  Zotero.__addonInstance__?.hooks.onMainWindowUnload(window);\n}\n\nfunction shutdown({ id, version, resourceURI, rootURI }, reason) {\n  if (reason === APP_SHUTDOWN) {\n    return;\n  }\n\n  if (typeof Zotero === \"undefined\") {\n    Zotero = Components.classes[\"@zotero.org/Zotero;1\"].getService(\n      Components.interfaces.nsISupports,\n    ).wrappedJSObject;\n  }\n  Zotero.__addonInstance__?.hooks.onShutdown();\n\n  Cc[\"@mozilla.org/intl/stringbundle;1\"]\n    .getService(Components.interfaces.nsIStringBundleService)\n    .flushBundles();\n\n  Cu.unload(`${rootURI}/chrome/content/scripts/__addonRef__.js`);\n\n  if (chromeHandle) {\n    chromeHandle.destruct();\n    chromeHandle = null;\n  }\n}\n\nfunction uninstall(data, reason) {}\n"
  },
  {
    "path": "addon/chrome/content/preferences-main.xhtml",
    "content": "<linkset>\n  <html:link rel=\"localization\" href=\"__addonRef__-preferences-main.ftl\" />\n  <html:link\n    xmlns:html=\"http://www.w3.org/1999/xhtml\"\n    rel=\"stylesheet\"\n    href=\"chrome://__addonRef__/content/prefpanel.css\"\n  />\n</linkset>\n<!-- 元数据抓取 -->\n<groupbox onload=\"Zotero.__addonInstance__.hooks.onPrefsWindowLoad(window);\">\n  <label><html:h2 data-l10n-id=\"pref-group-metadata\"></html:h2></label>\n  <checkbox\n    id=\"zotero-prefpane-__addonRef__-isMainlandChina\"\n    native=\"true\"\n    preference=\"isMainlandChina\"\n    data-l10n-id=\"label-isMainlandChina\"\n  />\n  <checkbox\n    id=\"zotero-prefpane-__addonRef__-autoupdate\"\n    native=\"true\"\n    preference=\"autoUpdateMetadata\"\n    data-l10n-id=\"label-autoupdate-metadata\"\n  />\n  <hbox flex=\"2\" class=\"inputbox\">\n    <menulist\n      id=\"zotero-prefpane-__addonRef__-namepattern-menulist\"\n      native=\"true\"\n    >\n      <menupopup>\n        <menuitem value=\"auto\" data-l10n-id=\"label-namepattern-auto\" />\n        <menuitem value=\"{%t}_{%g}\" data-l10n-id=\"label-namepattern-tg\" />\n        <menuitem value=\"{%t}\" data-l10n-id=\"label-namepattern-t\" />\n        <menuseparator></menuseparator>\n        <menuitem value=\"custom\" data-l10n-id=\"label-namepattern-custom\" />\n      </menupopup>\n    </menulist>\n    <html:input\n      type=\"text\"\n      class=\"auto_width\"\n      id=\"zotero-prefpane-__addonRef__-namepattern-input\"\n      preference=\"namePattern\"\n    >\n    </html:input>\n    <html:input\n      type=\"text\"\n      class=\"auto_width hidden\"\n      id=\"zotero-prefpane-__addonRef__-namepatternCustom-input\"\n      preference=\"namePatternCustom\"\n    >\n    </html:input>\n    <image\n      class=\"help-icon\"\n      src=\"chrome://jasminum/content/icons/help.svg\"\n      data-l10n-id=\"namepattern-desc\"\n    ></image>\n  </hbox>\n  <!-- <hbox align=\"center\">\n    <label\n      for=\"zotero-prefpane-__addonRef__-metadata-source\"\n      data-l10n-id=\"label-metadata-source\"\n    ></label>\n    <html:input\n      type=\"text\"\n      id=\"zotero-prefpane-__addonRef__-metadata-source-input\"\n      preference=\"metadataSource\"\n      disabled=\"true\"\n    ></html:input>\n    <html:div>\n      <button\n        id=\"zotero-prefpane-__addonRef__-metadata-source-button\"\n        data-l10n-id=\"label-choose-source\"\n      ></button>\n      <html:div id=\"metadata-source-dropdown\" class=\"dropdown-content\">\n        <checkbox\n          disabled=\"true\"\n          native=\"true\"\n          class=\"metadata-drop-item\"\n          value=\"CNKI\"\n          data-l10n-id=\"label-metadata-source-cnki\"\n        />\n        <checkbox\n          class=\"metadata-drop-item\"\n          native=\"true\"\n          value=\"CVIP\"\n          data-l10n-id=\"label-metadata-source-cvip\"\n        />\n      </html:div>\n    </html:div>\n  </hbox> -->\n</groupbox>\n<!-- 转换器 -->\n<groupbox>\n  <label><html:h2 data-l10n-id=\"pref-group-translators\"></html:h2></label>\n  <hbox align=\"center\">\n    <checkbox\n      id=\"zotero-prefpane-__addonRef__-autoUpdateTranslators\"\n      native=\"true\"\n      preference=\"autoUpdateTranslators\"\n      data-l10n-id=\"label-auto-update-translators\"\n    />\n    <button\n      id=\"zotero-prefpane-__addonRef__-force-update\"\n      data-l10n-id=\"label-translators-force-update\"\n      style=\"margin-left: 4px\"\n    ></button>\n  </hbox>\n  <hbox align=\"center\">\n    <label data-l10n-id=\"label-translators-detail\">click</label>\n    <button\n      id=\"zotero-prefpane-__addonRef__-open-translator-table\"\n      data-l10n-id=\"label-translators-detail-click\"\n    ></button>\n  </hbox>\n  <hbox align=\"center\">\n    <label data-l10n-id=\"label-translator-source\">click</label>\n    <menulist\n      id=\"zotero-prefpane-__addonRef__-source\"\n      preference=\"translatorSource\"\n    >\n      <menupopup>\n        <menuitem\n          value=\"https://ftp.zotero-chinese.com/translators_CN\"\n          label=\"Zotero中文\"\n        />\n        <menuitem\n          value=\"https://oss.wwang.de/translators_CN\"\n          label=\"可口可乐\"\n        />\n        <menuitem\n          value=\"https://www.wieke.cn/translators_CN\"\n          label=\"www.wieke.cn\"\n        />\n      </menupopup>\n    </menulist>\n    <button\n      id=\"zotero-prefpane-__addonRef__-best-speed-button\"\n      data-l10n-id=\"label-best-speed\"\n    ></button>\n    <image\n      class=\"help-icon\"\n      src=\"chrome://jasminum/content/icons/help.svg\"\n      data-l10n-id=\"translatorSource-desc\"\n    ></image>\n  </hbox>\n</groupbox>\n<!-- 附件 -->\n<groupbox>\n  <label><html:h2 data-l10n-id=\"pref-group-attachment\"></html:h2></label>\n  <hbox align=\"center\">\n    <label\n      for=\"zotero-prefpane-__addonRef__-pdf-match-folder\"\n      data-l10n-id=\"label-pdf-match-folder\"\n    ></label>\n    <html:input\n      type=\"text\"\n      id=\"zotero-prefpane-__addonRef__-pdf-match-folder-input\"\n      preference=\"pdfMatchFolder\"\n    ></html:input>\n    <button\n      id=\"zotero-prefpane-__addonRef__-pdf-match-folder-button\"\n      data-l10n-id=\"label-choose-folder\"\n    ></button>\n    <image\n      class=\"help-icon\"\n      src=\"chrome://jasminum/content/icons/help.svg\"\n      data-l10n-id=\"attachment-folder-desc\"\n    ></image>\n  </hbox>\n  <hbox align=\"center\">\n    <label data-l10n-id=\"action-after-import\"></label>\n    <menulist\n      id=\"zotero-prefpane-__addonRef__-source\"\n      preference=\"actionAfterAttachmentImport\"\n    >\n      <menupopup>\n        <menuitem value=\"nothing\" data-l10n-id=\"nothing-label\" />\n        <menuitem value=\"backup\" data-l10n-id=\"backup-label\" />\n        <menuitem value=\"delete\" data-l10n-id=\"delete-label\" />\n      </menupopup>\n    </menulist>\n    <image\n      class=\"help-icon\"\n      src=\"chrome://jasminum/content/icons/help.svg\"\n      data-l10n-id=\"action-after-import-desc\"\n    ></image>\n  </hbox>\n</groupbox>\n<!-- 书签 -->\n<groupbox>\n  <label><html:h2 data-l10n-id=\"pref-group-bookmark\"></html:h2></label>\n  <hbox align=\"center\">\n    <checkbox\n      id=\"zotero-prefpane-__addonRef__-enableBookmark\"\n      native=\"true\"\n      preference=\"enableBookmark\"\n      data-l10n-id=\"label-enableBookmark\"\n    />\n    <image\n      class=\"help-icon\"\n      src=\"chrome://jasminum/content/icons/help.svg\"\n      data-l10n-id=\"outline-desc\"\n    ></image>\n  </hbox>\n  <checkbox\n    id=\"zotero-prefpane-__addonRef__-disableZoteroOutline\"\n    native=\"true\"\n    preference=\"disableZoteroOutline\"\n    data-l10n-id=\"label-disableZoteroOutline\"\n  />\n</groupbox>\n<!-- 小工具 -->\n<groupbox>\n  <label><html:h2 data-l10n-id=\"pref-group-tools\"></html:h2></label>\n  <checkbox\n    id=\"zotero-prefpane-__addonRef__-autoSplitName\"\n    native=\"true\"\n    preference=\"autoSplitName\"\n    data-l10n-id=\"label-auto-split-name\"\n  />\n  <checkbox\n    id=\"zotero-prefpane-__addonRef__-splitEnName\"\n    native=\"true\"\n    preference=\"splitEnName\"\n    data-l10n-id=\"label-split-en-name\"\n  />\n  <hbox align=\"center\">\n    <label\n      for=\"zotero-prefpane-__addonRef__-language\"\n      data-l10n-id=\"label-language\"\n    ></label>\n    <html:input\n      type=\"text\"\n      id=\"zotero-prefpane-__addonRef__-language\"\n      preference=\"language\"\n    ></html:input>\n  </hbox>\n  <hbox>\n    <label\n      data-l10n-id=\"label-tools-info-1\"\n      style=\"margin-top: 8px; margin-bottom: 8px\"\n    ></label>\n    <label\n      data-l10n-id=\"label-tools-linter\"\n      is=\"zotero-text-link\"\n      class=\"zotero-text-link\"\n      href=\"https://zotero-chinese.com/user-guide/plugins/linter\"\n      style=\"margin-top: 8px; margin-bottom: 8px\"\n    ></label>\n    <label\n      data-l10n-id=\"label-tools-info-2\"\n      style=\"margin-left: 0.5ch; margin-top: 8px; margin-bottom: 8px\"\n    ></label>\n  </hbox>\n</groupbox>\n<!-- WPS下载 -->\n<!-- <groupbox>\n  <label><html:h2 data-l10n-id=\"pref-group-wps\"></html:h2></label>\n  <hbox align=\"center\">\n    <label data-l10n-id=\"label-wps\"></label>\n    <button\n      id=\"zotero-prefpane-__addonRef__-install-wps-plugin-button\"\n      data-l10n-id=\"label-install-wps-plugin-click\"\n    ></button>\n    <label\n      data-l10n-id=\"label-wps-help\"\n      is=\"zotero-text-link\"\n      class=\"zotero-text-link\"\n      href=\"https://zotero-chinese.com/\"\n    ></label>\n  </hbox>\n</groupbox> -->\n<!-- 介绍及致谢 -->\n<groupbox>\n  <label><html:h2 data-l10n-id=\"pref-group-about\"></html:h2></label>\n  <hbox>\n    <label\n      value=\"Jasminum \"\n      class=\"zotero-text-link\"\n      is=\"zotero-text-link\"\n      href=\"https://github.com/l0o0/jasminum\"\n      style=\"margin-right: 4px\"\n    ></label>\n    <label\n      data-l10n-id=\"pref-help\"\n      data-l10n-args='{\"time\": \"__buildTime__\",\"name\": \"__addonName__\",\"version\":\"__buildVersion__\"}'\n    ></label>\n    <label\n      data-l10n-id=\"label-zotero-chinese\"\n      class=\"zotero-text-link\"\n      is=\"zotero-text-link\"\n      href=\"https://zotero-chinese.com/\"\n    ></label>\n  </hbox>\n</groupbox>\n"
  },
  {
    "path": "addon/chrome/content/preferences-translators.xhtml",
    "content": "<?xml version=\"1.0\"?>\n<!-- prettier-ignore -->\n<?xml-stylesheet href=\"chrome://global/skin/\" type=\"text/css\"?>\n<!-- prettier-ignore -->\n<?xml-stylesheet href=\"chrome://zotero/skin/zotero.css\" type=\"text/css\"?>\n<!-- prettier-ignore -->\n<?xml-stylesheet href=\"chrome://zotero-platform/content/zotero-react-client.css\" type=\"text/css\"?>\n<!-- prettier-ignore -->\n<?xml-stylesheet href=\"chrome://zotero-platform/content/zotero.css\" type=\"text/css\"?>\n<!-- prettier-ignore -->\n<!DOCTYPE html>\n<html\n  id=\"__addonRef__-translators\"\n  xmlns=\"http://www.w3.org/1999/xhtml\"\n  xmlns:xul=\"http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul\"\n  xmlns:html=\"http://www.w3.org/1999/xhtml\"\n  windowtype=\"__addonRef__:translators\"\n>\n  <head>\n    <title data-l10n-id=\"title\"></title>\n    <meta charset=\"utf-8\" />\n    <xul:linkset>\n      <link rel=\"localization\" href=\"zotero.ftl\" />\n      <link\n        rel=\"localization\"\n        href=\"__addonRef__-preferences-translators.ftl\"\n      />\n    </xul:linkset>\n    <xul:keyset>\n      <xul:key\n        id=\"key_close\"\n        key=\"W\"\n        modifiers=\"accel\"\n        oncommand=\"window.close()\"\n      />\n    </xul:keyset>\n    <script>\n      document.addEventListener(\"DOMContentLoaded\", (ev) => {\n        Services.scriptloader.loadSubScript(\n          \"chrome://zotero/content/include.js\",\n          this,\n        );\n        window.arguments[0]._initPromise.resolve();\n      });\n\n      // Custom elements\n      Services.scriptloader.loadSubScript(\n        \"chrome://zotero/content/customElements.js\",\n        this,\n      );\n    </script>\n    <style>\n      html,\n      body {\n        margin: 0;\n        min-width: 800px;\n        min-height: 400px;\n        height: 100%;\n      }\n      header,\n      footer {\n        display: flex;\n        align-items: center;\n        height: 50px;\n        padding: 0 10px;\n      }\n      toolbar,\n      toolbar > hbox {\n        width: 100%;\n      }\n      .toolbarbutton-text {\n        margin-left: 4px;\n      }\n      main {\n        width: 100%;\n        height: calc(100% - 2 * 50px);\n        background: var(--material-background);\n      }\n      #table-container {\n        height: 100%;\n        width: 100%;\n      }\n      #translators-table {\n        height: 100%;\n      }\n      footer {\n        justify-content: space-between;\n      }\n      footer > * {\n        display: flex;\n        gap: 6px;\n      }\n      #links > .zotero-text-link {\n        margin: 0 5px;\n      }\n      footer > * > :first-child {\n        margin-left: 0;\n      }\n      footer > * > :last-child {\n        margin-right: 0;\n      }\n    </style>\n  </head>\n  <body>\n    <header\n      xmlns=\"http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul\"\n    >\n      <toolbar class=\"zotero-toolbar toolbar toolbar-primary\">\n        <hbox align=\"center\">\n          <toolbarbutton\n            id=\"github-link\"\n            class=\"zotero-tb-button\"\n            data-l10n-id=\"github-link\"\n            image=\"chrome://__addonRef__/content/icons/github.svg\"\n          />\n          <spacer flex=\"1\"></spacer>\n          <search-textbox\n            id=\"search-box\"\n            data-l10n-id=\"search-box\"\n            data-l10n-attrs=\"placeholder\"\n            timeout=\"1\"\n            style=\"padding: 0\"\n          />\n        </hbox>\n      </toolbar>\n    </header>\n    <main>\n      <div id=\"table-container\"></div>\n    </main>\n    <footer>\n      <div\n        id=\"buttons\"\n        xmlns=\"http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul\"\n      >\n        <button\n          id=\"request-new-translator\"\n          data-l10n-id=\"request-new-translator\"\n        />\n        <button\n          id=\"report-translator-bug\"\n          data-l10n-id=\"report-translator-bug\"\n        />\n      </div>\n      <div\n        id=\"links\"\n        xmlns=\"http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul\"\n      >\n        <label\n          data-l10n-id=\"how-to-update-translators\"\n          class=\"zotero-text-link\"\n          is=\"zotero-text-link\"\n          href=\"https://zotero-chinese.com/user-guide/faqs/update-translators\"\n        />\n        <label\n          data-l10n-id=\"translators-dashboard\"\n          class=\"zotero-text-link\"\n          is=\"zotero-text-link\"\n          href=\"https://zotero-chinese.com/translators/\"\n        />\n      </div>\n    </footer>\n  </body>\n</html>\n"
  },
  {
    "path": "addon/chrome/content/prefpanel.css",
    "content": ":root {\n  --dropdown-bg-color: #f9f9f9;\n  --dropdown-text-color: #333;\n  --dropdown-border-color: #ccc;\n  --dropdown-shadow-color: rgba(0, 0, 0, 0.2);\n}\n@media (prefers-color-scheme: dark) {\n  :root {\n    --dropdown-bg-color: #333;\n    --dropdown-text-color: #f9f9f9;\n    --dropdown-border-color: #555;\n    --dropdown-shadow-color: rgba(255, 255, 255, 0.2);\n  }\n}\n.dropdown-content {\n  display: none;\n  position: absolute;\n  background-color: var(--dropdown-bg-color);\n  border: 1px solid var(--dropdown-border-color);\n  box-shadow: 0px 8px 16px var(--dropdown-shadow-color);\n  z-index: 1;\n  padding: 8px 16px;\n  font-size: 12px;\n  cursor: pointer;\n  border-radius: 4px;\n  color: var(--dropdown-text-color);\n}\n.dropdown-content label {\n  display: block;\n  margin: 5px 0;\n  cursor: pointer;\n  color: var(--dropdown-text-color);\n}\n.show {\n  display: block;\n}\n\n.hidden {\n  display: none;\n}\n\n.help-icon {\n  margin-top: 5px;\n  margin-bottom: 5px;\n  width: 20px;\n}\n"
  },
  {
    "path": "addon/chrome/content/progress.xhtml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<?xml-stylesheet href=\"chrome://global/skin/\" type=\"text/css\"?> <?xml-stylesheet\nhref=\"chrome://zotero/skin/zotero.css\" type=\"text/css\"?> <?xml-stylesheet\nhref=\"chrome://zotero-platform/content/zotero-react-client.css\"\ntype=\"text/css\"?> <?xml-stylesheet\nhref=\"chrome://zotero-platform/content/zotero.css\" type=\"text/css\"?>\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\">\n<html\n  xmlns=\"http://www.w3.org/1999/xhtml\"\n  xmlns:xul=\"http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul\"\n  xmlns:html=\"http://www.w3.org/1999/xhtml\"\n>\n  <head>\n    <meta charset=\"UTF-8\" />\n    <xul:linkset>\n      <link rel=\"localization\" href=\"__addonRef__-progress.ftl\" />\n    </xul:linkset>\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title data-l10n-id=\"title\"></title>\n    <style>\n      /* 默认样式（Light Mode） */\n      html {\n        height: 100%;\n      }\n\n      body {\n        display: flex;\n        flex-direction: column;\n        font-family: Arial, sans-serif;\n        padding: 8px 8px 40px; /* 底部留出按钮空间 */\n        box-sizing: border-box; /* 确保尺寸计算包含padding */\n        border-radius: 4px;\n        background-color: #ffffff;\n        color: #000000;\n        height: 90vh; /* 使用视口高度 */\n      }\n\n      h1 {\n        flex-shrink: 0; /* 禁止标题收缩 */\n        margin: 0 0 10px 0;\n      }\n\n      /* 可滚动区域 */\n      #task-list {\n        flex: 1; /* 占据剩余空间 */\n        overflow-y: auto; /* 垂直滚动 */\n        min-height: 0; /* 允许内容压缩 */\n      }\n\n      /* 调整底部按钮定位 */\n      div.buttons {\n        position: fixed;\n        top: 332px;\n        right: 35px;\n        background: inherit; /* 继承背景色 */\n        border-radius: 4px;\n      }\n\n      div.hidden {\n        display: none;\n      }\n\n      .task {\n        margin-bottom: 10px;\n        border: 1px solid #ddd;\n        padding: 10px;\n        border-radius: 5px;\n        background-color: #f9f9f9;\n      }\n\n      .task-header {\n        display: flex;\n        align-items: center;\n        cursor: pointer;\n        position: relative;\n      }\n\n      .task-status {\n        margin-right: 10px;\n        font-size: 20px;\n      }\n\n      .task-title {\n        font-weight: bold;\n        flex-grow: 1;\n      }\n\n      .toggle-icon {\n        margin-left: 10px;\n        font-size: 14px;\n        transition: transform 0.2s;\n      }\n\n      .search-results-container {\n        display: flex;\n        align-items: flex-start; /* 确保按钮和搜索结果对齐 */\n        margin-left: 20px;\n        margin-top: 10px;\n      }\n\n      .confirm-button {\n        margin-right: 4px; /* 按钮放在搜索结果左侧 */\n        margin-left: -20px;\n        padding: 4px 8px;\n        background: #4caf50;\n        color: white;\n        border: none;\n        border-radius: 3px;\n        cursor: pointer;\n        display: none; /* 默认隐藏 */\n        font-size: 12px;\n        width: 50px;\n        margin-top: 1.5px;\n      }\n\n      .confirm-button:disabled {\n        background-color: #ccc;\n        cursor: not-allowed;\n      }\n\n      .search-results {\n        flex-grow: 1; /* 搜索结果占据剩余空间 */\n      }\n\n      .search-result {\n        margin-bottom: 4px;\n        padding: 4px;\n        border: 1px solid #eee;\n        border-radius: 3px;\n        background-color: #ffffff;\n        display: flex;\n        align-items: center;\n      }\n\n      .search-result input[type=\"radio\"] {\n        margin-right: 8px;\n      }\n\n      .search-result .info {\n        flex-grow: 1;\n      }\n\n      .search-result .source {\n        color: #666;\n        font-size: 0.9em;\n      }\n\n      .search-result .title {\n        font-weight: bold;\n      }\n\n      .task.completed .search-result input[type=\"radio\"] {\n        pointer-events: none; /* 禁用单选按钮 */\n        opacity: 0.5; /* 降低透明度 */\n      }\n\n      .task-msg {\n        width: 15px;\n        vertical-align: middle;\n        cursor: pointer;\n        display: inline-block;\n        animation: attention-shake 2s ease-in-out infinite;\n      }\n\n      /* 吸引注意力的动画：抖动 + 缩放 + 脉冲 */\n      @keyframes attention-shake {\n        0%,\n        100% {\n          transform: scale(1) rotate(0deg);\n          opacity: 1;\n        }\n        10% {\n          transform: scale(1.3) rotate(-10deg);\n        }\n        20% {\n          transform: scale(1.3) rotate(10deg);\n        }\n        30% {\n          transform: scale(1.3) rotate(-10deg);\n        }\n        40% {\n          transform: scale(1.3) rotate(10deg);\n        }\n        50% {\n          transform: scale(1.5) rotate(0deg);\n          opacity: 0.8;\n        }\n        60% {\n          transform: scale(1.2) rotate(0deg);\n        }\n        70% {\n          transform: scale(1) rotate(0deg);\n          opacity: 1;\n        }\n      }\n\n      /* Wrapper for icon + popover hover area */\n      .task-msg-wrapper {\n        position: relative;\n        display: inline-block;\n        margin-left: 8px;\n        vertical-align: middle;\n      }\n\n      /* 鼠标悬停时暂停动画并放大 */\n      .task-msg-wrapper:hover .task-msg,\n      .task-msg.active {\n        animation-play-state: paused;\n        transform: scale(1.5);\n        filter: drop-shadow(0 0 3px rgba(255, 193, 7, 0.8));\n      }\n\n      /* Custom popover for task messages */\n      .task-msg-popover {\n        position: absolute;\n        left: 0;\n        top: calc(100% + 8px);\n        padding: 12px 16px;\n        background: rgba(255, 255, 255, 0.82);\n        backdrop-filter: blur(16px) saturate(180%);\n        -webkit-backdrop-filter: blur(16px) saturate(180%);\n        border: 1px solid rgba(0, 0, 0, 0.08);\n        border-radius: 10px;\n        box-shadow:\n          0 8px 32px rgba(0, 0, 0, 0.12),\n          0 2px 8px rgba(0, 0, 0, 0.06);\n        font-size: 12px;\n        font-weight: normal;\n        line-height: 1.7;\n        white-space: pre-wrap;\n        word-break: break-all;\n        max-width: 420px;\n        min-width: 240px;\n        max-height: 250px;\n        overflow-y: auto;\n        z-index: 100;\n        color: #1d1d1f;\n        /* Hover transition */\n        opacity: 0;\n        visibility: hidden;\n        transform: translateY(4px);\n        transition:\n          opacity 0.2s ease,\n          visibility 0.2s ease,\n          transform 0.2s ease;\n        pointer-events: none;\n      }\n\n      /* Arrow */\n      .task-msg-popover::before {\n        content: \"\";\n        position: absolute;\n        top: -6px;\n        left: 12px;\n        width: 12px;\n        height: 12px;\n        background: rgba(255, 255, 255, 0.82);\n        backdrop-filter: blur(16px) saturate(180%);\n        -webkit-backdrop-filter: blur(16px) saturate(180%);\n        border-top: 1px solid rgba(0, 0, 0, 0.08);\n        border-left: 1px solid rgba(0, 0, 0, 0.08);\n        transform: rotate(45deg);\n      }\n\n      .task-msg-popover.visible {\n        opacity: 1;\n        visibility: visible;\n        transform: translateY(0);\n        pointer-events: auto;\n      }\n\n      .task-msg-popover a {\n        color: #0066cc;\n        text-decoration: none;\n        border-bottom: 1px solid rgba(0, 102, 204, 0.3);\n        transition:\n          border-color 0.15s ease,\n          color 0.15s ease;\n        cursor: pointer;\n      }\n\n      .task-msg-popover a:hover {\n        color: #0047ab;\n        border-bottom-color: rgba(0, 71, 171, 0.6);\n      }\n\n      /* 黑暗模式样式 */\n      @media (prefers-color-scheme: dark) {\n        body {\n          background-color: #121212;\n          color: #e0e0e0;\n        }\n\n        .task {\n          background-color: #1e1e1e;\n          border-color: #444;\n        }\n\n        .search-result,\n        div.buttons {\n          background-color: #2d2d2d;\n          border-color: #444;\n        }\n\n        .search-result .source {\n          color: #999;\n        }\n\n        .search-result .title {\n          color: #e0e0e0;\n        }\n\n        .task-status {\n          color: #e0e0e0;\n        }\n\n        .task-title,\n        div.buttons {\n          color: #e0e0e0;\n        }\n\n        .toggle-icon {\n          color: #e0e0e0;\n        }\n\n        .confirm-button {\n          background-color: #4caf50;\n        }\n\n        .confirm-button:disabled {\n          background-color: #666;\n        }\n\n        .task-msg-popover {\n          background: rgba(40, 40, 40, 0.85);\n          border-color: rgba(255, 255, 255, 0.1);\n          color: #e0e0e0;\n          box-shadow:\n            0 8px 32px rgba(0, 0, 0, 0.4),\n            0 2px 8px rgba(0, 0, 0, 0.2);\n        }\n\n        .task-msg-popover::before {\n          background: rgba(40, 40, 40, 0.85);\n          border-top-color: rgba(255, 255, 255, 0.1);\n          border-left-color: rgba(255, 255, 255, 0.1);\n        }\n\n        .task-msg-popover a {\n          color: #8ab4f8;\n          border-bottom-color: rgba(138, 180, 248, 0.3);\n        }\n\n        .task-msg-popover a:hover {\n          color: #aecbfa;\n          border-bottom-color: rgba(174, 203, 250, 0.6);\n        }\n      }\n    </style>\n  </head>\n  <body>\n    <h1 data-l10n-id=\"task-list\"></h1>\n    <div class=\"hidden\">\n      <p id=\"msg1\" data-l10n-id=\"confirm-close\"></p>\n    </div>\n    <div id=\"task-list\"></div>\n    <div class=\"buttons\">\n      <button id=\"button-cancel\">Close</button>\n      <button id=\"button-ok\" style=\"display: none\">OK</button>\n    </div>\n  </body>\n  <script>\n    //<![CDATA[\n    if (window.arguments) {\n      document.addEventListener(\"DOMContentLoaded\", (ev) => {\n        Services.scriptloader.loadSubScript(\n          \"chrome://zotero/content/include.js\",\n          this,\n        );\n\n        window.arguments[0]._initPromise.resolve();\n      });\n    }\n\n    // window.addEventListener(\"beforeunload\", (e) => {\n    //   Zotero.Jasminum.data.myCookieSandbox._CNKIHomeCookieBox = null;\n    // });\n\n    // 模拟数据\n    const tasks = [\n      {\n        id: \"1\",\n        type: \"attachment\",\n        item: { getField: () => \"论文标题 1\" },\n        status: \"success\",\n        message: \"This is error msg1\",\n        searchResult: [\n          {\n            source: \"Source A\",\n            title: \"Result 1\",\n            url: \"https://example.com/1\",\n          },\n          {\n            source: \"Source B\",\n            title: \"Result 2\",\n            url: \"https://example.com/2\",\n          },\n        ],\n      },\n      {\n        id: \"2\",\n        type: \"snapshot\",\n        item: { getField: () => \"论文标题 2\" },\n        status: \"processing\",\n        message: \"This is error msg2\",\n      },\n      {\n        id: \"3\",\n        type: \"attachment\",\n        item: { getField: () => \"论文标题 3\" },\n        status: \"fail\",\n        message:\n          \"抓取失败\\n[小红书l0o0](https://www.xiaohongshu.com/user/profile/6153b4fa000000001f03ac8c)\",\n      },\n    ];\n\n    // 状态图标映射\n    const statusIcons = {\n      waiting: \"⏳\",\n      processing: \"🔄\",\n      multiple_results: \"🔍\",\n      success: \"✅\",\n      fail: \"❌\",\n    };\n\n    // Convert [text](url) and bare URLs to clickable links\n    function linkifyText(text) {\n      return text\n        .split(\"\\n\")\n        .map((line) =>\n          line\n            .replace(\n              /\\[([^\\]]+)\\]\\((https?:\\/\\/[^)]+)\\)/g,\n              '<a href=\"#\" data-url=\"$2\">$1</a>',\n            )\n            .replace(\n              /(https?:\\/\\/[^\\s<]+)(?![^<]*<\\/a>)/g,\n              '<a href=\"#\" data-url=\"$1\">$1</a>',\n            ),\n        )\n        .join(\"<br>\");\n    }\n\n    // 渲染任务列表\n    function renderTaskList(tasks) {\n      const taskList = document.getElementById(\"task-list\");\n      if (!taskList) return;\n\n      taskList.innerHTML = tasks\n        .map(\n          (task) => `\n            <div class=\"task\" data-task-id=\"${task.id}\">\n              <div class=\"task-header\">\n                <span class=\"task-status\">${statusIcons[task.status]}</span>\n                <span class=\"task-title\">${task.item.getField(\"title\")}\n                  <span class=\"task-msg-wrapper\">\n                    <span class=\"task-msg\" id=\"task-msg-${task.id}\">⚠️</span>\n                    <div class=\"task-msg-popover\" id=\"task-msg-popover-${task.id}\">${linkifyText(task.message)}</div>\n                  </span>\n                </span>\n                ${\n                  task.searchResult && task.searchResult.length > 0\n                    ? `<span class=\"toggle-icon\" id=\"toggle-icon-${task.id}\">▼</span>`\n                    : \"\"\n                }\n              </div>\n              ${\n                task.searchResult && task.searchResult.length > 0\n                  ? `\n                    <div class=\"search-results-container\" id=\"search-results-container-${task.id}\">\n                      <button class=\"confirm-button\" data-task-id=\"${task.id}\">确认</button>\n                      <div class=\"search-results\" id=\"search-results-${task.id}\">\n                        ${task.searchResult\n                          .map(\n                            (result, index) => `\n                              <div class=\"search-result\">\n                                <input type=\"radio\" name=\"task-${task.id}\" data-task-id=\"${task.id}\" data-result-index=\"${index}\" />\n                                <div class=\"info\">\n                                  <span class=\"source\" data-l10n-id=\"result-source\" data-l10n-args='{\"source\": \"${result.source}\"}'></span>\n                                  <span class=\"title\" data-l10n-id=\"result-title\" data-l10n-args='{\"title\": \"${result.title}\"}'></span>\n                                  <span class=\"score\" data-l10n-id=\"result-score\" data-l10n-args='{\"score\": \"${result.score}\"}'></span>\n                                </div>\n                              </div>\n                            `,\n                          )\n                          .join(\"\")}\n                      </div>\n                    </div>\n                  `\n                  : \"\"\n              }\n            </div>\n          `,\n        )\n        .join(\"\");\n    }\n\n    // 切换搜索结果的显示/隐藏\n    function toggleSearchResults(taskId) {\n      const searchResultsContainer = document.getElementById(\n        `search-results-container-${taskId}`,\n      );\n      const toggleIcon = document.getElementById(`toggle-icon-${taskId}`);\n      console.log(\"click\", `#search-results-container-${taskId}`);\n      if (searchResultsContainer && toggleIcon) {\n        if (searchResultsContainer.style.display === \"none\") {\n          searchResultsContainer.style.display = \"\";\n          toggleIcon.textContent = \"▲\"; // 展开时显示向上箭头\n        } else {\n          searchResultsContainer.style.display = \"none\";\n          toggleIcon.textContent = \"▼\"; // 收起时显示向下箭头\n        }\n      }\n    }\n\n    // Close all open popovers\n    function closeAllPopovers() {\n      document.querySelectorAll(\".task-msg-popover.visible\").forEach((p) => {\n        p.classList.remove(\"visible\");\n      });\n      document.querySelectorAll(\".task-msg.active\").forEach((i) => {\n        i.classList.remove(\"active\");\n      });\n    }\n\n    // Show popover on hover, close on click outside\n    document.getElementById(\"task-list\").addEventListener(\n      \"mouseenter\",\n      (event) => {\n        const wrapper = event.target.closest(\".task-msg-wrapper\");\n        if (!wrapper) return;\n        closeAllPopovers();\n        const popover = wrapper.querySelector(\".task-msg-popover\");\n        const icon = wrapper.querySelector(\".task-msg\");\n        if (popover) popover.classList.add(\"visible\");\n        if (icon) icon.classList.add(\"active\");\n      },\n      true,\n    );\n\n    document.addEventListener(\"click\", (event) => {\n      if (\n        !event.target.closest(\".task-msg-popover\") &&\n        !event.target.closest(\".task-msg-wrapper\")\n      ) {\n        closeAllPopovers();\n      }\n    });\n\n    // 事件委托：绑定点击事件\n    document.getElementById(\"task-list\").addEventListener(\"click\", (event) => {\n      console.log(\"click\", event.target);\n\n      // Handle popover link clicks via Zotero.launchURL\n      const link = event.target.closest(\".task-msg-popover a[data-url]\");\n      if (link) {\n        event.preventDefault();\n        event.stopPropagation();\n        const url = link.getAttribute(\"data-url\");\n        if (url) {\n          if (typeof Zotero !== \"undefined\") {\n            Zotero.launchURL(url);\n          } else {\n            window.open(url, \"_blank\");\n          }\n        }\n        return;\n      }\n\n      const taskHeader = event.target.closest(\".task-header\");\n      if (taskHeader) {\n        const taskId = taskHeader.closest(\".task\").getAttribute(\"data-task-id\");\n        toggleSearchResults(taskId);\n      }\n\n      const radio = event.target.closest('input[type=\"radio\"]');\n      if (radio) {\n        const taskId = radio.getAttribute(\"data-task-id\");\n        const confirmButton = document.querySelector(\n          `.confirm-button[data-task-id=\"${taskId}\"]`,\n        );\n        if (confirmButton) {\n          confirmButton.style.display = radio.checked ? \"inline-block\" : \"none\";\n        }\n      }\n\n      const confirmButton = event.target.closest(\".confirm-button\");\n      if (confirmButton) {\n        const taskId = confirmButton.getAttribute(\"data-task-id\");\n        const selectedRadio = document.querySelector(\n          `input[type=\"radio\"][data-task-id=\"${taskId}\"]:checked`,\n        );\n        if (selectedRadio) {\n          const resultIndex = selectedRadio.getAttribute(\"data-result-index\");\n          console.log(`已确认选择：${taskId} (${resultIndex})`);\n          Zotero.Jasminum.taskRunner.resumeTask(taskId, resultIndex);\n\n          // 标记任务为已完成\n          const taskElement = confirmButton.closest(\".task\");\n          taskElement.classList.add(\"completed\");\n\n          // 禁用所有单选按钮\n          const radios = taskElement.querySelectorAll('input[type=\"radio\"]');\n          radios.forEach((radio) => {\n            radio.disabled = true;\n          });\n\n          // 隐藏确认按钮\n          confirmButton.style.display = \"none\";\n        }\n      }\n    });\n\n    document.getElementById(\"button-cancel\").addEventListener(\"click\", (e) => {\n      console.log(e);\n      const unfinishedTasks = Zotero.Jasminum.taskRunner.tasks.filter(\n        (t) => t.status != \"fail\" && t.status != \"success\",\n      );\n      if (unfinishedTasks.length > 0) {\n        const msg = document\n          .getElementById(\"msg1\")\n          .textContent.replace(\"xxx\", unfinishedTasks.length);\n        const userConfirmed = confirm(msg);\n        if (userConfirmed) {\n          window.close();\n        }\n      } else {\n        window.close();\n      }\n    });\n\n    // 初始化渲染\n    if (window.arguments == undefined) {\n      renderTaskList(tasks);\n      // 默认展开有多个搜索结果的任务\n      tasks.forEach((task) => {\n        if (task.searchResult && task.searchResult.length > 0) {\n          const searchResults = document.getElementById(\n            `search-results-${task.id}`,\n          );\n          const toggleIcon = document.getElementById(`toggle-icon-${task.id}`);\n          if (searchResults && toggleIcon) {\n            searchResults.style.display = \"block\"; // 默认展开\n            toggleIcon.textContent = \"▲\"; // 默认显示向上箭头\n          }\n        }\n      });\n    }\n\n    // window.addEventListener(\"DOMWindowClose\", (e) => {\n    //   const shouldClose = confirm(\"确定要关闭窗口吗？\");\n    //   if (!shouldClose) {\n    //     e.preventDefault(); // 阻止关闭\n    //   }\n    // });\n    //]]>\n  </script>\n</html>\n"
  },
  {
    "path": "addon/locale/en-US/addon.ftl",
    "content": "plugin-name = Jasminum\nprefs-table-title = Title\nprefs-table-detail = Detail\ntabpanel-lib-tab-label = Lib Tab\ntabpanel-reader-tab-label = Reader Tab\n\n# Preference\nselect-download-folder = Select download folder\nget-Chinese-styles = Get Chinese Styles\ninfo-translators-cn-updaing = Chinese translators are under updating.\ninfo-best-speed-source-updated = Updated to fastest source: { $source }\ninfo-best-speed-source-failed = Failed to select fastest source, please check network connection\n\n\n# Preference translator table\nth-filename = File name\nth-label = Label\nth-local-update-time = Local update time\nth-remote-update-time = Remote update time\n\n# Help menu\nhelp-menu-chinese = Zotero Chinese Community\nhelp-menu-wiki = Zotero Wiki\nhelp-menu-addons = Addon Store\nhelp-menu-csl = Donwload more CSL\nhelp-menu-translator = Help with Chinese literature capture\n\n# Menu\nmenu-metadata = Metadata(CN)\nmenuitem-retrieveMetadata = Find article metadata\nmenuitem-retrieveMetadataForBook = Find book metadata\n\nmenuitem-find-attachment = Find attachment in Folder\nmenuitem-import-attachments = Import attachments from Folder\n\nmenu-tools = Tools\nmenuitem-mergeName = Concat Name\nmenuitem-splitName = Split Name\nmenuitem-updateCNKICite = Update CNKI citation\n\n# ui\nCNKIcitation = CNKICite\n\n# popup window\ncitation = Cite\nno-chinese-item-for-citation = Only Chinese items can find CNKI citation\nupdate-translators-start = Start updating translators\nupdate-successfully = Update successfully: { $name }\nupdate-failed = Update failed: { $name }\nupdate-skipped = Up to date: { $name }\nupdate-translators-complete = Update translators completed, Success: { $successCounts }, Failed: { $failCounts }, Up to date: { $skipCounts }\nno-item-need-attachment = No item need attachment\nno-attachments-found = No attachments found (PDF, CAJ, etc.)\nimport-attachments-success = Import attachments from folder successfully\nimporting-attachments-is-running = An attachment import task is already running. Please try again later.\n\n# outline\noutline = Show Bookmark (By Jasminum)\noutline-expand-all = Expand all\noutline-collapse-all = Collapse all\noutline-add = Add bookmark\noutline-delete = Delete bookmark\noutline-save-to-pdf = Save outline to PDF\noutline-empty-prompt = Please click the button above { $icon } to create a bookmark\noutline-delete-confirm = This node has child nodes, do you want to delete?\n  {\" \"}\n  If you delete, all child nodes will be deleted.\n\n# bookmark\nbookmark = Show Bookmarks (By Jasminum)\nbookmark-add = Add bookmark\nbookmark-delete = Delete bookmark\n\n# Progress window\ntask-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)\ntask-already-exists = Task already exists: { $title }"
  },
  {
    "path": "addon/locale/en-US/mainWindow.ftl",
    "content": "item-section-example1-head-text =\n    .label = Plugin Template: Item Info\nitem-section-example1-sidenav-tooltip =\n    .tooltiptext = This is Plugin Template section (item info)\nitem-section-example2-head-text =\n    .label = Plugin Template: Reader [{$status}]\nitem-section-example2-sidenav-tooltip =\n    .tooltiptext = This is Plugin Template section (reader)\nitem-section-example2-button-tooltip =\n    .tooltiptext = Unregister this section\n"
  },
  {
    "path": "addon/locale/en-US/preferences-main.ftl",
    "content": "# Metadata Settings\npref-group-metadata = Chinese Metadata Retrieval Settings\nlabel-isMainlandChina = \n  .label = Currently located in Chinese Mainland (excluding Hong Kong, Macao and Taiwan), uncheck for overseas users\nlabel-autoupdate-metadata =\n  .label = Automatically retrieve metadata from CNKI when adding Chinese PDF/CAJ files\nlabel-rename =\n  .label = Rename attachments based on metadata (requires Attanger or zotmoov plugin)\nlabel-namepattern = Filename Parsing Template\nlabel-namepattern-auto =\n  .label = Smart Recognition\n  .tooltiptext = Use Jasmine's built-in algorithm to intelligently identify authors or titles from filenames\nlabel-namepattern-tg =\n  .label = Title_Author (Default)\n  .tooltiptext = Rename files in the format \"Title_FirstAuthor,\" e.g., \"Design and Application of Redundant Avionics Systems for Drones_Yang Lu.caj\"\nlabel-namepattern-t =\n  .label = Title\n  .tooltiptext = Rename files using the \"Title\" format, e.g., \"Design and Application of Redundant Avionics Systems for Drones.caj\"\nlabel-namepattern-info = Filename recognition template. Select a format from the dropdown or enter directly\nlabel-namepattern-custom =\n  .label = Custom\n  .tooltiptext = Set custom rules to extract title and author information from filenames for metadata retrieval\nlabel-choose-namepattern =\n  .label = Select Template\n\nlabel-metadata-source = Metadata Retrieval Source\nlabel-choose-source =\n  .label = Select Data Source\nlabel-metadata-source-cnki =\n  .label = CNKI (China National Knowledge Infrastructure)\nlabel-metadata-source-cvip =\n  .label = VIP Journals (Chinese VIP Information)\nlabel-pdf-match-folder = Attachment Matching Folder\nlabel-choose-folder =\n  .label = Select Folder\nnamepattern-desc =\n  .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.\n\n# Transator Settings\npref-group-translators = Chinese Translator Settings\nlabel-translator-source = Translator Download Source\nlabel-best-speed = Choose Fastest Source\ntranslatorSource-desc =\n  .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.\nlabel-auto-update-translators =\n  .label = Automatically Update Translators\nlabel-translators-force-update =\n  .label = Update Immediately\nlabel-translators-detail = Translator Details\nlabel-translators-detail-click = Click to View\n\n# Attachment Settings\npref-group-attachment = Local Attachment Search Settings\nattachment-folder-desc = \n  .tooltiptext = Search for attachments in the download directory and match them to entries that are missing attachments.\n  Set this to your browser's download directory, and the plugin can batch import and search for attachments from the download directory.\nlabel-pdf-match-folder = Attachment Download Folder\naction-after-import = After matching attachments to entries, what to do with the original downloaded files:\nlabel-choose-folder =\n  .label = Choose Folder\nnothing-label =\n  .label = Do Nothing\nbackup-label =\n  .label = Backup Attachment\ndelete-label =\n  .label = Delete Attachment\naction-after-import-desc =\n  .tooltiptext = After successfully matching attachments to entries, you can choose one of the following actions: \n  1. Do Nothing: No action is taken, and the downloaded files remain in the download directory. \n  2. Backup Attachment: Back up the original downloaded files to a specified directory. \n  3. Delete Attachment: Delete the original downloaded files (the attachments are already matched and saved in Zotero).\n\n# Outline Bookmark Settings\npref-group-bookmark = Outline Bookmark Settings\nlabel-disableZoteroOutline = \n  .label = Disable Zotero's Built-in Outline\nlabel-enableBookmark = \n  .label = Enable Outline Bookmark\noutline-desc = \n  .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.\n\n# Tool Settings\npref-group-tools = Tool Settings\nlabel-auto-split-name =\n  .label = Automatically split first name and last name when adding new items\nlabel-split-en-name = \n  .label = Include English names when splitting/merging names\nlabel-language = Manually Set Language\nlabel-tools-info-1 = 💡 The \nlabel-tools-info-2 = provides richer metadata inspection functionality\nlabel-tools-linter = Linter Plugin\n\n# WPS Plugin Installation\npref-group-wps = WPS Zotero Plugin\nlabel-wps = Install Zotero Add-on for WPS\nlabel-wps-help = Usage Help\nlabel-install-wps-plugin-click =\n  .label = Click to Install\n\n# About\npref-group-about = About\npref-help = Version { $version } Build { $time } ❤️\nlabel-zotero-chinese = Zotero Chinese Community\npref-enable =\n  .label = Enable"
  },
  {
    "path": "addon/locale/en-US/preferences-translators.ftl",
    "content": "title = Chinese Community Translators List\n\ngithub-link =\n  .label =  Project Homepage\n\nsearch-box =\n  .placeholder = Search translators\n\n# Links\nhow-to-update-translators = How to update translators?\ntranslators-dashboard = Translators Dashboard\n\n# Buttons\nrequest-new-translator = Request new translator\nreport-translator-bug = Report translator bug"
  },
  {
    "path": "addon/locale/en-US/progress.ftl",
    "content": "title = Jasmine Task Window\ntask-list = Task List\nresult-source = Source: { source }\nresult-title = Title: { title }\nresult-score = Match Score: { score }\nconfirm-close = There are still xxx pending tasks. Close the window anyway?"
  },
  {
    "path": "addon/locale/zh-CN/addon.ftl",
    "content": "plugin-name = 茉莉花\n\nprefs-table-title = 标题\nprefs-table-detail = 详情\ntabpanel-lib-tab-label = 库标签\ntabpanel-reader-tab-label = 阅读器标签\n\n# Preference\nselect-download-folder = 选择下载文件保存目录\nget-Chinese-styles = 获取中文社区样式…\ninfo-translators-cn-updaing = 中文转换器正在更新中\ninfo-best-speed-source-updated = 已更新为最快源：{ $source }\ninfo-best-speed-source-failed = 选择最快源失败，请检查网络连接\n\n# Preference translator table\nth-filename = 文件名\nth-label = 标签\nth-local-update-time = 本地更新时间\nth-remote-update-time = 远程更新时间\n\n# Help menu\nhelp-menu-chinese = Zotero 中文社区\nhelp-menu-wiki = Zotero 使用文档\nhelp-menu-addons = 插件商店\nhelp-menu-csl = CSL样式下载\nhelp-menu-translator = 中文文献抓取异常修复\n\n# Menu\nmenu-metadata = 茉莉花抓取\nmenuitem-retrieveMetadata = 抓取期刊元数据\nmenuitem-retrieveMetadataForBook = 抓取书籍元数据\n\nmenuitem-find-attachment = 在下载文件夹中查找附件\nmenuitem-import-attachments = 从下载文件夹中导入附件\n\nmenu-tools = 小工具\nmenuitem-mergeName = 合并姓名\nmenuitem-splitName = 拆分姓名\nmenuitem-updateCNKICite = 更新知网引用数\n\n# ui\nCNKIcitation = 知网引用数\n\n# popup window\ncitation = 引用\nno-chinese-item-for-citation = 只有中文条目才能抓取引用数哦😀\nupdate-translators-start = 开始更新转换器\nupdate-successfully = 更新成功：{ $name }\nupdate-failed = 更新失败：{ $name }\nupdate-skipped = 已最新：{ $name }\nupdate-translators-complete = 转换器更新完成，成功：{ $successCounts }, 失败：{ $failCounts }， 已最新：{ $skipCounts }\nno-item-need-attachment = 这些条目已有附件或属于非学术类型条目\nno-attachments-found = 未找到可导入的附件（PDF, CAJ等）\nimport-attachments-success = 从文件夹中导入附件成功\nimporting-attachments-is-running = 已有一个附件导入任务正在运行，请稍后再试。\n\n# outline\noutline = 显示书签大纲（茉莉花）\noutline-expand-all = 展开所有\noutline-collapse-all = 收起所有\noutline-add = 添加书签\noutline-delete = 删除书签\noutline-save-to-pdf = 将大纲保存到PDF文件\noutline-edit-placeholder = 请输入书签\noutline-empty-prompt = 请点击上方按钮{ $icon }创建书签\noutline-delete-confirm = 该节点有子节点，是否删除?\n  {\" \"}\n  如果删除，则所有子节点也会被删除。\n\n# bookmark\nbookmark = 显示书签（茉莉花）\nbookmark-add = 添加书签\nbookmark-delete = 删除书签\n\n# Progress window\ntask-msg-header = 如果抓取异常需要帮助，请截图以下内容并联系开发者：[小红书l0o0](https://www.xiaohongshu.com/user/profile/6153b4fa000000001f03ac8c)\ntask-already-exists = 任务已存在：{ $title }"
  },
  {
    "path": "addon/locale/zh-CN/mainWindow.ftl",
    "content": "item-section-example1-head-text =\n    .label = 插件模板: 条目信息\nitem-section-example1-sidenav-tooltip =\n    .tooltiptext = 这是插件模板面板(条目信息)\nitem-section-example2-head-text =\n    .label = 插件模板: 阅读器[{$status}]\nitem-section-example2-sidenav-tooltip =\n    .tooltiptext = 这是插件模板面板(阅读器)\nitem-section-example2-button-tooltip =\n    .tooltiptext = 移除此面板\n"
  },
  {
    "path": "addon/locale/zh-CN/preferences-main.ftl",
    "content": "# 元数据设置\npref-group-metadata = 中文元数据抓取设置\nlabel-isMainlandChina = \n  .label = 当前位于中国大陆（不包括中国香港、中国澳门及中国台湾），海外用户请取消勾选\nlabel-autoupdate-metadata = \n  .label = 添加中文PDF/CAJ时自动从知网抓取元数据\nlabel-rename = \n  .label = 根据元数据重命名附件（依赖Attanger或zotmoov插件）\nlabel-namepattern = 文件名解析模板\nlabel-namepattern-auto = \n  .label = 智能识别\n  .tooltiptext = 利用茉莉花内置的算法智能识别文件名的作者或标题\nlabel-namepattern-tg = \n  .label = 标题_作者(默认设置)\n  .tooltiptext =「标题_第一作者」格式命名文件，如「无人机多余度航空电子系统设计与应用_杨璐.caj」\nlabel-namepattern-t = \n  .label = 标题\n  .tooltiptext =「标题」格式命名文件，如「无人机多余度航空电子系统设计与应用.caj」\nlabel-namepattern-info = 文件名识别模板，从下拉菜单中选择对应格式或直接输入\nlabel-namepattern-custom = \n  .label = 自定义\n  .tooltiptext = 设置自定义规则，识别文件名中的标题、作者信息用于元数据抓取\nlabel-choose-namepattern =\n  .label = 选择模板\n\nlabel-metadata-source = 元数据抓取来源\nlabel-choose-source =\n  .label = 选择数据源\nlabel-metadata-source-cnki =\n  .label = 中国知网CNKI\nlabel-metadata-source-cvip =\n  .label = 维普期刊CVIP\n\nnamepattern-desc = \n  .tooltiptext = 根据文件名抓取知网元数据，文件名格式设置:\n  {\"{\"}%t{\"}\"}=标题，{\"{\"}%g{\"}\"}=作者，{\"{\"}%y{\"}\"}=年份，{\"{\"}%j{\"}\"}=其他（例如来源信息）；分隔符依实情指定，可连续使用多个；不用考虑文件后缀名。\n  默认使用{\"{\"}%t{\"}\"}_{\"{\"}%g{\"}\"}，可识别大部分知网下载的文件名格式，包括文件名只包括标题无分隔符号。\n\n# 附件查找设置\npref-group-attachment = 本地附件查找设置\nattachment-folder-desc = \n  .tooltiptext = 从下载目录中查找附件，并匹配到缺少附件的条目中。\n  此处请设置为浏览器的下载目录，插件就可以批量从下载目录中导入和查询附件。\nlabel-pdf-match-folder = 附件下载文件夹\naction-after-import = 附件匹配到条目之后，如何处理原始下载的附件文件：\nlabel-choose-folder =\n  .label = 选择文件夹\nnothing-label =\n  .label = 无须处理\nbackup-label =\n  .label = 备份附件\ndelete-label =\n  .label = 删除附件\naction-after-import-desc =\n  .tooltiptext = 附件成功匹配到条目之后，您可以选择以下操作：\n  1. 无须处理：不做任何操作，下载的附件还在下载目录中；\n  2. 备份附件：将原始下载的附件文件备份到指定目录；\n  3. 删除附件：删除原始下载的附件文件，该附件已经匹配到条目，保存到Zotero中，可放心删除。\n\n# 转换器设置\npref-group-translators = 中文转换器设置\nlabel-translator-source = 转换器下载源\nlabel-best-speed = 选择最快源\ntranslatorSource-desc =\n  .tooltiptext = 选择转换器下载源，一般情况下不用切换。如果您无法下载中文转换器，可选择尝试其他源或点击 选择最快源 按钮。\nlabel-auto-update-translators = \n  .label = 自动更新转换器\nlabel-translators-force-update = \n  .label = 立即更新\nlabel-translators-detail = 转换器详情\nlabel-translators-detail-click = 点击查看\n\n# 大纲书签设置\npref-group-bookmark = 大纲书签设置\nlabel-disableZoteroOutline = \n  .label = 禁用 Zotero 自带的大纲\nlabel-enableBookmark = \n  .label = 启用大纲书签\noutline-desc = \n  .tooltiptext = 请注意，当您修改大纲或书签时，需要点击保存按钮才会保存到PDF文件中。默认将书签大纲信息与PDF文件分开保存。\n\n# 小工具设置\npref-group-tools = 小工具设置\nlabel-auto-split-name = \n  .label = 导入新条目时自动拆分姓名\nlabel-split-en-name = \n  .label = 拆分/合并姓名时包括英文名\nlabel-language = 手动设置语言\nlabel-tools-info-1 = 💡 \nlabel-tools-info-2 = 提供更丰富的元数据检查功能\nlabel-tools-linter = Linter 插件\n\n# WPS 插件安装\npref-group-wps = WPS Zotero 插件\nlabel-wps = 为 WPS 安装 Zotero 加载项\nlabel-wps-help = 使用帮助\nlabel-install-wps-plugin-click =\n  .label = 点击安装\n\n# 其他\npref-group-about = 关于\npref-help = 版本 { $version } 构建于 { $time } ❤️\nlabel-zotero-chinese = Zotero中文社区\npref-enable =\n  .label = 启用"
  },
  {
    "path": "addon/locale/zh-CN/preferences-translators.ftl",
    "content": "title = 中文社区转换器列表\n\ngithub-link = \n  .label = 项目主页\n\nsearch-box =\n  .placeholder = 搜索转换器\n\n# Links\nhow-to-update-translators = 如何更新转换器？\ntranslators-dashboard = 转换器看板\n\n# Buttons\nrequest-new-translator = 申请适配\nreport-translator-bug = 反馈错误\n"
  },
  {
    "path": "addon/locale/zh-CN/progress.ftl",
    "content": "title = 茉莉花任务窗口\ntask-list = 任务列表\nresult-source = 来源：{ source }\nresult-title = 标题：{ title }\nresult-score = 匹配度：{ score }\nconfirm-close = 还有 xxx 个任务未完成，是否关闭窗口？"
  },
  {
    "path": "addon/locale/zh-TW/addon.ftl",
    "content": "plugin-name = 茉莉花\n\nprefs-table-title = 標題\nprefs-table-detail = 詳細資料\ntabpanel-lib-tab-label = 圖書館標籤\ntabpanel-reader-tab-label = 閱讀器標籤\n\n# Preference\nselect-download-folder = 選擇下載檔案儲存目錄\nget-Chinese-styles = 取得中文社群樣式…\ninfo-translators-cn-updaing = 中文轉換器正在更新中\ninfo-best-speed-source-updated = 已更新為最快源：{ $source }\ninfo-best-speed-source-failed = 選擇最快源失敗，請檢查網路連接\n\n# Preference translator table\nth-filename = 檔案名稱\nth-label = 標籤\nth-local-update-time = 本地更新時間\nth-remote-update-time = 遠程更新時間\n\n# Help menu\nhelp-menu-chinese = Zotero 中文社群\nhelp-menu-wiki = Zotero 使用說明\nhelp-menu-addons = 插件商店\nhelp-menu-csl = CSL樣式下載\nhelp-menu-translator = 中文文獻抓取異常解決\n\n# Menu\nmenu-metadata = 元資料抓取\nmenuitem-retrieveMetadata = 抓取期刊元資料\nmenuitem-retrieveMetadataForBook = 抓取書籍元資料\n\nmenuitem-find-attachment = 在資料夾中尋找附件\nmenuitem-import-attachments = 從資料夾中導入附件\n\nmenu-tools = 小工具\nmenuitem-mergeName = 合併姓名\nmenuitem-splitName = 拆分姓名\nmenuitem-updateCNKICite = 更新知網引用數\n\n# ui\nCNKIcitation = 知網引用數\n\n# popup window\ncitation = 引用\nno-chinese-item-for-citation = 只有中文項目才能抓取引用數哦😀\nupdate-translators-start = 開始更新轉換器\nupdate-successfully = 更新成功：{ $name }\nupdate-failed = 更新失敗：{ $name }\nupdate-skipped = 已最新：{ $name }\nupdate-translators-complete = 轉換器更新完成，成功：{ $successCounts }, 失敗：{ $failCounts }， 已最新：{ $skipCounts }\nno-item-need-attachment = 項目已存在附件或屬於非學術類型\nimport-attachments-success = 從資料夾中導入附件成功\nimporting-attachments-is-running = 已有附件匯入任務正在執行，請稍後再試\nno-attachments-found = 未找到可匯入的附件（PDF、CAJ等）\n\n# outline\noutline = 顯示書籤大纲（茉莉花）\noutline-expand-all = 展開所有\noutline-collapse-all = 收起所有\noutline-add = 添加書籤\noutline-delete = 刪除書籤\noutline-save-to-pdf = 將大纲儲存到PDF檔案\noutline-edit-placeholder = 請輸入書籤\noutline-empty-prompt = 請點擊上方按鈕{ $icon }創建書籤\noutline-delete-confirm = 該節點有子節點，是否刪除?\n  {\" \"}\n  如果刪除，則所有子節點也會被刪除。\n\n# bookmark\nbookmark = 顯示書籤（茉莉花）\nbookmark-add = 添加書籤\nbookmark-delete = 刪除書籤\n\n# Progress window\ntask-msg-header = 如果抓取異常需要幫助，請截圖以下內容並聯繫開發者：[小紅書l0o0](https://www.xiaohongshu.com/user/profile/6153b4fa000000001f03ac8c)\ntask-already-exists = 已存在任務：{ $title }\n"
  },
  {
    "path": "addon/locale/zh-TW/mainWindow.ftl",
    "content": "item-section-example1-head-text =\n    .label = 插件模板: 条目信息\nitem-section-example1-sidenav-tooltip =\n    .tooltiptext = 这是插件模板面板(条目信息)\nitem-section-example2-head-text =\n    .label = 插件模板: 阅读器[{$status}]\nitem-section-example2-sidenav-tooltip =\n    .tooltiptext = 这是插件模板面板(阅读器)\nitem-section-example2-button-tooltip =\n    .tooltiptext = 移除此面板\n"
  },
  {
    "path": "addon/locale/zh-TW/preferences-main.ftl",
    "content": "# 元數據設定\npref-group-metadata = 中文元數據抓取設定\nlabel-isMainlandChina = \n  .label = 目前位於中國大陸（不包括中國香港、中國澳門及中國台灣），海外用戶請取消勾選\nlabel-autoupdate-metadata = \n  .label = 新增中文PDF/CAJ時自動從知網抓取元數據\nlabel-rename = \n  .label = 根據元數據重新命名附件（依賴Attanger或zotmoov插件）\nlabel-namepattern = 檔案名稱解析範本\nlabel-namepattern-auto = \n  .label = 智能識別\n  .tooltiptext = 利用茉莉花內建的演算法智能識別檔案名稱的作者或標題\nlabel-namepattern-tg = \n  .label = 標題_作者(預設設定)\n  .tooltiptext =「標題_第一作者」格式命名檔案，如「無人機多餘度航空電子系統設計與應用_楊璐.caj」\nlabel-namepattern-t = \n  .label = 標題\n  .tooltiptext =「標題」格式命名檔案，如「無人機多餘度航空電子系統設計與應用.caj」\nlabel-namepattern-info = 檔案名稱識別範本，從下拉選單中選擇對應格式或直接輸入\nlabel-namepattern-custom = \n  .label = 自訂\n  .tooltiptext = 設定自訂規則，識別檔案名稱中的標題、作者資訊用於元數據抓取\nlabel-choose-namepattern =\n  .label = 選擇範本\n\nlabel-metadata-source = 元數據抓取來源\nlabel-choose-source =\n  .label = 選擇資料來源\nlabel-metadata-source-cnki =\n  .label = 中國知網CNKI\nlabel-metadata-source-cvip =\n  .label = 維普期刊CVIP\nlabel-pdf-match-folder = 附件匹配資料夾\nlabel-choose-folder =\n  .label = 選擇資料夾\nnamepattern-desc = \n  .tooltiptext = 根據檔案名稱抓取知網元數據，檔案名稱格式設定:{\"{\"}%t{\"}\"}=標題，{\"{\"}%g{\"}\"}=作者，{\"{\"}%y{\"}\"}=年份，{\"{\"}%j{\"}\"}=其他（例如來源資訊）；分隔符依實際情況指定，可連續使用多個；不用考慮檔案副檔名。預設使用{\"{\"}%t{\"}\"}_{\"{\"}%g{\"}\"}，可識別大部分知網下載的檔案名稱格式，包括檔案名稱只包括標題無分隔符號。\n\n# 轉換器設定\npref-group-translators = 中文轉換器設定\nlabel-translator-source = 轉換器下載源\nlabel-best-speed = 選擇最快源\ntranslatorSource-desc =\n  .tooltiptext = 選擇轉換器下載源，一般情況下不用切換。如果您無法下載中文轉換器，可選擇嘗試其他源或點擊「選擇最快源」按鈕。\nlabel-auto-update-translators = \n  .label = 自動更新轉換器\nlabel-translators-force-update = \n  .label = 立即更新\nlabel-translators-detail = 轉換器詳情\nlabel-translators-detail-click = 點擊查看\n\n# 附件設定\npref-group-attachment = 本地附件查找設定\nattachment-folder-desc = \n  .tooltiptext = 從下載目錄中查找附件，並匹配到缺少附件的條目中\n  此處請設定為瀏覽器的下載目錄，插件即可批次從下載目錄中匯入及查詢附件。\nlabel-pdf-match-folder = 附件下載資料夾\naction-after-import = 附件匹配到條目之後，如何處理原始下載的附件檔案：\nlabel-choose-folder =\n  .label = 選擇資料夾\nnothing-label =\n  .label = 無須處理\nbackup-label =\n  .label = 備份附件\ndelete-label =\n  .label = 刪除附件\naction-after-import-desc =\n  .tooltiptext = 附件成功匹配到條目之後，您可以選擇以下操作：\n  1. 無須處理：不做任何操作，下載的附件仍保留在下載目錄中；\n  2. 備份附件：將原始下載的附件檔案備份到指定目錄；\n  3. 刪除附件：刪除原始下載的附件檔案（該附件已匹配到條目並儲存到Zotero中）。\n\n# 大綱書籤設定\npref-group-bookmark = 大綱書籤設定\nlabel-disableZoteroOutline = \n  .label = 禁用 Zotero 自帶的大綱\nlabel-enableBookmark = \n  .label = 啟用大綱書籤\noutline-desc = \n  .tooltiptext = 請注意，當您修改大綱或書籤時，需要點擊「儲存」按鈕才會將變更保存至PDF檔案中。預設情況下，書籤與大綱資訊會與PDF檔案分開儲存。\n\n# 小工具設定\npref-group-tools = 小工具設定\nlabel-auto-split-name = \n  .label = 導入新條目時自動拆分姓名\nlabel-split-en-name =\n  .label = 拆分/合併姓名時包括英文名\nlabel-language = 手動設定語言\nlabel-tools-info-1 = 💡 \nlabel-tools-info-2 = 提供更豐富的元數據檢查功能\nlabel-tools-linter = Linter 插件\n\n# WPS 插件安裝\npref-group-wps = WPS Zotero 插件\nlabel-wps = 為 WPS 安裝 Zotero 加載項\nlabel-wps-help = 使用說明\nlabel-install-wps-plugin-click =\n  .label = 點擊安裝\n\n# 其他\npref-group-about = 關於\npref-help = 版本 { $version } 建置於 { $time } ❤️\nlabel-zotero-chinese = Zotero中文社群\npref-enable =\n  .label = 啟用"
  },
  {
    "path": "addon/locale/zh-TW/preferences-translators.ftl",
    "content": "title = 中文社群轉換器列表\n\ngithub-link =\n  .label =  專案首頁\n\nsearch-box =\n  .placeholder = 搜尋轉換器\n\n# Links\nhow-to-update-translators = 如何更新轉換器？\ntranslators-dashboard = 轉換器看板\n\n# Buttons\nrequest-new-translator = 請求适配\nreport-translator-bug = 報告錯誤"
  },
  {
    "path": "addon/locale/zh-TW/progress.ftl",
    "content": "title = 茉莉花任務視窗\ntask-list = 任務列表\nresult-source = 來源：{ source }\nresult-title = 標題：{ title }\nresult-score = 匹配度：{ score }\nconfirm-close = 仍有 xxx 個抓取任務未完成，是否關閉視窗？\n"
  },
  {
    "path": "addon/manifest.json",
    "content": "{\n    \"manifest_version\": 2,\n    \"name\": \"__addonName__\",\n    \"version\": \"__buildVersion__\",\n    \"description\": \"__description__\",\n    \"homepage_url\": \"__homepage__\",\n    \"author\": \"__author__\",\n    \"icons\": {\n        \"48\": \"chrome/content/icons/icon@0.5x.png\",\n        \"96\": \"chrome/content/icons/icon.png\"\n    },\n    \"applications\": {\n        \"zotero\": {\n            \"id\": \"__addonID__\",\n            \"update_url\": \"__updateURL__\",\n            \"strict_min_version\": \"7.999\",\n            \"strict_max_version\": \"8.*.*\"\n        }\n    }\n}"
  },
  {
    "path": "addon/prefs.js",
    "content": "/* eslint-disable no-undef */\npref(\"firstRun\", true);\npref(\"translatorsMended\", false);\n/* tools */\npref(\"autoSplitName\", false);\npref(\"splitEnName\", false);\npref(\"language\", \"zh\");\n/* retrieve metadata */\npref(\"autoUpdateMetadata\", true);\npref(\"namePattern\", \"{%t}_{%g}\");\npref(\"namePatternCustom\", \"{%t}\");\npref(\"metadataSource\", \"CNKI\");\npref(\"isMainlandChina\", true);\npref(\"cnkiAttachmentCookie\", \"\");\npref(\"similarityThresholdForMetaData\", \"0.6\");\n/* match pdf */\npref(\"pdfMatchFolder\", \"\");\npref(\"actionAfterAttachmentImport\", \"backup\")\npref(\"similarityThreshold\", \"0.8\");\npref(\"topMatchCount\", 3);\n/* update translators */\npref(\"autoUpdateTranslators\", true);\npref(\"translatorUpdateTime\", \"0\");\npref(\"translatorSource\", \"\");\n/* bookmark */\npref(\"enableBookmark\", true);\npref(\"newNodeAsChild\", false);\npref(\"disableZoteroOutline\", true);\n"
  },
  {
    "path": "doc/README-zhCN.md",
    "content": "# Zotero Plugin Template\n\n[![zotero target version](https://img.shields.io/badge/Zotero-7-green?style=flat-square&logo=zotero&logoColor=CC2936)](https://www.zotero.org)\n[![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)\n\n这是 [Zotero](https://www.zotero.org/) 的插件模板.\n\n[English](../README.md) | [简体中文](./README-zhCN.md)\n\n- 开发指南\n  - [📖 插件开发文档](https://zotero-chinese.com/plugin-dev-guide/) (中文版，尚不完善)\n  - [📖 Zotero 7 插件开发文档](https://www.zotero.org/support/dev/zotero_7_for_developers)\n- 开发工具参考\n  - [🛠️ Zotero 插件工具包](https://github.com/windingwind/zotero-plugin-toolkit) | [API 文档](https://github.com/windingwind/zotero-plugin-toolkit/blob/master/docs/zotero-plugin-toolkit.md)\n  - [🛠️ Zotero 插件开发脚手架](https://github.com/northword/zotero-plugin-scaffold)\n  - [📜 Zotero 源代码](https://github.com/zotero/zotero)\n  - [ℹ️ Zotero 类型定义](https://github.com/windingwind/zotero-types)\n  - [📌 Zotero 插件模板](https://github.com/windingwind/zotero-plugin-template) (即本仓库)\n\n> [!tip]\n> 👁 Watch 本仓库，以及时收到修复或更新的通知.\n\n## 使用此模板构建的插件\n\n[![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)\n[![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)\n[![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)\n[![GitHub Repo stars](https://img.shields.io/github/stars/windingwind/zotero-tag?label=zotero-tag&style=flat-square)](https://github.com/windingwind/zotero-tag)\n[![GitHub Repo stars](https://img.shields.io/github/stars/iShareStuff/ZoteroTheme?label=zotero-theme&style=flat-square)](https://github.com/iShareStuff/ZoteroTheme)\n[![GitHub Repo stars](https://img.shields.io/github/stars/MuiseDestiny/zotero-reference?label=zotero-reference&style=flat-square)](https://github.com/MuiseDestiny/zotero-reference)\n[![GitHub Repo stars](https://img.shields.io/github/stars/MuiseDestiny/zotero-citation?label=zotero-citation&style=flat-square)](https://github.com/MuiseDestiny/zotero-citation)\n[![GitHub Repo stars](https://img.shields.io/github/stars/MuiseDestiny/ZoteroStyle?label=zotero-style&style=flat-square)](https://github.com/MuiseDestiny/ZoteroStyle)\n[![GitHub Repo stars](https://img.shields.io/github/stars/volatile-static/Chartero?label=Chartero&style=flat-square)](https://github.com/volatile-static/Chartero)\n[![GitHub Repo stars](https://img.shields.io/github/stars/l0o0/tara?label=tara&style=flat-square)](https://github.com/l0o0/tara)\n[![GitHub Repo stars](https://img.shields.io/github/stars/redleafnew/delitemwithatt?label=delitemwithatt&style=flat-square)](https://github.com/redleafnew/delitemwithatt)\n[![GitHub Repo stars](https://img.shields.io/github/stars/redleafnew/zotero-updateifsE?label=zotero-updateifsE&style=flat-square)](https://github.com/redleafnew/zotero-updateifsE)\n[![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)\n[![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)\n[![GitHub Repo stars](https://img.shields.io/github/stars/MuiseDestiny/zotero-gpt?label=zotero-gpt&style=flat-square)](https://github.com/MuiseDestiny/zotero-gpt)\n[![GitHub Repo stars](https://img.shields.io/github/stars/zoushucai/zotero-journalabbr?label=zotero-journalabbr&style=flat-square)](https://github.com/zoushucai/zotero-journalabbr)\n[![GitHub Repo stars](https://img.shields.io/github/stars/MuiseDestiny/zotero-figure?label=zotero-figure&style=flat-square)](https://github.com/MuiseDestiny/zotero-figure)\n[![GitHub Repo stars](https://img.shields.io/github/stars/l0o0/jasminum?label=jasminum&style=flat-square)](https://github.com/l0o0/jasminum)\n[![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)\n\n[![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)\n\n如果你正在使用此库，我建议你将这个标志 ([![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 文件中:\n\n```md\n[![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)\n```\n\n## Features 特性\n\n- 事件驱动、函数式编程的可扩展框架；\n- 简单易用，开箱即用；\n- ⭐[新特性!]自动热重载！每当修改源码时，都会自动编译并重新加载插件；[详情请跳转→](#自动热重载)\n- `src/modules/examples.ts` 中有丰富的示例，涵盖了插件中常用的大部分API (使用 [zotero-plugin-toolkit](https://github.com/windingwind/zotero-plugin-toolkit)；\n- TypeScript 支持:\n  - 为使用 JavaScript 编写的 Zotero 源码提供全面的类型定义支持 (使用 [zotero-types](https://github.com/windingwind/zotero-types))；\n  - 全局变量和环境设置；\n- 插件开发/构建/发布工作流:\n  - 自动生成/更新插件版本、更新配置和设置环境变量 (`development`/`production`)；\n  - 自动在 Zotero 中构建和重新加载代码；\n  - 自动发布到 GitHub ;\n- 集成 Prettier 和 ES Lint;\n\n## Examples 示例\n\n此库提供了 [zotero-plugin-toolkit](https://github.com/windingwind/zotero-plugin-toolkit) 中API的示例.\n\n在 `src/examples.ts` 中搜索`@example` 查看示例. 这些示例在 `src/hooks.ts` 中调用演示.\n\n### 基本示例(Basic Examples)\n\n- registerNotifier\n- registerPrefs, unregisterPrefs\n\n### 快捷键示例(Shortcut Keys Examples)\n\n- registerShortcuts\n- exampleShortcutLargerCallback\n- exampleShortcutSmallerCallback\n- exampleShortcutConflictionCallback\n\n### UI示例(UI Examples)\n\n![image](https://user-images.githubusercontent.com/33902321/211739774-cc5c2df8-5fd9-42f0-9cdf-0f2e5946d427.png)\n\n- registerStyleSheet(the official make-it-red example)\n- registerRightClickMenuItem\n- registerRightClickMenuPopup\n- registerWindowMenuWithSeprator\n- registerExtraColumn\n- registerExtraColumnWithCustomCell\n- registerCustomItemBoxRow\n- registerLibraryTabPanel\n- registerReaderTabPanel\n\n### 首选项面板示例(Preference Pane Examples)\n\n![image](https://user-images.githubusercontent.com/33902321/211737987-cd7c5c87-9177-4159-b975-dc67690d0490.png)\n\n- Preferences bindings\n- UI Events\n- Table\n- Locale\n\n详情参见 [`src/modules/preferenceScript.ts`](./src/modules/preferenceScript.ts)\n\n### 帮助示例(HelperExamples)\n\n![image](https://user-images.githubusercontent.com/33902321/215119473-e7d0d0ef-6d96-437e-b989-4805ffcde6cf.png)\n\n- dialogExample\n- clipboardExample\n- filePickerExample\n- progressWindowExample\n- vtableExample(See Preference Pane Examples)\n\n### 指令行示例(PromptExamples)\n\nObsidian风格的指令输入模块，它通过接受文本来运行插件，并在弹出窗口中显示可选项.\n\n使用 `Shift+P` 激活.\n\n![image](https://user-images.githubusercontent.com/33902321/215120009-e7c7ed27-33a0-44fe-b021-06c272481a92.png)\n\n- registerAlertPromptExample\n\n## 快速上手\n\n### 0 环境要求\n\n1. 安装 [beta 版 Zotero](https://www.zotero.org/support/beta_builds)\n2. 安装 [Node.js](https://nodejs.org/en/) 和 [Git](https://git-scm.com/)\n\n> [!note]\n> 本指南假定你已经对 Zotero 插件的基本结构和工作原理有初步的了解. 如果你还不了解，请先参考[官方文档](https://www.zotero.org/support/dev/zotero_7_for_developers) 和[官方插件样例 Make It Red](https://github.com/zotero/make-it-red)。\n\n### 1 创建你的仓库(Create Your Repo)\n\n1. 点击 `Use this template`；\n2. 使用 `git clone` 克隆上一步生成的仓库；\n   <details >\n   <summary>💡 从 GitHub Codespace 开始</summary>\n\n   _GitHub CodeSpace_ 使你可以直接开始开发而无需在本地下载代码/IDE/依赖.\n\n   重复下列步骤，仅需三十秒即可开始构建你的第一个插件！\n\n   - 点击首页 `Use this template` 按钮，随后点击 `Open in codespace`， 你需要登录你的 GitHub 账号.\n   - 等待 codespace 加载.\n\n   </details>\n\n3. 进入项目文件夹；\n\n### 2 配置模板和开发环境(Config Template Settings and Enviroment)\n\n1. 修改 `./package.json` 中的设置，包括：\n\n   ```json5\n   {\n     version: \"\", // 修改为 0.0.0\n     author: \"\",\n     description: \"\",\n     homepage: \"\",\n     config: {\n       addonName: \"\", // 插件名称\n       addonID: \"\", // 插件 ID 【重要：防止冲突】\n       addonRef: \"\", // 插件命名空间：元素前缀等\n       addonInstance: \"\", // 注册在 Zotero 根下的实例名\n       prefsPrefix: \"extensions.zotero.${addonRef}\", // 首选项的前缀\n     },\n   }\n   ```\n\n   > [!warning]\n   > 注意设置 addonID 和 addonRef 以避免冲突.\n\n   如果你需要在 GitHub 以外的地方托管你的 XPI 包，请修改 `zotero-plugin.config.ts` 中的 `updateURL` 和 `xpiDownloadLink`。\n\n2. 复制 Zotero 启动配置，填入 Zotero 可执行文件路径和 profile 路径.\n\n   > (可选项) 创建开发用 profile 目录：\n   >\n   > 此操作仅需执行一次: 使用 `/path/to/zotero -p` 启动 Zotero，创建一个新的配置文件并用作开发配置文件。\n\n   ```sh\n   cp .env.example .env\n   vim .env\n   ```\n\n   如果你维护了多个插件，可以将这些内容存入系统环境变量，以避免在每个插件中都需要重复设置。\n\n3. 运行 `npm install` 以安装相关依赖\n\n   > 如果你使用 `pnpm` 作为包管理器，你需要添加 `public-hoist-pattern[]=*@types/bluebird*` 到`.npmrc`, 详情请查看 [zotero-types](https://github.com/windingwind/zotero-types?tab=readme-ov-file#usage) 的文档.\n\n   如果你使用 `npm install` 的过程中遇到了 `npm ERR! ERESOLVE unable to resolve dependency tree` ，这是由于上游依赖 typescript-eslint 导致的错误，请使用 `npm i -f` 命令进行安装。\n\n### 3 开发插件\n\n使用 `npm start` 启动开发服务器，它将：\n\n- 在开发模式下预构建插件\n- 启动 Zotero ，并让其从 `build/` 中加载插件\n- 打开开发者工具（devtool）\n- 监听 `src/**` 和 `addon/**`.\n  - 如果 `src/**` 修改了，运行 esbuild 并且重新加载\n  - 如果 `addon/**` 修改了，(在开发模式下)重新构建插件并且重新加载\n\n#### 自动热重载\n\n厌倦了无休止的重启吗？忘掉它，拥抱热加载！\n\n1. 运行 `npm start`.\n2. 编码. (是的，就这么简单)\n\n当检测到 `src` 或 `addon` 中的文件修改时，插件将自动编译并重新加载.\n\n<details style=\"text-indent: 2em\">\n<summary>💡 将此功能添加到现有插件的步骤</summary>\n\n请参阅：[zotero-plugin-scaffold](https://github.com/northword/zotero-plugin-scaffold)。\n\n</details>\n\n#### 调试代码\n\n你还可以:\n\n- 在 Tools->Developer->Run Javascript 中测试代码片段;\n\n- 使用 `Zotero.debug()` 调试输出. 在 Help->Debug Output Logging->View Output 查看输出;\n\n- 调试 UI. Zotero 建立在 Firefox XUL 框架之上. 使用 [XUL Explorer](https://udn.realityripple.com/docs/Archive/Mozilla/XUL_Explorer) 等软件调试 XUL UI.\n\n  > XUL 文档: <http://www.devdoc.net/web/developer.mozilla.org/en-US/docs/XUL.html>\n\n### 4 构建插件\n\n运行 `npm run build` 在生产模式下构建插件，构建的结果位于 `build/` 目录中.\n\n构建步骤:\n\n- 创建/清空 `build/`\n- 复制 `addon/**` 到 `build/addon/**`\n- 替换占位符：使用 `replace-in-file` 去替换在 `package.json` 中定义的关键字和配置 (`xhtml`、`.flt` 等)\n- 准备本地化文件以避免冲突，查看官方文档了解更多（<https://www.zotero.org/support/dev/zotero_7_for_developers#avoiding_localization_conflicts）>\n  - 重命名`**/*.flt` 为 `**/${addonRef}-*.flt`\n  - 在每个消息前加上 `addonRef-`\n- 使用 Esbuild 来将 `.ts` 源码构建为 `.js`，从 `src/index.ts` 构建到`./build/addon/chrome/content/scripts`\n- (仅在生产模式下工作) 压缩 `./build/addon` 目录为 `./build/*.xpi`\n- (仅在生产模式下工作) 准备 `update.json` 或 `update-beta.json`\n\n> [!note]\n>\n> **Dev & prod 两者有什么区别?**\n>\n> - 此环境变量存储在 `Zotero.${addonInstance}.data.env` 中，控制台输出在生产模式下被禁用.\n> - 你可以根据此变量决定用户无法查看/使用的内容.\n> - 在生产模式下，构建脚本将自动打包插件并更新 `update.json`.\n\n### 5 发布\n\n如果要构建和发布插件，运行如下指令：\n\n```shell\n# version increase, git add, commit and push\n# then on ci, npm run build, and release to GitHub\nnpm run release\n```\n\n> [!note]\n> 在此模板中，release-it 被配置为在本地更新版本号、提交并推送标签，随后 GitHub Action 将重新构建插件并将 XPI 发布到 GitHub Release.\n\n#### 关于预发布\n\n该模板将 `prerelease` 定义为插件的测试版，当你在 release-it 中选择 `prerelease` 版本 (版本号中带有 `-` )，构建脚本将创建一个 `update-beta.json` 给预发布版本使用，这将确保常规版本的用户不会自动更新到测试版，只有手动下载并安装了测试版的用户才能自动更新到下一个测试版. 当下一个正式版本更新时，脚本将同步更新 `update.json` 和 `update-beta.json`，这将使正式版和测试版用户都可以更新到最新的正式版.\n\n> [!warning]\n> 严格来说，区分 Zotero 6 和 Zotero 7 兼容的插件版本应该通过 `update.json` 的 `addons.__addonID__.updates[]` 中分别配置 `applications.zotero.strict_min_version`，这样 Zotero 才能正确识别，详情在 Zotero 7 开发文档（<https://www.zotero.org/support/dev/zotero_7_for_developers#updaterdf_updatesjson）获取>.\n\n## Details 更多细节\n\n### 关于Hooks(About Hooks)\n\n> 可以在 [`src/hooks.ts`](https://github.com/windingwind/zotero-plugin-template/blob/main/src/hooks.ts) 中查看更多\n\n1. 当在 Zotero 中触发安装/启用/启动时，`bootstrap.js` > `startup` 被调用\n   - 等待 Zotero 就绪\n   - 加载 `index.js` (插件代码的主入口，从 `index.ts` 中构建)\n   - 如果是 Zotero 7 以上的版本则注册资源\n2. 主入口 `index.js` 中，插件对象被注入到 `Zotero` ，并且 `hooks.ts` > `onStartup` 被调用.\n   - 初始化插件需要的资源，包括通知监听器、首选项面板和UI元素.\n3. 当在 Zotero 中触发卸载/禁用时，`bootstrap.js` > `shutdown` 被调用.\n   - `events.ts` > `onShutdown` 被调用. 移除 UI 元素、首选项面板或插件创建的任何内容.\n   - 移除脚本并释放资源.\n\n### 关于全局变量(About Global Variables)\n\n> 可以在 [`src/index.ts`](https://github.com/windingwind/zotero-plugin-template/blob/main/src/index.ts)中查看更多\n\nbootstrap插件在沙盒中运行，但沙盒中没有默认的全局变量，例如 `Zotero` 或 `window` 等我们曾在overlay插件环境中使用的变量.\n\n此模板将以下变量注册到全局范围:\n\n```ts\nZotero, ZoteroPane, Zotero_Tabs, window, document, rootURI, ztoolkit, addon;\n```\n\n### 创建元素 API(Create Elements API)\n\n插件模板为 bootstrap 插件提供了一些新的API. 我们有两个原因使用这些 API，而不是使用 `createElement/createElementNS`：\n\n- 在 bootstrap 模式下，插件必须在推出（禁用或卸载）时清理所有 UI 元素，这非常麻烦. 使用 `createElement`，插件模板将维护这些元素. 仅仅在退出时 `unregisterAll` .\n- Zotero 7 需要 createElement()/createElementNS() → createXULElement() 来表示其他的 XUL 元素，而 Zotero 6 并不支持 `createXULElement`. 类似于 React.createElement 的API `createElement` 检测 namespace(xul/html/svg) 并且自动创建元素，返回元素为对应的 TypeScript 元素类型.\n\n```ts\ncreateElement(document, \"div\"); // returns HTMLDivElement\ncreateElement(document, \"hbox\"); // returns XUL.Box\ncreateElement(document, \"button\", { namespace: \"xul\" }); // manually set namespace. returns XUL.Button\n```\n\n### 关于 Zotero API(About Zotero API)\n\nZotero 文档已过时且不完整，克隆 <https://github.com/zotero/zotero> 并全局搜索关键字.\n\n> ⭐[zotero-types](https://github.com/windingwind/zotero-types) 提供了最常用的 Zotero API，在默认情况下它被包含在此模板中. 你的 IDE 将为大多数的 API 提供提醒.\n\n猜你需要：查找所需 API的技巧\n\n在 `.xhtml`/`.flt` 文件中搜索 UI 标签，然后在 locale 文件中找到对应的键. ，然后在 `.js`/`.jsx` 文件中搜索此键.\n\n### 目录结构(Directory Structure)\n\n本部分展示了模板的目录结构.\n\n- 所有的 `.js/.ts` 代码都在 `./src`;\n- 插件配置文件：`./addon/manifest.json`;\n- UI 文件: `./addon/chrome/content/*.xhtml`.\n- 区域设置文件: `./addon/locale/**/*.flt`;\n- 首选项文件: `./addon/prefs.js`;\n  > 不要在 `prefs.js` 中换行\n\n```shell\n.\n|-- .eslintrc.json            # eslint conf\n|-- .gitattributes            # git conf\n|-- .github/                  # github conf\n|-- .gitignore                # git conf\n|-- .prettierrc               # prettier conf\n|-- .release-it.json          # release-it conf\n|-- .vscode                   # vs code conf\n|   |-- extensions.json\n|   |-- launch.json\n|   |-- setting.json\n|   `-- toolkit.code-snippets\n|-- package-lock.json         # npm conf\n|-- package.json              # npm conf\n|-- LICENSE\n|-- README.md\n|-- addon\n|   |-- bootstrap.js               # addon load/unload script, like a main.c\n|   |-- chrome\n|   |   `-- content\n|   |       |-- icons/\n|   |       |-- preferences.xhtml  # preference panel\n|   |       `-- zoteroPane.css\n|   |-- locale                     # locale\n|   |   |-- en-US\n|   |   |   |-- addon.ftl\n|   |   |   `-- preferences.ftl\n|   |   `-- zh-CN\n|   |       |-- addon.ftl\n|   |       `-- preferences.ftl\n|   |-- manifest.json              # addon config\n|   `-- prefs.js\n|-- build/                         # build dir\n|-- scripts                        # scripts for dev\n|   |-- build.mjs                      # script to build plugin\n|   |-- scripts.mjs                    # scripts send to Zotero, such as reload, openDevTool, etc\n|   |-- server.mjs                     # script to start a development server\n|   |-- start.mjs                      # script to start Zotero process\n|   |-- stop.mjs                       # script to kill Zotero process\n|   |-- utils.mjs                      # utils functions for dev scripts\n|   |-- update-template.json      # template of `update.json`\n|   `-- zotero-cmd-template.json  # template of local env\n|-- src                           # source code\n|   |-- addon.ts                  # base class\n|   |-- hooks.ts                  # lifecycle hooks\n|   |-- index.ts                  # main entry\n|   |-- modules                   # sub modules\n|   |   |-- examples.ts\n|   |   `-- preferenceScript.ts\n|   `-- utils                     # utilities\n|       |-- locale.ts\n|       |-- prefs.ts\n|       |-- wait.ts\n|       `-- window.ts\n|-- tsconfig.json                 # https://code.visualstudio.com/docs/languages/jsconfig\n|-- typings                       # ts typings\n|   `-- global.d.ts\n`-- update.json\n```\n\n## Disclaimer 免责声明\n\n在 AGPL 下使用此代码. 不提供任何保证. 遵守你所在地区的法律！\n\n如果你想更改许可，请通过 <wyzlshx@foxmail.com> 与我联系.\n"
  },
  {
    "path": "eslint.config.mjs",
    "content": "// @ts-check Let TS check this config file\n\nimport eslint from \"@eslint/js\";\nimport tseslint from \"typescript-eslint\";\n\nexport default tseslint.config(\n  {\n    ignores: [\"build/**\", \"dist/**\", \"node_modules/**\", \"scripts/\"],\n  },\n  {\n    extends: [eslint.configs.recommended, ...tseslint.configs.recommended],\n    rules: {\n      \"no-restricted-globals\": [\n        \"error\",\n        { message: \"Use `Zotero.getMainWindow()` instead.\", name: \"window\" },\n        {\n          message: \"Use `Zotero.getMainWindow().document` instead.\",\n          name: \"document\",\n        },\n        {\n          message: \"Use `Zotero.getActiveZoteroPane()` instead.\",\n          name: \"ZoteroPane\",\n        },\n        \"Zotero_Tabs\",\n      ],\n\n      \"@typescript-eslint/ban-ts-comment\": [\n        \"warn\",\n        {\n          \"ts-expect-error\": \"allow-with-description\",\n          \"ts-ignore\": \"allow-with-description\",\n          \"ts-nocheck\": \"allow-with-description\",\n          \"ts-check\": \"allow-with-description\",\n        },\n      ],\n      \"@typescript-eslint/no-unused-vars\": \"off\",\n      \"@typescript-eslint/no-explicit-any\": [\n        \"off\",\n        {\n          ignoreRestArgs: true,\n        },\n      ],\n      \"@typescript-eslint/no-non-null-assertion\": \"off\",\n    },\n  },\n);\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"jasminum\",\n  \"version\": \"1.1.31\",\n  \"description\": \"一个简单的 Zotero 中文插件\",\n  \"config\": {\n    \"addonName\": \"Jasminum\",\n    \"addonID\": \"jasminum@linxzh.com\",\n    \"addonRef\": \"jasminum\",\n    \"addonInstance\": \"Jasminum\",\n    \"prefsPrefix\": \"extensions.jasminum\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/l0o0/jasminum.git\"\n  },\n  \"author\": \"l0o0\",\n  \"bugs\": {\n    \"url\": \"https://github.com/l0o0/jasminum/issues\"\n  },\n  \"homepage\": \"https://github.com/l0o0/jasminum#readme\",\n  \"license\": \"AGPL-3.0-or-later\",\n  \"scripts\": {\n    \"start\": \"zotero-plugin serve\",\n    \"build\": \"tsc --noEmit && zotero-plugin build\",\n    \"lint\": \"prettier --write . && eslint . --fix\",\n    \"release\": \"zotero-plugin release\",\n    \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\",\n    \"update-deps\": \"npm update --save\"\n  },\n  \"dependencies\": {\n    \"pdf-lib\": \"^1.17.1\",\n    \"string-similarity\": \"^4.0.4\",\n    \"zotero-plugin-toolkit\": \"5.1.0-beta.4\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.27.0\",\n    \"@types/node\": \"^25.0.10\",\n    \"@types/string-similarity\": \"^4.0.2\",\n    \"eslint\": \"^9.27.0\",\n    \"prettier\": \"^3.5.3\",\n    \"typescript\": \"^5.8.3\",\n    \"typescript-eslint\": \"^8.33.0\",\n    \"zotero-plugin-scaffold\": \"^0.8.0\",\n    \"zotero-types\": \"4.1.0-beta.4\"\n  },\n  \"prettier\": {\n    \"printWidth\": 80,\n    \"tabWidth\": 2,\n    \"endOfLine\": \"lf\",\n    \"overrides\": [\n      {\n        \"files\": [\n          \"*.xhtml\"\n        ],\n        \"options\": {\n          \"htmlWhitespaceSensitivity\": \"css\"\n        }\n      }\n    ]\n  },\n  \"packageManager\": \"pnpm@9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf\"\n}"
  },
  {
    "path": "src/addon.ts",
    "content": "import hooks from \"./hooks\";\nimport { createZToolkit } from \"./utils/ztoolkit\";\nimport { Progress } from \"./modules/progress\";\nimport { VirtualizedTableHelper } from \"zotero-plugin-toolkit\";\nimport { MyCookieSandbox } from \"./utils/cookiebox\";\nimport { getOutlineFromPDF } from \"./modules/outline/outline\";\nimport { TaskRunner } from \"./utils/task\";\nimport { requestDocument } from \"./utils/http\";\n\nclass Addon {\n  public data: {\n    alive: boolean;\n    // Env type, see build.js\n    env: \"development\" | \"production\";\n    ztoolkit: ZToolkit;\n    locale?: {\n      current: any;\n    };\n    prefs?: {\n      window: Window;\n    };\n    progress: Progress;\n    windows: Record<string, Window>;\n    translators: {\n      window?: Window;\n      helper?: VirtualizedTableHelper;\n      rows: TableRow[];\n      allRows: TableRow[];\n      selected?: string;\n      updating?: boolean;\n    };\n    myCookieSandbox: MyCookieSandbox;\n    isImportingAttachments: boolean;\n  };\n  // Lifecycle hooks\n  public hooks: typeof hooks;\n  // APIs\n  public api: object;\n  public taskRunner: TaskRunner;\n\n  constructor() {\n    this.data = {\n      alive: true,\n      env: __env__,\n      ztoolkit: createZToolkit(),\n      progress: new Progress(),\n      windows: {},\n      translators: {\n        rows: [],\n        allRows: [],\n        updating: false,\n      },\n      myCookieSandbox: new MyCookieSandbox(),\n      isImportingAttachments: false,\n    };\n    this.hooks = hooks;\n    this.api = { getOutlineFromPDF, requestDocument };\n    this.taskRunner = new TaskRunner();\n  }\n}\n\nexport default Addon;\n"
  },
  {
    "path": "src/hooks.ts",
    "content": "import { config } from \"../package.json\";\nimport { initLocale } from \"./utils/locale\";\nimport {\n  registerPrefsPane,\n  onPrefsWindowLoad,\n  initPrefs,\n} from \"./modules/preferences/main\";\nimport { createZToolkit } from \"./utils/ztoolkit\";\nimport { registerMenu } from \"./modules/menu\";\nimport {\n  registerExtraColumnWithCustomCell,\n  registerNotifiers,\n  registerTab,\n} from \"./modules/notifier\";\nimport { injectStylesLink } from \"./modules/styles\";\nimport { updateTranslators } from \"./modules/translators\";\nimport { getPref } from \"./utils/prefs\";\n\nasync function onStartup() {\n  await Promise.all([\n    Zotero.initializationPromise,\n    Zotero.unlockPromise,\n    Zotero.uiReadyPromise,\n  ]);\n\n  initLocale();\n\n  registerPrefsPane();\n  initPrefs();\n\n  registerNotifiers();\n\n  registerMenu();\n  registerTab();\n  await registerExtraColumnWithCustomCell();\n\n  injectStylesLink();\n\n  // @ts-ignore - Not typed.\n  await Zotero.Promise.delay(1000);\n  await Promise.all(\n    Zotero.getMainWindows().map((win) => onMainWindowLoad(win)),\n  );\n}\n\nasync function onMainWindowLoad(win: Window): Promise<void> {\n  // Create ztoolkit for every window\n  addon.data.ztoolkit = createZToolkit();\n\n  // @ts-ignore - Not typed.\n  await Zotero.Promise.delay(1000);\n\n  if (getPref(\"autoUpdateTranslators\")) {\n    // @ts-ignore - Not typed.\n    await Zotero.Promise.delay(10000);\n    ztoolkit.log(\"auto update translators\");\n    updateTranslators();\n  }\n}\n\nfunction onShutdown(): void {\n  ztoolkit.unregisterAll();\n  // Remove addon object\n  addon.data.alive = false;\n  // @ts-ignore - Plugin instance is not typed\n  delete Zotero[config.addonInstance];\n}\n\n// Add your hooks here. For element click, etc.\n// Keep in mind hooks only do dispatch. Don't add code that does real jobs in hooks.\n// Otherwise the code would be hard to read and maintain.\n\nexport default {\n  onStartup,\n  onShutdown,\n  onMainWindowLoad,\n  onPrefsWindowLoad,\n};\n"
  },
  {
    "path": "src/index.ts",
    "content": "import { BasicTool } from \"zotero-plugin-toolkit\";\nimport Addon from \"./addon\";\nimport { config } from \"../package.json\";\n\nconst basicTool = new BasicTool();\n// @ts-ignore - Plugins instance not typed.\nif (!basicTool.getGlobal(\"Zotero\")[config.addonInstance]) {\n  _globalThis.addon = new Addon();\n  defineGlobal(\"ztoolkit\", () => {\n    return _globalThis.addon.data.ztoolkit;\n  });\n  // @ts-ignore - Plugins instance not typed.\n  Zotero[config.addonInstance] = addon;\n}\n\nfunction defineGlobal(name: Parameters<BasicTool[\"getGlobal\"]>[0]): void;\nfunction defineGlobal(name: string, getter: () => any): void;\nfunction defineGlobal(name: string, getter?: () => any) {\n  Object.defineProperty(_globalThis, name, {\n    get() {\n      return getter ? getter() : basicTool.getGlobal(name);\n    },\n  });\n}\n"
  },
  {
    "path": "src/modules/attachments/index.ts",
    "content": "import { getPref } from \"../../utils/prefs\";\nimport { LocalAttachmentService } from \"./localMatch\";\n\nconst localService = new LocalAttachmentService();\nexport async function attachmentSearch(task: AttachmentTask): Promise<void> {\n  const attachmentSearchResults = await localService.searchAttachments(task);\n  if (!attachmentSearchResults || attachmentSearchResults.length === 0) {\n    task.addMsg(\"No matching attachments found in local.\");\n    task.status = \"fail\";\n    return;\n  } else if (attachmentSearchResults.length === 1) {\n    task.searchResults = attachmentSearchResults;\n    task.resultIndex = 0;\n    task.addMsg(\"Found one matching attachment in local.\");\n  } else {\n    task.status = \"multiple_results\";\n    task.searchResults = attachmentSearchResults;\n    task.addMsg(\n      `Found ${attachmentSearchResults.length} matching attachments in local.`,\n    );\n  }\n}\n\nexport async function importAttachment(task: AttachmentTask): Promise<void> {\n  // Maybe oneday I will support remote attachment import\n  await localService.importAttachment(task);\n\n  // Action after import\n  await actionAfterImport(task.searchResults![task.resultIndex!].url);\n}\n\nexport async function actionAfterImport(\n  attachmentPath: string,\n  action?: string,\n): Promise<void> {\n  const a = action || getPref(\"actionAfterAttachmentImport\") || \"nothing\";\n  const attachmentName = PathUtils.filename(attachmentPath);\n  const backupFolder = PathUtils.join(\n    getPref(\"pdfMatchFolder\"),\n    \"jasminum-backup\",\n  );\n  const backupFile = PathUtils.join(backupFolder, attachmentName);\n  ztoolkit.log(\"Action after import: \", a, attachmentName);\n  switch (a) {\n    case \"nothing\":\n      ztoolkit.log(\"No action after import.\");\n      break;\n    case \"backup\":\n      ztoolkit.log(\"Backing up the attachment...\");\n      await IOUtils.makeDirectory(backupFolder, { ignoreExisting: true });\n      await IOUtils.move(attachmentPath, backupFile);\n      break;\n    case \"delete\":\n      ztoolkit.log(\"Deleting the attachment...\");\n      await IOUtils.remove(attachmentPath);\n      break;\n  }\n}\n"
  },
  {
    "path": "src/modules/attachments/localMatch.ts",
    "content": "import { compareTwoStrings } from \"string-similarity\";\nimport { getPref } from \"../../utils/prefs\";\nimport { isChineseAttachmentFilename } from \"../../utils/detect\";\n\n// Return full path of the attachments.\nexport async function findAttachmentsInFolder(\n  folder?: string,\n): Promise<string[]> {\n  if (!folder) folder = getPref(\"pdfMatchFolder\");\n  ztoolkit.log(folder);\n  return (await IOUtils.getChildren(folder)).filter((filename) => {\n    ztoolkit.log(filename);\n    return isChineseAttachmentFilename(PathUtils.filename(filename));\n  });\n}\n\nexport class LocalAttachmentService implements AttachmentService {\n  async searchAttachments(\n    task: AttachmentTask,\n  ): Promise<AttachmentSearchResult[] | null> {\n    ztoolkit.log(\"Searching for local attachments...\");\n    const threshold = parseFloat(getPref(\"similarityThreshold\"));\n    const top = getPref(\"topMatchCount\");\n    const searchString = task.item.getField(\"title\");\n    const attachmentFilenames = await findAttachmentsInFolder();\n    ztoolkit.log(attachmentFilenames);\n    if (!attachmentFilenames || attachmentFilenames.length === 0) {\n      return null;\n    }\n\n    // 创建包含评分和文件名的对象数组\n    const scoredItems = attachmentFilenames.map((filename) => {\n      const name = PathUtils.filename(filename);\n      const name_no_ext = name.replace(/\\.(pdf|caj|kdh|nh)$/i, \"\");\n      const score = compareTwoStrings(\n        searchString.toUpperCase(),\n        name_no_ext.toUpperCase(),\n      );\n      ztoolkit.log(\n        searchString.toUpperCase(),\n        name,\n        name_no_ext.toUpperCase(),\n        score,\n      );\n      return {\n        title: name,\n        filename: name,\n        score: score,\n        url: filename,\n        source: \"local\",\n      };\n    });\n    ztoolkit.log(scoredItems);\n\n    // 按评分降序排序\n    const sortedItems = scoredItems.sort((a, b) => b.score - a.score);\n\n    // 过滤阈值并取前3项\n    const topMatches = sortedItems\n      .filter((item) => item.score >= threshold)\n      .slice(0, top);\n\n    return topMatches.length > 0 ? topMatches : null;\n  }\n\n  async importAttachment(task: AttachmentTask): Promise<void> {\n    if (\n      !task.searchResults ||\n      task.searchResults.length === 0 ||\n      task.resultIndex === undefined\n    ) {\n      task.addMsg(\"Found attachment, but import failed.\");\n      task.status = \"fail\";\n      return;\n    }\n    const searchResult = task.searchResults[task.resultIndex];\n    const importOptions: _ZoteroTypes.Attachments.OptionsFromFile = {\n      file: searchResult.url,\n      parentItemID: task.item.id,\n      title: `FullText_by_Jasminum.${searchResult.title}`,\n    };\n    const importItem = await Zotero.Attachments.importFromFile(importOptions);\n    if (importItem) {\n      task.status = \"success\";\n    }\n  }\n}\n"
  },
  {
    "path": "src/modules/menu.ts",
    "content": "import { MenuitemOptions } from \"zotero-plugin-toolkit/dist/managers/menu\";\nimport { config } from \"../../package.json\";\nimport { getString } from \"../utils/locale\";\nimport {\n  mergeName,\n  splitName,\n  updateCNKICite,\n  importAttachmentsFromFolder,\n  handleAttachmentMenu,\n} from \"./tools\";\nimport { isChineseTopAttachment, isChinsesSnapshot } from \"../utils/detect\";\n\nconst metaddataMenuItems: MenuitemOptions[] = [\n  {\n    tag: \"menuitem\",\n    label: \"retrieveMetadata\",\n    icon: `chrome://${config.addonRef}/content/icons/searchCNKI.png`,\n    isHidden: (_elm, _ev) =>\n      Zotero.getActiveZoteroPane()\n        .getSelectedItems()\n        .some((item) => {\n          return !(isChineseTopAttachment(item) || isChinsesSnapshot(item));\n        }),\n    commandListener: async () => {\n      const items = Zotero.getActiveZoteroPane().getSelectedItems();\n      for (const item of items) {\n        await addon.taskRunner.createAndAddTask(\n          item,\n          isChineseTopAttachment(item) ? \"attachment\" : \"snapshot\",\n        );\n      }\n    },\n  },\n  {\n    tag: \"menuitem\",\n    label: \"retrieveMetadataForBook\",\n    icon: `chrome://${config.addonRef}/content/icons/searchCNKI.png`,\n    isHidden: () => true,\n    // getVisibility: (_elm, _ev) =>\n    //   Zotero.getActiveZoteroPane()\n    //     .getSelectedItems()\n    //     .some((item) => {\n    //       return isChineseTopAttachment(item);\n    //     }),\n    commandListener: () => {\n      // @ts-ignore - The plugin instance is not typed.\n      Zotero[config.addonInstance].scraper.search(\n        Zotero.getActiveZoteroPane().getSelectedItems()[0],\n      );\n    },\n  },\n];\n\nconst toolsMenuItems: MenuitemOptions[] = [\n  {\n    tag: \"menuitem\",\n    label: \"mergeName\",\n    icon: `chrome://${config.addonRef}/content/icons/name.png`,\n    commandListener: () => {\n      for (const item of Zotero.getActiveZoteroPane().getSelectedItems()) {\n        mergeName(item);\n      }\n    },\n  },\n  {\n    tag: \"menuitem\",\n    label: \"splitName\",\n    icon: `chrome://${config.addonRef}/content/icons/name.png`,\n    commandListener: () => {\n      for (const item of Zotero.getActiveZoteroPane().getSelectedItems()) {\n        splitName(item);\n      }\n    },\n  },\n  {\n    tag: \"menuitem\",\n    label: \"updateCNKICite\",\n    icon: `chrome://${config.addonRef}/content/icons/cite.png`,\n    commandListener: async () => {\n      await updateCNKICite(Zotero.getActiveZoteroPane().getSelectedItems());\n    },\n  },\n  {\n    tag: \"menuitem\",\n    label: \"find-attachment\",\n    icon: `chrome://${config.addonRef}/content/icons/attachment-search.svg`,\n    commandListener: () => {\n      handleAttachmentMenu(\"item\");\n    },\n  },\n];\n\nexport function registerMenu() {\n  const separatorMenu: MenuitemOptions = {\n    tag: \"menuseparator\",\n    id: `${config.addonRef}-separator`,\n    isHidden: (_event) =>\n      Zotero.getActiveZoteroPane()\n        .getSelectedItems()\n        .some((item) => {\n          return !(\n            isChineseTopAttachment(item) ||\n            isChinsesSnapshot(item) ||\n            (item.isTopLevelItem() && item.isRegularItem())\n          );\n        }),\n  };\n\n  const metadataMenu: MenuitemOptions = {\n    tag: \"menu\",\n    label: getString(\"menu-metadata\"),\n    id: `${config.addonRef}-metadata-menu`,\n    icon: `chrome://${config.addonRef}/content/icons/icon.png`,\n    children: metaddataMenuItems.map((subOption) => {\n      const label = subOption.label as string;\n      subOption.id = `${config.addonRef}-menuitem-${label}`;\n      subOption.label = getString(`menuitem-${label}`);\n      return subOption;\n    }),\n    isHidden: (_event) =>\n      Zotero.getActiveZoteroPane()\n        .getSelectedItems()\n        .some((item) => {\n          return !(isChineseTopAttachment(item) || isChinsesSnapshot(item));\n        }),\n  };\n  const toolsMenu: MenuitemOptions = {\n    tag: \"menu\",\n    label: getString(\"menu-tools\"),\n    id: `${config.addonRef}-tools-menu`,\n    icon: `chrome://${config.addonRef}/content/icons/icon.png`,\n    children: toolsMenuItems.map((subOption) => {\n      const label = subOption.label as string;\n      subOption.id = `${config.addonRef}-menuitem-${label}`;\n      subOption.label = getString(`menuitem-${label}`);\n      return subOption;\n    }),\n    isHidden: () =>\n      Zotero.getActiveZoteroPane()\n        .getSelectedItems()\n        .some((item) => {\n          return !(item.isTopLevelItem() && item.isRegularItem());\n        }),\n  };\n  ztoolkit.Menu.register(\"item\", separatorMenu);\n  ztoolkit.Menu.register(\"item\", metadataMenu);\n  ztoolkit.Menu.register(\"item\", toolsMenu);\n\n  const attachmentMenu: MenuitemOptions = {\n    tag: \"menuitem\",\n    label: getString(\"menuitem-find-attachment\"),\n    id: `${config.addonRef}-attachment-menu`,\n    icon: `chrome://${config.addonRef}/content/icons/attachment-search.svg`,\n    commandListener: () => {\n      handleAttachmentMenu(\"collection\");\n    },\n    isHidden: () =>\n      Zotero.getActiveZoteroPane().getSelectedCollection() === undefined\n        ? true\n        : false,\n  };\n\n  const importAttachmentMenu: MenuitemOptions = {\n    tag: \"menuitem\",\n    label: getString(\"menuitem-import-attachments\"),\n    id: `${config.addonRef}-attachment-menu`,\n    icon: `chrome://${config.addonRef}/content/icons/folder-import.svg`,\n    commandListener: async () => {\n      await importAttachmentsFromFolder();\n    },\n    isHidden: () =>\n      Zotero.getActiveZoteroPane().getSelectedCollection() === undefined\n        ? true\n        : false,\n  };\n\n  ztoolkit.Menu.register(\"collection\", attachmentMenu);\n  ztoolkit.Menu.register(\"collection\", importAttachmentMenu);\n  // ztoolkit.Menu.register(\"item\", {\n  //   tag: \"menuitem\",\n  //   label: \"TEST\",\n  //   commandListener: async () => {\n  //     // downloadTranslator(true);\n  //     const item = Zotero.getActiveZoteroPane().getSelectedItems()[0];\n  //     const title = await getPDFTitle(item.id);\n  //     ztoolkit.log(title);\n  //   },\n  // });\n\n  // Disable in collection\n  // ztoolkit.Menu.register(\"collection\", metadataMenu);\n}\n"
  },
  {
    "path": "src/modules/notifier.ts",
    "content": "import { config } from \"../../package.json\";\nimport { getString } from \"../utils/locale\";\nimport { getPref } from \"../utils/prefs\";\nimport { isChineseTopAttachment } from \"../utils/detect\";\nimport { registerOutline } from \"./outline\";\nimport { splitName } from \"./tools\";\n\n/**\n * A wrap for Zotero.Notifier.registerObserver,\n * which will automatically unregister the observer when the addon is disabled.\n */\nfunction registerNotifier(\n  onNotify: (\n    event: string,\n    type: string,\n    ids: number[] | string[],\n    extraData: { [key: string]: any },\n  ) => void,\n  types: _ZoteroTypes.Notifier.Type[],\n) {\n  const callback = {\n    notify: async (\n      event: string,\n      type: string,\n      ids: number[] | string[],\n      extraData: { [key: string]: any },\n    ) => {\n      if (!addon?.data.alive) {\n        unregisterNotifier(notifierID);\n        return;\n      }\n      onNotify(event, type, ids, extraData);\n    },\n  };\n\n  // Register the callback in Zotero as an item observer\n  const notifierID = Zotero.Notifier.registerObserver(callback, types);\n\n  Zotero.Plugins.addObserver({\n    shutdown: ({ id }) => {\n      if (id === config.addonID) unregisterNotifier(notifierID);\n    },\n  });\n}\n\nfunction unregisterNotifier(notifierID: string) {\n  Zotero.Notifier.unregisterObserver(notifierID);\n}\n\n/**\n * Register notifiers for the addon at startup hooks.\n */\nexport function registerNotifiers() {\n  registerNotifier(onAddItem, [\"item\"]);\n  // registerNotifier(onOpenTab, [\"tab\"]);\n}\n\nasync function onAddItem(\n  event: string,\n  type: string,\n  ids: Array<string | number>,\n  extraData: { [key: string]: any },\n) {\n  // ztoolkit.log(`notify: add item, event: ${event}, type: ${type}, ids: ${ids}`);\n  if (event !== \"add\" || type !== \"item\") return;\n  for (const id of ids) {\n    const item = Zotero.Items.get(id);\n\n    if (getPref(\"autoUpdateMetadata\")) {\n      if (isChineseTopAttachment(item)) {\n        await addon.taskRunner.createAndAddTask(item, \"attachment\");\n      }\n    }\n\n    if (getPref(\"autoSplitName\")) {\n      splitName(item);\n    }\n  }\n}\n\n// TODO: Complete the notifier.\n// async function onOpenTab(\n//   event: string,\n//   type: string,\n//   ids: Array<string | number>,\n//   extraData: { [key: string]: any },\n// ) {\n//   const id = ids[0];\n//   if (\n//     (event == \"select\" || event == \"load\") &&\n//     type == \"tab\" &&\n//     extraData[id].type == \"reader\"\n//   ) {\n//     ztoolkit.log(\"onOpenTab\", event, type, extraData);\n//     if (getPref(\"enableBookmark\")) {\n//       await registerOutline(id as string);\n//     } else {\n//       ztoolkit.log(\"Jasminum bookmark is disabled\");\n//     }\n//   }\n// }\n\nexport async function registerExtraColumnWithCustomCell() {\n  const registeredDataKey = Zotero.ItemTreeManager.registerColumn({\n    dataKey: \"CNKIcitation\",\n    label: getString(\"CNKIcitation\"),\n    pluginID: config.addonID,\n    dataProvider: (item, dataKey) => {\n      // 网友提供的特殊字符，方便排序\n      return ztoolkit.ExtraField.getExtraField(item, \"CNKICite\") || \"\\u2068\";\n    },\n    // @ts-ignore - Not typed.\n    // renderCell(index, data, column, isFirstColumn, doc) {\n    //   const span = doc.createElementNS(\"http://www.w3.org/1999/xhtml\", \"span\");\n    //   span.className = `cell ${column.className}`;\n    //   span.title = getString(\"CNKIcitation\");\n    //   span.innerText = data == \"\" ? null : data;\n    //   return span;\n    // },\n  });\n}\n\n// For Outline register.\nexport function registerTab() {\n  Zotero.Reader.registerEventListener(\n    \"renderToolbar\",\n    tabRegisterCallback,\n    config.addonID,\n  );\n\n  //   Zotero.Reader.registerEventListener(\n  //     \"renderTextSelectionPopup\",\n  //     (event: any) => {\n  //       ztoolkit.log(event);\n  //       event.append(\"<div>Jasminum</div>\");\n  //     },\n  //   );\n}\n\nasync function tabRegisterCallback(event: any) {\n  if (getPref(\"enableBookmark\")) {\n    const { reader } = event;\n    await registerOutline(reader.tabID);\n  } else {\n    ztoolkit.log(\"Jasminum bookmark is disabled\");\n  }\n}\n"
  },
  {
    "path": "src/modules/outline/bookmark.ts",
    "content": "import { version } from \"../../../package.json\";\nimport { getString } from \"../../utils/locale\";\nimport { ICONS } from \"./style\";\nimport { OUTLINE_SCHEMA } from \"./outline\";\n\nexport const BOOKMARK_SCHEMA = OUTLINE_SCHEMA;\nexport const DEFAULT_BOOKMARK_FONT_SIZE = 13; // Default font size for bookmarks\n\n// 学生友好的清新现代颜色\nexport const DEFAULT_BOOKMARK_COLORS = [\n  \"#FF6B6B\", // 珊瑚红\n  \"#4ECDC4\", // 薄荷绿\n  \"#45B7D1\", // 天空蓝\n  \"#96CEB4\", // 薄荷色\n  \"#FECA57\", // 向日葵黄\n  \"#FF9FF3\", // 粉紫色\n  \"#54A0FF\", // 宝蓝色\n  \"#5F27CD\", // 紫罗兰\n  \"#00D2D3\", // 青绿色\n  \"#FF9F43\", // 橙色\n  \"#10AC84\", // 翡翠绿\n  \"#EE5A24\", // 朱砂橙\n];\n\n// 获取随机颜色\nfunction getRandomBookmarkColor(): string {\n  const randomIndex = Math.floor(\n    Math.random() * DEFAULT_BOOKMARK_COLORS.length,\n  );\n  return DEFAULT_BOOKMARK_COLORS[randomIndex];\n}\n\nfunction migrateBookmarkInfo(\n  raw: any,\n  fromSchema: number,\n): { bookmarks: BookmarkNode[]; baseFontSize: number } {\n  let bookmarks: BookmarkNode[] = raw.bookmarks ?? [];\n  let baseFontSize = DEFAULT_BOOKMARK_FONT_SIZE;\n\n  // v1 → v2: add baseFontSize and bookmark color\n  if (fromSchema < 2) {\n    baseFontSize = raw.info?.baseFontSize ?? DEFAULT_BOOKMARK_FONT_SIZE;\n    bookmarks = bookmarks.map((b: any) => ({\n      ...b,\n      color: b.color || getRandomBookmarkColor(),\n    }));\n  }\n\n  // Future v2 → v3 migrations go here\n\n  return { bookmarks, baseFontSize };\n}\n\nfunction getReaderPagePosition(): PdfPosition {\n  const reader = Zotero.Reader.getByTabID(\n    ztoolkit.getGlobal(\"Zotero_Tabs\").selectedID,\n  );\n  const primaryView = reader._internalReader\n    ._primaryView as _ZoteroTypes.Reader.PDFView;\n  const PDFViewerApplication = primaryView._iframeWindow!.PDFViewerApplication;\n  const doc = primaryView._iframeWindow!.document;\n  const container = doc.getElementById(\"viewerContainer\")!;\n  const pageIndex = PDFViewerApplication.pdfViewer!.currentPageNumber - 1;\n  const pageView = PDFViewerApplication.pdfViewer!.getPageView(pageIndex);\n  const viewport = pageView.viewport;\n  const scrollX = 0;\n  const scrollY = container.scrollTop - pageView.div.offsetTop;\n  const [x, y] = viewport.convertToPdfPoint(scrollX, scrollY);\n  return { position: { pageIndex, rects: [[x, y, x, y]] } };\n}\n\nexport async function saveBookmarksToJSON(\n  item?: Zotero.Item,\n  bookmarks?: BookmarkNode[],\n  baseFontSize?: number,\n) {\n  if (!bookmarks) {\n    bookmarks = getBookmarksFromPage();\n  }\n  if (!item) {\n    const reader = Zotero.Reader.getByTabID(\n      ztoolkit.getGlobal(\"Zotero_Tabs\").selectedID,\n    );\n    item = reader._item;\n  }\n  // Get current baseFontSize if not provided\n  if (baseFontSize === undefined) {\n    const currentInfo = await loadBookmarkInfoFromJSON(item);\n    baseFontSize = currentInfo?.baseFontSize ?? DEFAULT_BOOKMARK_FONT_SIZE;\n  }\n  const bookmarkInfo: BookmarkInfo = {\n    info: {\n      itemID: item.id,\n      schema: BOOKMARK_SCHEMA,\n      jasminumVersion: version,\n      baseFontSize: baseFontSize,\n    },\n    bookmarks: bookmarks,\n  };\n  const bookmarkStr = JSON.stringify(bookmarkInfo);\n  const bookmarkPath = PathUtils.join(\n    Zotero.DataDirectory.dir,\n    \"storage\",\n    item.key,\n    \"jasminum-bookmarks.json\",\n  );\n  await Zotero.File.putContentsAsync(bookmarkPath, bookmarkStr);\n  ztoolkit.log(\"Save bookmarks to JSON\");\n}\n\nexport async function loadBookmarkInfoFromJSON(\n  item: Zotero.Item,\n): Promise<{ bookmarks: BookmarkNode[]; baseFontSize: number } | null> {\n  const bookmarkPath = PathUtils.join(\n    Zotero.DataDirectory.dir,\n    \"storage\",\n    item.key,\n    \"jasminum-bookmarks.json\",\n  );\n  const isFileExist = await IOUtils.exists(bookmarkPath);\n  if (!isFileExist) {\n    ztoolkit.log(`Bookmarks json is missing: ${bookmarkPath}`);\n    return null;\n  } else {\n    const content = (await Zotero.File.getContentsAsync(\n      bookmarkPath,\n    )) as string;\n    const tmp = JSON.parse(content);\n    const fileSchema = tmp.info?.schema ?? 1;\n    if (fileSchema < BOOKMARK_SCHEMA) {\n      // Migrate old bookmark data instead of discarding\n      const migrated = migrateBookmarkInfo(tmp, fileSchema);\n      await saveBookmarksToJSON(\n        item,\n        migrated.bookmarks,\n        migrated.baseFontSize,\n      );\n      return migrated;\n    } else {\n      const bookmarkInfo = JSON.parse(content) as BookmarkInfo;\n      return {\n        bookmarks: bookmarkInfo.bookmarks,\n        baseFontSize:\n          bookmarkInfo.info.baseFontSize ?? DEFAULT_BOOKMARK_FONT_SIZE,\n      };\n    }\n  }\n}\n\nexport async function loadBookmarksFromJSON(\n  item: Zotero.Item,\n): Promise<BookmarkNode[] | null> {\n  const info = await loadBookmarkInfoFromJSON(item);\n  return info?.bookmarks ?? null;\n}\n\nexport function getBookmarksFromPage(): BookmarkNode[] {\n  const reader = Zotero.Reader.getByTabID(\n    ztoolkit.getGlobal(\"Zotero_Tabs\").selectedID,\n  );\n  const rootUL = reader._iframeWindow!.document.querySelector(\n    \"#bookmark-root-list\",\n  );\n  if (!rootUL) return [];\n\n  const bookmarkItems = Array.from(rootUL.querySelectorAll(\"li.bookmark-item\"));\n  if (bookmarkItems.length === 0) {\n    ztoolkit.log(\"No bookmarks found on this page.\");\n    return [];\n  }\n  return bookmarkItems.map((li, index) => {\n    const bookmarkDiv = (li as Element).querySelector(\"div.bookmark-node\")!;\n    const titleSpan = (li as Element).querySelector(\"span.bookmark-title\")!;\n    return {\n      id: bookmarkDiv.getAttribute(\"data-id\")!,\n      title: titleSpan.textContent!,\n      page: parseInt(bookmarkDiv.getAttribute(\"page\")!),\n      x: parseFloat(bookmarkDiv.getAttribute(\"x\")!),\n      y: parseFloat(bookmarkDiv.getAttribute(\"y\")!),\n      order: index,\n      createdAt: parseInt(bookmarkDiv.getAttribute(\"data-created\") || \"0\"),\n      color:\n        bookmarkDiv.getAttribute(\"data-color\") || DEFAULT_BOOKMARK_COLORS[0],\n    };\n  });\n}\n\nexport function createBookmarkNodes(\n  nodes: BookmarkNode[] | null,\n  parentElement: HTMLElement,\n  doc: Document,\n) {\n  if (nodes === null || nodes.length == 0) {\n    ztoolkit.UI.appendElement(\n      {\n        tag: \"div\",\n        namespace: \"html\",\n        classList: [\"empty-bookmark-prompt\"],\n        properties: { innerHTML: `请点击上方按钮${ICONS.add}创建书签` },\n      },\n      parentElement,\n    );\n  } else {\n    // 按order排序\n    const sortedNodes = [...nodes].sort((a, b) => a.order - b.order);\n    sortedNodes.forEach((node) => {\n      const li = ztoolkit.UI.createElement(doc, \"li\", {\n        namespace: \"html\",\n        classList: [\"bookmark-item\"],\n        children: [\n          {\n            tag: \"div\",\n            namespace: \"html\",\n            classList: [\"bookmark-node\"],\n            attributes: {\n              draggable: \"true\",\n              \"data-id\": node.id,\n              page: node.page,\n              x: node.x,\n              y: node.y,\n              \"data-created\": node.createdAt,\n              \"data-color\": node.color,\n            },\n            styles: {\n              borderLeftColor: node.color,\n            },\n            children: [\n              {\n                tag: \"div\",\n                namespace: \"html\",\n                classList: [\"bookmark-content\"],\n                children: [\n                  {\n                    tag: \"span\",\n                    namespace: \"html\",\n                    classList: [\"bookmark-title\"],\n                    properties: { textContent: node.title },\n                    attributes: {\n                      title: `${node.title}, Page: ${node.page}`,\n                    },\n                  },\n                ],\n              },\n            ],\n          },\n        ],\n      });\n      parentElement.appendChild(li);\n    });\n  }\n}\n\n// 生成智能书签名称\nfunction generateSmartBookmarkTitle(pageNumber: number): string {\n  const existingBookmarks = getBookmarksFromPage();\n  const baseName = `P_${pageNumber}_`;\n\n  // 检查是否有重名\n  const existingTitles = existingBookmarks.map((b) => b.title);\n\n  // 找到下一个可用的数字后缀\n  let counter = 1;\n  let candidateName = `${baseName}${counter}`;\n  while (existingTitles.includes(candidateName)) {\n    counter++;\n    candidateName = `${baseName}${counter}`;\n  }\n\n  return candidateName;\n}\n\nexport function addNewBookmark(title?: string): BookmarkNode {\n  const location = getReaderPagePosition();\n  const now = Date.now();\n  const pageNumber = location.position.pageIndex + 1;\n  return {\n    id: `bookmark_${now}_${Math.random().toString(36).substr(2, 9)}`,\n    title: title || generateSmartBookmarkTitle(pageNumber),\n    page: pageNumber,\n    x: location.position.rects[0][0],\n    y: location.position.rects[0][1],\n    order: now, // 使用时间戳作为默认排序\n    createdAt: now,\n    color: getRandomBookmarkColor(),\n  };\n}\n\nexport function addBookmarkButton(doc: Document) {\n  if (doc.querySelector(\"#sidebarContainer div.start\") === null) {\n    ztoolkit.log(\"Sidebar toolbar button is missing.\");\n  }\n  ztoolkit.UI.appendElement(\n    {\n      tag: \"button\",\n      namespace: \"html\",\n      id: \"j-bookmark-button\",\n      classList: [\"toolbar-button\"],\n      properties: { innerHTML: ICONS.bookmark },\n      attributes: {\n        title: getString(\"bookmark\"),\n        tabindex: \"-1\",\n        role: \"tab\",\n        \"aria-selected\": \"false\",\n        \"aria-controls\": \"j-bookmark-viewer\",\n      },\n    },\n    doc.querySelector(\"#sidebarContainer div.start\")!,\n  );\n}\n\n// Update bookmark font size dynamically\nexport function updateBookmarkFontSize(doc: Document, baseFontSize: number) {\n  const styleId = \"jasminum-bookmark-dynamic-font-size\";\n  let styleElement = doc.getElementById(styleId) as HTMLStyleElement;\n\n  if (!styleElement) {\n    styleElement = doc.createElement(\"style\");\n    styleElement.id = styleId;\n    styleElement.type = \"text/css\";\n    doc.querySelector(\"head\")!.appendChild(styleElement);\n  }\n\n  const dynamicCSS = `\n    .bookmark-node {\n      font-size: ${baseFontSize}px !important;\n    }\n  `;\n\n  styleElement.textContent = dynamicCSS;\n  ztoolkit.log(`Updated bookmark font size: ${baseFontSize}px`);\n}\n"
  },
  {
    "path": "src/modules/outline/events.ts",
    "content": "import {\n  saveOutlineToJSON,\n  createTreeNodes,\n  getOutlineFromPDF,\n  updateOutlineFontSize,\n  loadOutlineInfoFromJSON,\n  DEFAULT_BASE_FONT_SIZE,\n} from \"./outline\";\nimport {\n  saveBookmarksToJSON,\n  createBookmarkNodes,\n  addNewBookmark,\n  DEFAULT_BOOKMARK_COLORS,\n  updateBookmarkFontSize,\n  loadBookmarkInfoFromJSON,\n  DEFAULT_BOOKMARK_FONT_SIZE,\n} from \"./bookmark\";\nimport { ICONS } from \"./style\";\nimport { getString } from \"../../utils/locale\";\nimport { getPref } from \"../../utils/prefs\";\n\nconst MAX_LEVEL = 7;\n\nfunction getReaderPagePosition(): PdfPosition {\n  const reader = Zotero.Reader.getByTabID(\n    ztoolkit.getGlobal(\"Zotero_Tabs\").selectedID,\n  );\n  const primaryView = reader._internalReader\n    ._primaryView as _ZoteroTypes.Reader.PDFView;\n  const PDFViewerApplication = primaryView._iframeWindow!.PDFViewerApplication;\n  const doc = primaryView._iframeWindow!.document;\n  const container = doc.getElementById(\"viewerContainer\")!;\n  const pageIndex = PDFViewerApplication.pdfViewer!.currentPageNumber - 1;\n  const pageView = PDFViewerApplication.pdfViewer!.getPageView(pageIndex);\n  const viewport = pageView.viewport;\n  // const scrollX = container.scrollLeft - pageView.div.offsetLeft;\n  const scrollX = 0;\n  const scrollY = container.scrollTop - pageView.div.offsetTop;\n  const [x, y] = viewport.convertToPdfPoint(scrollX, scrollY);\n  ztoolkit.log(\n    \"get position\",\n    pageIndex + 1,\n    container.scrollTop,\n    scrollX,\n    scrollY,\n    x,\n    y,\n  );\n  return { position: { pageIndex, rects: [[x, y, x, y]] } };\n}\n\nexport function initEventListener(\n  reader: _ZoteroTypes.ReaderInstance,\n  doc: Document,\n) {\n  // Hide or show side bar\n  function hideShowMyOutlineAndBar(e: Event) {\n    const targetElement = e.target as Element;\n    const button = targetElement.closest(\"button\");\n    if (!button) return;\n    ztoolkit.log(\"click to hide outline/bookmark\", targetElement, button);\n\n    // Enable j outline view\n    if (button.id === \"j-outline-button\") {\n      ztoolkit.log(\"jasminum show outline\");\n      reader.setSidebarView(\"jasminum-outline\");\n      doc.getElementById(\"jasminum-outline\")?.classList.remove(\"hidden\");\n      doc.getElementById(\"jasminum-bookmarks\")?.classList.add(\"hidden\");\n      doc\n        .getElementById(\"j-outline-toolbar\")\n        ?.classList.toggle(\"j-hidden\", false);\n      doc\n        .getElementById(\"j-bookmark-toolbar\")\n        ?.classList.toggle(\"j-hidden\", true);\n      button.classList.toggle(\"active\", true);\n      doc\n        .getElementById(\"j-bookmark-button\")\n        ?.classList.toggle(\"active\", false);\n    } else if (button.id === \"j-bookmark-button\") {\n      ztoolkit.log(\"jasminum show bookmark\");\n      reader.setSidebarView(\"jasminum-bookmarks\");\n      doc.getElementById(\"jasminum-bookmarks\")?.classList.remove(\"hidden\");\n      doc.getElementById(\"jasminum-outline\")?.classList.add(\"hidden\");\n      doc\n        .getElementById(\"j-bookmark-toolbar\")\n        ?.classList.toggle(\"j-hidden\", false);\n      doc\n        .getElementById(\"j-outline-toolbar\")\n        ?.classList.toggle(\"j-hidden\", true);\n      button.classList.toggle(\"active\", true);\n      doc.getElementById(\"j-outline-button\")?.classList.toggle(\"active\", false);\n    } else {\n      // Hide both outline and bookmark views\n      ztoolkit.log(\"hide jasminum views\");\n      doc.getElementById(\"jasminum-outline\")?.classList.toggle(\"hidden\", true);\n      doc\n        .getElementById(\"jasminum-bookmarks\")\n        ?.classList.toggle(\"hidden\", true);\n      doc\n        .getElementById(\"j-outline-toolbar\")\n        ?.classList.toggle(\"j-hidden\", true);\n      doc\n        .getElementById(\"j-bookmark-toolbar\")\n        ?.classList.toggle(\"j-hidden\", true);\n      doc.getElementById(\"j-outline-button\")?.classList.toggle(\"active\", false);\n      doc\n        .getElementById(\"j-bookmark-button\")\n        ?.classList.toggle(\"active\", false);\n    }\n  }\n  // 给默认按钮添加事件，避免切换面板时异常\n  doc\n    .querySelector(\"#sidebarContainer > div.sidebar-toolbar > div.start\")\n    ?.addEventListener(\"click\", hideShowMyOutlineAndBar);\n\n  const treeContainer = doc.getElementById(\"j-outline-viewer\");\n  if (!treeContainer) return;\n\n  // 节点展开/折叠事件，选中节点\n  treeContainer // 节点点击选择事件\n    .addEventListener(\"click\", async (e: Event) => {\n      const target = e.target as HTMLElement;\n      ztoolkit.log(\"click container\", e.target);\n      // 检查是否点击的是展开/折叠图标\n      const spanElement = target.closest(\"span\");\n      if (spanElement && spanElement.classList.contains(\"expander\")) {\n        ztoolkit.log(\"click expander\");\n        const listItem = target.closest(\"li\");\n        if (!listItem) return;\n        toggleNode(listItem);\n        e.stopPropagation();\n        await saveOutlineToJSON();\n        return;\n      }\n\n      // 节点选择\n      if (target.closest(\".tree-node\")) {\n        selectNode(target.closest(\".tree-node\")!);\n        clickToPosition(target);\n      }\n    });\n\n  // 双击编辑节点\n  treeContainer.addEventListener(\"dblclick\", function (e) {\n    if ((e.target as Element).classList.contains(\"node-title\")) {\n      makeNodeEditable(e.target as Element);\n      e.stopPropagation();\n    }\n  });\n\n  // 书签上方工具栏事件\n  doc\n    .getElementById(\"j-outline-expand-all\")\n    ?.addEventListener(\"click\", expandAll);\n  doc\n    .getElementById(\"j-outline-collapse-all\")\n    ?.addEventListener(\"click\", collapseAll);\n  doc\n    .getElementById(\"j-outline-add-node\")\n    ?.addEventListener(\"click\", addNewNode);\n  doc\n    .getElementById(\"j-outline-delete-node\")\n    ?.addEventListener(\"click\", deleteSelectedNode);\n  doc\n    .getElementById(\"j-outline-save-pdf\")\n    ?.addEventListener(\"click\", async (ev: Event) => {\n      const button = ev.currentTarget as HTMLButtonElement;\n      button.disabled = true;\n      await addOutlineToPDFRunner();\n      button.disabled = false;\n    });\n\n  // 拖拽相关事件\n  treeContainer.addEventListener(\"dragstart\", handleDragStart);\n  treeContainer.addEventListener(\"dragover\", handleDragOver);\n  treeContainer.addEventListener(\"dragleave\", handleDragLeave);\n  treeContainer.addEventListener(\"drop\", handleDrop);\n  treeContainer.addEventListener(\"dragend\", handleDragEnd);\n\n  // 处理键盘事件\n  treeContainer.addEventListener(\"keydown\", handleKeydownEvent);\n\n  // 点击书签跳转到具体页码\n\n  // 书签相关事件处理\n  const bookmarkContainer = doc.getElementById(\"j-bookmark-viewer\");\n  if (bookmarkContainer) {\n    // 书签点击选择和跳转事件\n    bookmarkContainer.addEventListener(\"click\", async (e: Event) => {\n      const target = e.target as HTMLElement;\n      ztoolkit.log(\"click bookmark container\", e.target);\n\n      // 书签选择和跳转\n      if (target.closest(\".bookmark-node\")) {\n        selectBookmarkNode(target.closest(\".bookmark-node\")!);\n        clickToBookmarkPosition(target);\n      }\n    });\n\n    // 双击编辑书签\n    bookmarkContainer.addEventListener(\"dblclick\", function (e) {\n      if ((e.target as Element).classList.contains(\"bookmark-title\")) {\n        makeBookmarkNodeEditable(e.target as Element);\n        e.stopPropagation();\n      }\n    });\n\n    // 书签拖拽相关事件\n    bookmarkContainer.addEventListener(\"dragstart\", handleBookmarkDragStart);\n    bookmarkContainer.addEventListener(\"dragover\", handleBookmarkDragOver);\n    bookmarkContainer.addEventListener(\"dragleave\", handleBookmarkDragLeave);\n    bookmarkContainer.addEventListener(\"drop\", handleBookmarkDrop);\n    bookmarkContainer.addEventListener(\"dragend\", handleBookmarkDragEnd);\n  }\n\n  // 书签工具栏事件\n  doc\n    .getElementById(\"j-bookmark-add\")\n    ?.addEventListener(\"click\", addNewBookmarkNode);\n  doc\n    .getElementById(\"j-bookmark-delete\")\n    ?.addEventListener(\"click\", deleteSelectedBookmarkNode);\n\n  // 字体大小调整按钮事件\n  doc\n    .getElementById(\"j-outline-zoom-in\")\n    ?.addEventListener(\"click\", handleFontSizeIncrease);\n  doc\n    .getElementById(\"j-outline-zoom-out\")\n    ?.addEventListener(\"click\", handleFontSizeDecrease);\n}\n\n// 为节点添加事件监听，以下为事件处理函数\nasync function expandAll(ev: Event) {\n  const doc = (ev.target as Element).ownerDocument;\n  const collapsedNodes = doc.querySelectorAll(\".tree-item.collapsed\");\n  collapsedNodes.forEach((node) => {\n    node.classList.remove(\"collapsed\");\n\n    const expander = node.querySelector(\".expander\");\n    if (expander?.hasChildNodes()) {\n      //expander!.textContent = \"▼\";\n      expander!.innerHTML = ICONS.down;\n    }\n  });\n  await saveOutlineToJSON();\n}\n\nasync function collapseAll(ev: Event) {\n  const doc = (ev.target as Element).ownerDocument;\n  const parentNodes = doc.querySelectorAll(\".tree-item.has-children\");\n  parentNodes.forEach((node) => {\n    node.classList.add(\"collapsed\");\n    const expander = node.querySelector(\".expander\");\n    if (expander?.hasChildNodes()) {\n      //expander!.textContent = \"►\";\n      expander!.innerHTML = ICONS.right;\n    }\n  });\n  await saveOutlineToJSON();\n}\n\n// 切换节点展开/折叠状态\nfunction toggleNode(node: Element) {\n  if (node.classList.contains(\"has-children\")) {\n    node.classList.toggle(\"collapsed\");\n\n    // 更新展开/折叠图标\n    const expander = node.querySelector(\".expander\");\n    if (node.classList.contains(\"collapsed\")) {\n      //expander!.textContent = \"►\";\n      expander!.innerHTML = ICONS.right;\n    } else {\n      //expander!.textContent = \"▼\";\n      expander!.innerHTML = ICONS.down;\n    }\n  }\n}\n\n// 选择节点\nfunction selectNode(node: Element) {\n  const doc = node.ownerDocument;\n  const selectedNode = doc.querySelector(\".node-selected\");\n  // 取消之前的选择\n  if (selectedNode) {\n    selectedNode.classList.remove(\"node-selected\");\n  }\n\n  // 设置新选择\n  node.classList.add(\"node-selected\");\n}\n\n// Key events for the outline panel.\nexport async function handleKeydownEvent(ev: KeyboardEvent) {\n  const newPanel = (ev.target! as Element).ownerDocument.getElementById(\n    \"root-list\",\n  )!;\n  const nodes = Array.from(newPanel.querySelectorAll(\"div.tree-node\"));\n  const selectedNode = newPanel.querySelector(\"div.tree-node.node-selected\");\n  let currentIdx = nodes.indexOf(selectedNode as Element);\n  // ztoolkit.log(\"Keydown event\", currentIdx, ev);\n\n  if (ev.key === \"ArrowDown\") {\n    while (currentIdx < nodes.length - 1) {\n      const nextNode = nodes[currentIdx + 1] as HTMLElement;\n      // ztoolkit.log(\"Next node\", currentIdx, nextNode);\n      if (nextNode && nextNode.checkVisibility()) {\n        nextNode.querySelector<HTMLElement>(\"span.node-title\")!.click();\n        nextNode.focus();\n        break;\n      }\n      currentIdx += 1;\n    }\n  }\n  if (ev.key === \"ArrowUp\") {\n    while (currentIdx > 0) {\n      const nextNode = nodes[currentIdx - 1] as HTMLElement;\n      if (nextNode && nextNode.checkVisibility()) {\n        nextNode.querySelector<HTMLElement>(\"span.node-title\")!.click();\n        nextNode.focus();\n        break;\n      }\n      currentIdx -= 1;\n    }\n  }\n\n  if (ev.key === \"ArrowLeft\" || ev.key === \"ArrowRight\") {\n    (selectedNode?.querySelector(\"span.expander\") as HTMLElement).click();\n  }\n\n  if (ev.key === \" \") {\n    // ztoolkit.log(\"Space key pressed\", selectedNode);\n    ev.preventDefault();\n    makeNodeEditable(\n      selectedNode!.querySelector<HTMLElement>(\"span.node-title\")!,\n    );\n  }\n\n  if (ev.key === \"Delete\" || ev.key === \"Backspace\") {\n    // ztoolkit.log(\"Delete key pressed\");\n    deleteSelectedNode(ev);\n  }\n\n  // Level up\n  if (ev.key === \"[\") {\n    // ztoolkit.log(\"[ key pressed\");\n    const targetNode = (ev.target as Element).querySelector<Element>(\n      \".node-selected\",\n    )!;\n    const targetLi = targetNode.closest(\"li\")!;\n    const oldParentUl = targetLi.parentElement!;\n    const oldGrandParent = oldParentUl.parentElement!;\n    // 如果是根节点，直接返回\n    if (oldParentUl.id === \"root-list\") return;\n    oldParentUl.removeChild(targetLi);\n    // 此时原来的父节点已经没有子节点了，删除\n    if (oldParentUl.children.length === 0) {\n      oldGrandParent.removeChild(oldParentUl);\n      oldGrandParent.classList.remove(\"has-children\");\n      const expander = oldGrandParent.querySelector(\".expander\")!;\n      expander.textContent = \" \";\n    }\n    oldGrandParent.parentElement!.insertBefore(\n      targetLi,\n      oldGrandParent.nextSibling,\n    );\n    updateNodeLevels(targetLi);\n    await saveOutlineToJSON();\n  }\n  // Level down\n  if (ev.key === \"]\") {\n    // ztoolkit.log(\"] key pressed\");\n    const targetNode = (ev.target as Element).querySelector<Element>(\n      \".node-selected\",\n    )!;\n    const targetLi = targetNode.closest(\"li\")!;\n    const parentLi = targetLi.previousElementSibling;\n    if (!parentLi) return;\n    let parentUl = parentLi.querySelector(\"ul\");\n\n    // 如果没有子列表，创建一个\n    if (!parentUl) {\n      parentUl = targetNode.ownerDocument.createElement(\"ul\");\n      parentUl.classList.add(\"tree-list\");\n      parentLi.appendChild(parentUl);\n\n      // 更新父节点状态\n      parentLi.classList.add(\"has-children\");\n      const expander = parentLi.querySelector(\".expander\")!;\n      // expander.textContent = \"▼\";\n      expander.innerHTML = ICONS.down;\n    }\n    // 添加到子列表\n    parentUl.appendChild(targetLi);\n    // 确保目标节点展开\n    targetLi.classList.remove(\"collapsed\");\n\n    updateNodeLevels(targetLi);\n    await saveOutlineToJSON();\n  }\n\n  // Add new node\n  if (ev.key === \"\\\\\") {\n    // ztoolkit.log(\"\\\\ key pressed\");\n    addNewNode(ev);\n  }\n}\n\nexport function handleDragStart(e: DragEvent) {\n  // if (!(e.target instanceof HTMLElement)) return;\n  ztoolkit.log(\" start to drag\");\n  const target = e.target as Element;\n  if (!target.classList.contains(\"tree-node\")) return;\n\n  const draggedNode = target.closest(\"li\") as HTMLElement;\n  e.dataTransfer!.setData(\"text/plain\", draggedNode.innerText);\n  e.dataTransfer!.effectAllowed = \"move\";\n\n  // 为拖拽中的元素添加样式\n  setTimeout(() => {\n    draggedNode.classList.add(\"dragging\");\n  }, 0);\n}\n\n// 拖拽经过目标元素\nexport function handleDragOver(e: DragEvent) {\n  e.preventDefault();\n  e.dataTransfer!.dropEffect = \"move\";\n  const target = e.target as HTMLElement;\n  const doc = target.ownerDocument;\n  // 修复坐标异常\n  const upperHeight =\n    doc.querySelector(\"html\")?.getBoundingClientRect().height || 41;\n  const draggedNode = doc.querySelector(\".dragging\");\n  if (!draggedNode) return;\n\n  // if (!(e.target instanceof HTMLElement)) return;\n  // 找到最近的节点元素\n  const targetNode = target.closest(\".tree-node\");\n  if (!targetNode) {\n    hideDropIndicator(doc);\n    return;\n  }\n\n  // 不能拖拽到自己或自己的子元素\n  const targetLi = targetNode.closest(\"li\") as Element;\n  if (draggedNode === targetLi || isAncestor(draggedNode, targetLi)) {\n    hideDropIndicator(doc);\n    return;\n  }\n\n  // 计算拖拽位置（上方、中间放入其中、下方）\n  const rect = targetNode.getBoundingClientRect();\n  const mouseY = e.clientY;\n  const relativeY = mouseY - rect.top;\n  const height = rect.height;\n\n  let dropPosition;\n  if (relativeY < height * 0.25) {\n    dropPosition = \"before\";\n  } else if (relativeY > height * 0.75) {\n    dropPosition = \"after\";\n  } else {\n    dropPosition = \"inside\";\n  }\n\n  // 如果位置或目标变化了，才更新指示器\n  // 临时数据暂时存储在window中\n  if (\n    doc.defaultView!.lastDropPosition !== dropPosition ||\n    doc.defaultView!.lastDropTarget !== targetLi\n  ) {\n    updateDropIndicator(targetNode, dropPosition, upperHeight);\n    doc.defaultView!.lastDropPosition = dropPosition;\n    doc.defaultView!.lastDropTarget = targetLi;\n  }\n\n  // 添加可放置样式\n  doc.querySelectorAll(\".dragover\").forEach((el) => {\n    el.classList.remove(\"dragover\");\n  });\n  targetNode.classList.add(\"dragover\");\n}\n\nfunction updateDropIndicator(\n  targetNode: Element,\n  position: string,\n  upperHeight: number,\n) {\n  const rect = targetNode.getBoundingClientRect();\n  const doc = targetNode.ownerDocument;\n  const dropIndicator = doc.querySelector(\".drop-indicator\") as HTMLElement;\n\n  // 清除所有位置类\n  dropIndicator.classList.remove(\"top\", \"middle\", \"bottom\");\n  dropIndicator.classList.add(\"visible\");\n\n  if (position === \"before\") {\n    dropIndicator.classList.add(\"top\");\n    dropIndicator.style.left = `${rect.left}px`;\n    dropIndicator.style.top = `${rect.top - 2 - upperHeight}px`;\n    dropIndicator.style.width = `${rect.width}px`;\n  } else if (position === \"after\") {\n    dropIndicator.classList.add(\"bottom\");\n    dropIndicator.style.left = `${rect.left}px`;\n    dropIndicator.style.top = `${rect.bottom - upperHeight}px`;\n    dropIndicator.style.width = `${rect.width}px`;\n  } else {\n    // inside position\n    dropIndicator.classList.add(\"middle\");\n    dropIndicator.style.left = `${rect.left + 20}px`;\n    dropIndicator.style.top = `${rect.top + rect.height / 2 - upperHeight}px`;\n    dropIndicator.style.width = `${rect.width - 25}px`;\n  }\n}\n\nfunction hideDropIndicator(doc: Document) {\n  const dropIndicator = doc.querySelector(\".drop-indicator\")!;\n  dropIndicator.classList.remove(\"visible\");\n  doc.defaultView!.lastDropPosition = null;\n  doc.defaultView!.lastDropTarget = null;\n}\n\n// 拖拽离开目标元素\nexport function handleDragLeave(e: DragEvent) {\n  const doc = (e.target as Element).ownerDocument;\n  if (\n    !e.relatedTarget ||\n    !(e.relatedTarget as Element).closest(\"#j-outline-viewer\")\n  ) {\n    hideDropIndicator(doc);\n  }\n\n  const targetNode = (e.target as HTMLElement).closest(\".tree-node\");\n  if (targetNode) {\n    // 移除可放置样式\n    targetNode.classList.remove(\"dragover\");\n  }\n}\n\n// 处理放置\nexport async function handleDrop(e: DragEvent) {\n  e.preventDefault();\n  // if (!(e.target instanceof HTMLElement)) return;\n  const target = e.target as HTMLElement;\n  const doc = target.ownerDocument;\n  const draggedNode = doc.querySelector(\".dragging\");\n\n  // 隐藏指示器\n  hideDropIndicator(doc);\n\n  if (!draggedNode) return;\n  // 获取目标节点\n  const targetTreeNode = target.closest(\".tree-node\");\n  if (!targetTreeNode) return;\n\n  // 移除可放置样式\n  doc.querySelectorAll(\".dragover\").forEach((el) => {\n    el.classList.remove(\"dragover\");\n  });\n  // 获取目标列表项\n  const targetLi = targetTreeNode.closest(\"li\")!;\n\n  // 不能将节点拖到自己或其子节点上\n  if (draggedNode === targetLi || isAncestor(draggedNode, targetLi)) {\n    return;\n  }\n\n  // 移除拖拽的节点\n  const oldParent = draggedNode.parentNode! as HTMLElement;\n  oldParent.removeChild(draggedNode);\n\n  // 判断放置位置：是作为子节点还是兄弟节点\n  const dropPosition = determineDropPosition(e, targetTreeNode);\n\n  if (dropPosition === \"child\") {\n    // 作为子节点\n    let targetUl = targetLi.querySelector(\"ul\");\n\n    // 如果没有子列表，创建一个\n    if (!targetUl) {\n      targetUl = doc.createElement(\"ul\");\n      targetUl.classList.add(\"tree-list\");\n      targetLi.appendChild(targetUl);\n\n      // 更新父节点状态\n      targetLi.classList.add(\"has-children\");\n      const expander = targetLi.querySelector(\".expander\")!;\n      // expander.textContent = \"▼\";\n      expander.innerHTML = ICONS.down;\n    }\n\n    // 确保目标节点展开\n    targetLi.classList.remove(\"collapsed\");\n\n    // 添加到子列表\n    targetUl.appendChild(draggedNode);\n  } else {\n    // 作为兄弟节点\n    const targetParent = targetLi.parentNode!;\n\n    if (dropPosition === \"before\") {\n      targetParent.insertBefore(draggedNode, targetLi);\n    } else {\n      // 'after'\n      targetParent.insertBefore(draggedNode, targetLi.nextSibling);\n    }\n  }\n\n  // 如果原父列表为空，更新其父节点状态\n  if (\n    oldParent.children.length === 0 &&\n    oldParent.tagName === \"UL\" &&\n    oldParent !== doc.getElementById(\"root-list\")\n  ) {\n    const oldGrandParent = oldParent.parentNode as HTMLElement;\n    oldGrandParent.removeChild(oldParent);\n    oldGrandParent.classList.remove(\"has-children\");\n    const expander = oldGrandParent.querySelector(\".expander\")!;\n    expander.textContent = \" \";\n  }\n\n  // 更新节点级别样式\n  updateNodeLevels(draggedNode);\n\n  // 保存节点信息\n  await saveOutlineToJSON();\n}\n\n// 拖拽结束\nexport function handleDragEnd(e: DragEvent) {\n  // if (!(e.target instanceof HTMLElement)) return;\n  const doc = (e.target as HTMLElement).ownerDocument;\n  const draggedNode = doc.querySelector(\".dragging\");\n  if (!draggedNode) return;\n\n  draggedNode.classList.remove(\"dragging\");\n\n  // 隐藏指示器\n  hideDropIndicator(doc);\n\n  // 清除所有dragover样式\n  doc.querySelectorAll(\".dragover\").forEach((el) => {\n    el.classList.remove(\"dragover\");\n  });\n}\n\n// 检查一个节点是否是另一个节点的祖先\nfunction isAncestor(ancestor: Element, descendant: Element) {\n  let current = descendant.parentNode;\n  while (current) {\n    if (current === ancestor) {\n      return true;\n    }\n    current = current.parentNode;\n  }\n  return false;\n}\n\n// 确定放置位置：作为子节点、同级前面或同级后面\nfunction determineDropPosition(event: DragEvent, targetNode: Element) {\n  const rect = targetNode.getBoundingClientRect();\n  const mouseY = event.clientY;\n\n  // 上三分之一区域放在前面，下三分之一区域放在后面，中间放在内部\n  const relativeY = mouseY - rect.top;\n  const height = rect.height;\n\n  if (relativeY < height / 3) {\n    return \"before\";\n  } else if (relativeY > (height * 2) / 3) {\n    return \"after\";\n  } else {\n    return \"child\";\n  }\n}\n\n// 更新节点及其子节点的级别样式\nfunction updateNodeLevels(node: Element) {\n  const updateLevel = (element: Element, level: number) => {\n    const nodeDiv = element.querySelector(\".tree-node\")!;\n\n    // 移除所有级别类\n    for (let i = 1; i <= MAX_LEVEL; i++) {\n      nodeDiv.classList.remove(`level-${i}`);\n    }\n\n    // 添加正确的级别类\n    nodeDiv.classList.add(`level-${level}`);\n    nodeDiv.setAttribute(\"level\", level.toString());\n\n    // 递归处理子节点\n    const childList = element.querySelector(\"ul\");\n    if (childList) {\n      Array.from(childList.children).forEach((child) => {\n        updateLevel(child, level + 1);\n      });\n    }\n  };\n\n  // 计算当前节点的级别\n  let level = 1;\n  let parent = node.parentNode as Element;\n\n  while (parent && parent.id !== \"root-list\") {\n    if (parent.tagName === \"UL\") {\n      level++;\n    }\n    parent = parent.parentNode as Element;\n  }\n\n  updateLevel(node, level);\n}\n\nexport function makeNodeEditable(titleElement: Element) {\n  const doc = titleElement.ownerDocument;\n  const parent = titleElement.parentNode! as Element;\n  const treeNode = titleElement.closest(\"div.tree-node\")!;\n  // 获取当前值\n  const currentTitle = titleElement.textContent || \"\";\n  const currentPage = treeNode.getAttribute(\"page\")!;\n\n  // 创建容器\n  const container = doc.createElement(\"div\");\n  container.style.display = \"flex\";\n  container.style.gap = \"5px\";\n\n  // 创建标题输入框\n  const titleInput = doc.createElement(\"input\");\n  titleInput.type = \"text\";\n  titleInput.value = currentTitle.trim();\n  titleInput.placeholder = getString(\"outline-edit-placeholder\");\n\n  // 替换原始元素\n  container.appendChild(titleInput);\n  // container.appendChild(pageInput);\n  parent.replaceChild(container, titleElement);\n\n  // 聚焦到标题输入框\n  titleInput.focus();\n  // 禁用拖拽功能\n  treeNode.setAttribute(\"draggable\", \"false\");\n\n  // 保存逻辑\n  const saveChanges = async () => {\n    const newTitle = titleInput.value.trim();\n\n    // 更新原始元素\n    titleElement.textContent = newTitle || currentTitle;\n    titleElement.setAttribute(\"title\", `${newTitle}, Page: ${currentPage}`);\n    treeNode.setAttribute(\"page\", currentPage);\n\n    // 恢复 DOM 结构\n    parent.replaceChild(titleElement, container);\n    // 恢复拖拽功能\n    treeNode.setAttribute(\"draggable\", \"true\");\n\n    // 保存节点信息\n    await saveOutlineToJSON();\n  };\n\n  // 事件处理\n  const handleKeyDown = (e: KeyboardEvent) => {\n    if (e.key === \"Enter\") {\n      saveChanges();\n      doc.getElementById(\"j-outline-viewer\")!.focus();\n    } else if (e.key === \"Escape\") {\n      parent.replaceChild(titleElement, container);\n    }\n    e.stopPropagation();\n    // 保留焦点\n  };\n\n  const handleBlur = (e: FocusEvent) => {\n    if (!container.contains(e.relatedTarget as Node)) {\n      saveChanges();\n    }\n  };\n\n  // 绑定事件\n  titleInput.addEventListener(\"keydown\", handleKeyDown);\n  container.addEventListener(\"blur\", handleBlur, true);\n}\n\n// 删除选中节点\nexport async function deleteSelectedNode(ev: Event) {\n  const doc = (ev.target as Element).ownerDocument;\n  const selectedNode = doc.querySelector<HTMLElement>(\".node-selected\")!;\n  const rootNode = doc.getElementById(\"root-list\");\n  if (!selectedNode || !rootNode) return;\n\n  const listItem = selectedNode.closest(\"li\")!;\n  const beforeSelectedLi = listItem.previousElementSibling;\n  const parent = listItem.parentNode as HTMLElement;\n\n  // 如果有子节点，则进行提示确认是否删除\n  if (listItem.classList.contains(\"has-children\")) {\n    const confirmDelete = ztoolkit.getGlobal(\"confirm\")(\n      getString(\"outline-delete-confirm\"),\n    );\n    if (!confirmDelete) return;\n  }\n  // 移除节点\n  parent.removeChild(listItem);\n\n  // 如果父列表没有其他子元素，更新其父节点的状态\n  if (\n    parent.children.length === 0 &&\n    parent.tagName === \"UL\" &&\n    parent !== doc.getElementById(\"root-list\")\n  ) {\n    const parentLi = parent.parentNode as HTMLElement;\n    parentLi.removeChild(parent);\n    parentLi.classList.remove(\"has-children\");\n    const expander = parentLi.querySelector(\".expander\")!;\n    expander.textContent = \" \";\n  }\n\n  // 保存节点信息\n  await saveOutlineToJSON();\n\n  if (!rootNode.hasChildNodes()) {\n    ztoolkit.UI.appendElement(\n      {\n        tag: \"div\",\n        namespace: \"html\",\n        classList: [\"empty-outline-prompt\"],\n        properties: {\n          innerHTML: getString(\"outline-empty-prompt\", {\n            args: { icon: ICONS.add },\n          }),\n        },\n      },\n      rootNode,\n    );\n  }\n  if (beforeSelectedLi) {\n    beforeSelectedLi\n      .querySelector(\"div.tree-node\")\n      ?.classList.add(\"node-selected\");\n  } else {\n    parent.parentNode\n      ?.querySelector(\"div.tree-node\")\n      ?.classList.add(\"node-selected\");\n  }\n  doc.getElementById(\"j-outline-viewer\")?.focus();\n}\n\n// 添加新节点。选中节点的子节点还是下一个同级节点\n// 默认设置为添加节点的同级节点\nexport async function addNewNode(ev: Event) {\n  const doc = (ev.target as Element).ownerDocument;\n  const newTitle = \"新书签\";\n  const selectedNode = doc.querySelector(\".node-selected\");\n  const location = getReaderPagePosition();\n\n  // 如果没有选中节点，添加到根\n  if (!selectedNode) {\n    const rootList = doc.getElementById(\"root-list\")!;\n    createTreeNodes(\n      [\n        {\n          level: 1,\n          title: newTitle,\n          page: location.position.pageIndex + 1,\n          x: location.position.rects[0][0],\n          y: location.position.rects[0][1],\n        },\n      ],\n      rootList,\n      doc,\n    );\n    doc.querySelector(\".empty-outline-prompt\")?.classList.add(\"hidden\");\n  } else {\n    // 添加为选中节点的子节点或兄弟节点\n    let targetChildrenList: HTMLElement;\n    let targetLevel: number;\n    const selectedLevel = parseInt(selectedNode.getAttribute(\"level\") || \"1\");\n    if (getPref(\"newNodeAsChild\")) {\n      // 作为子节点\n      const selectedLi = selectedNode.closest(\"li.tree-item\")!;\n      targetLevel = selectedLevel + 1;\n\n      // 检查是否有子列表，如果没有，创建一个\n      targetChildrenList = selectedLi.querySelector(\"ul\")!;\n      if (!targetChildrenList) {\n        targetChildrenList = ztoolkit.UI.createElement(doc, \"ul\", {\n          classList: [\"tree-list\"],\n        });\n        selectedLi.appendChild(targetChildrenList);\n\n        // 添加父节点标记并更新展开图标\n        selectedLi.classList.add(\"has-children\");\n        const expander = selectedLi.querySelector(\".expander\")!;\n        //expander.textContent = \"▼\";\n        expander.innerHTML = ICONS.down;\n      }\n      // 确保父节点展开\n      selectedLi.classList.remove(\"collapsed\");\n    } else {\n      targetLevel = selectedLevel;\n      targetChildrenList = selectedNode.closest(\"ul.tree-list\") as HTMLElement;\n    }\n    createTreeNodes(\n      [\n        {\n          level: targetLevel,\n          title: newTitle,\n          page: location.position.pageIndex + 1,\n          x: location.position.rects[0][0],\n          y: location.position.rects[0][1],\n        },\n      ],\n      targetChildrenList,\n      doc,\n    );\n  }\n  // 保存节点信息\n  await saveOutlineToJSON();\n}\n\nfunction clickToPosition(targetElement: Element) {\n  const reader = Zotero.Reader.getByTabID(\n    ztoolkit.getGlobal(\"Zotero_Tabs\").selectedID,\n  );\n  const treeNode = targetElement.closest(\"div.tree-node\");\n  if (!treeNode) return;\n\n  const page = parseInt(treeNode.getAttribute(\"page\")!);\n  const x = parseInt(treeNode.getAttribute(\"x\")!);\n  const y = parseInt(treeNode.getAttribute(\"y\")!);\n  ztoolkit.log(\"Click to position\", page, x, y);\n  // const location = {\n  //   position: { pageIndex: page - 1, rects: [[x, y, x, y]] },\n  // };\n  // @ts-ignore - not typed\n  // reader.navigate(location);\n  const PDFViewerApplication = (\n    reader._internalReader._primaryView as _ZoteroTypes.Reader.PDFView\n  )._iframeWindow.PDFViewerApplication;\n  const pageView = PDFViewerApplication.pdfViewer!.getPageView(page - 1);\n  // @ts-ignore - Not typed\n  const [scrollX, scrollY] = pageView.viewport.convertToViewportPoint(x, y);\n  (\n    reader._internalReader._primaryView as _ZoteroTypes.Reader.PDFView\n  )._iframeWindow!.PDFViewerApplication.page = page;\n  const container = (\n    reader._internalReader._primaryView as _ZoteroTypes.Reader.PDFView\n  )._iframeWindow!.document.getElementById(\"viewerContainer\")!;\n  ztoolkit.log(`Scroll to ${scrollX}, ${scrollY}`);\n  container.scrollBy(scrollX, scrollY);\n}\n\n// Use worker to add outline to PDF\nexport async function addOutlineToPDFRunner(): Promise<void> {\n  const reader = Zotero.Reader.getByTabID(\n    ztoolkit.getGlobal(\"Zotero_Tabs\").selectedID,\n  );\n  if (!reader) {\n    ztoolkit.log(\"No reader found\");\n    return;\n  }\n  const outlineNodes = await getOutlineFromPDF(reader);\n  if (!outlineNodes) {\n    ztoolkit.log(\"No outline nodes found\");\n    return;\n  }\n  const filePath = reader._item.getFilePath();\n  const worker = new Worker(\n    \"chrome://jasminum/content/scripts/jasminum-worker.js\",\n  );\n  worker.onmessage = (event) => {\n    // @ts-ignore - event.data is not typed\n    const data = event.data;\n    ztoolkit.log(\"data\", data);\n    if (data && data.action === \"addOutlineReturn\") {\n      ztoolkit.log(\"Add outline to PDF return\", data);\n    }\n  };\n\n  return new Promise((resolve, reject) => {\n    ztoolkit.log(filePath, outlineNodes);\n    const jobID = Zotero.Utilities.randomString();\n\n    // 消息处理器\n    const handler = (event: MessageEvent<any>) => {\n      const data = event.data;\n      ztoolkit.log(\"Main handler\", data);\n      // 仅处理匹配 jobID 和 action 的消息\n      if (data?.action !== \"addOutlineReturn\" || data?.jobID !== jobID) return;\n\n      worker.removeEventListener(\"message\", handler as EventListener);\n\n      if (data.status === \"success\") {\n        resolve(data);\n      } else {\n        reject(new Error(data.error || \"Unknown error\"));\n      }\n    };\n\n    worker.addEventListener(\"message\", handler as EventListener);\n    worker.postMessage({ action: \"addOutline\", jobID, filePath, outlineNodes });\n  });\n}\n\n// ========== 书签相关函数 ==========\n\n// 选择书签节点\nfunction selectBookmarkNode(node: Element) {\n  const doc = node.ownerDocument;\n  const selectedNode = doc.querySelector(\".bookmark-selected\");\n  // 取消之前的选择\n  if (selectedNode) {\n    selectedNode.classList.remove(\"bookmark-selected\");\n  }\n  // 设置新选择\n  node.classList.add(\"bookmark-selected\");\n}\n\n// 点击书签跳转到对应位置\nfunction clickToBookmarkPosition(targetElement: Element) {\n  const reader = Zotero.Reader.getByTabID(\n    ztoolkit.getGlobal(\"Zotero_Tabs\").selectedID,\n  );\n  const bookmarkNode = targetElement.closest(\"div.bookmark-node\");\n  if (!bookmarkNode) return;\n\n  const page = parseInt(bookmarkNode.getAttribute(\"page\")!);\n  const x = parseInt(bookmarkNode.getAttribute(\"x\")!);\n  const y = parseInt(bookmarkNode.getAttribute(\"y\")!);\n  ztoolkit.log(\"Click to bookmark position\", page, x, y);\n\n  const PDFViewerApplication = (\n    reader._internalReader._primaryView as _ZoteroTypes.Reader.PDFView\n  )._iframeWindow!.PDFViewerApplication;\n  const pageView = PDFViewerApplication.pdfViewer!.getPageView(page - 1);\n  // @ts-ignore - Not typed\n  const [scrollX, scrollY] = pageView.viewport.convertToViewportPoint(x, y);\n  (\n    reader._internalReader._primaryView as _ZoteroTypes.Reader.PDFView\n  )._iframeWindow!.PDFViewerApplication.page = page;\n  const container = (\n    reader._internalReader._primaryView as _ZoteroTypes.Reader.PDFView\n  )._iframeWindow!.document.getElementById(\"viewerContainer\")!;\n  ztoolkit.log(`Scroll to bookmark ${scrollX}, ${scrollY}`);\n  container.scrollBy(scrollX, scrollY);\n}\n\n// 编辑书签节点\nexport function makeBookmarkNodeEditable(titleElement: Element) {\n  const doc = titleElement.ownerDocument;\n  const parent = titleElement.parentNode! as Element;\n  const bookmarkNode = titleElement.closest(\"div.bookmark-node\")!;\n  // 获取当前值\n  const currentTitle = titleElement.textContent || \"\";\n  const currentPage = bookmarkNode.getAttribute(\"page\")!;\n  const currentColor =\n    bookmarkNode.getAttribute(\"data-color\") || DEFAULT_BOOKMARK_COLORS[0];\n\n  // 创建编辑容器\n  const editContainer = doc.createElement(\"div\");\n  editContainer.className = \"bookmark-edit-container\";\n\n  // 创建标题输入框\n  const titleInput = doc.createElement(\"input\");\n  titleInput.type = \"text\";\n  titleInput.value = currentTitle.trim();\n  titleInput.placeholder = \"书签标题\";\n\n  // 创建颜色选择器容器\n  const colorContainer = doc.createElement(\"div\");\n  colorContainer.className = \"bookmark-color-picker\";\n\n  let selectedColor = currentColor;\n\n  // 创建颜色选项\n  DEFAULT_BOOKMARK_COLORS.forEach((color) => {\n    const colorOption = doc.createElement(\"div\");\n    colorOption.className = \"bookmark-color-option\";\n    if (color === currentColor) {\n      colorOption.classList.add(\"selected\");\n    }\n    colorOption.style.backgroundColor = color;\n\n    colorOption.addEventListener(\"click\", () => {\n      // 更新选中状态\n      colorContainer.querySelectorAll(\"div\").forEach((opt) => {\n        opt.classList.remove(\"selected\");\n      });\n      colorOption.classList.add(\"selected\");\n      selectedColor = color;\n\n      // 实时更新书签的颜色显示\n      (bookmarkNode as HTMLElement).style.borderLeftColor = color;\n      bookmarkNode.setAttribute(\"data-color\", color);\n    });\n\n    colorContainer.appendChild(colorOption);\n  });\n\n  // 创建分隔线\n  const separator = doc.createElement(\"div\");\n  separator.className = \"bookmark-edit-separator\";\n\n  editContainer.appendChild(titleInput);\n  editContainer.appendChild(separator);\n  editContainer.appendChild(colorContainer);\n\n  // 替换原始元素\n  parent.replaceChild(editContainer, titleElement);\n\n  // 聚焦到输入框\n  titleInput.focus();\n  // 禁用拖拽功能\n  bookmarkNode.setAttribute(\"draggable\", \"false\");\n\n  // 保存逻辑\n  const saveChanges = async () => {\n    const newTitle = titleInput.value.trim();\n\n    // 更新原始元素\n    titleElement.textContent = newTitle || currentTitle;\n    titleElement.setAttribute(\"title\", `${newTitle}, Page: ${currentPage}`);\n\n    // 更新颜色\n    bookmarkNode.setAttribute(\"data-color\", selectedColor);\n    (bookmarkNode as HTMLElement).style.borderLeftColor = selectedColor;\n\n    // 恢复 DOM 结构\n    parent.replaceChild(titleElement, editContainer);\n    // 恢复拖拽功能\n    bookmarkNode.setAttribute(\"draggable\", \"true\");\n\n    // 保存书签信息\n    await saveBookmarksToJSON();\n  };\n\n  // 事件处理\n  const handleKeyDown = (e: KeyboardEvent) => {\n    if (e.key === \"Enter\") {\n      saveChanges();\n      doc.getElementById(\"j-bookmark-viewer\")!.focus();\n    } else if (e.key === \"Escape\") {\n      parent.replaceChild(titleElement, editContainer);\n      bookmarkNode.setAttribute(\"draggable\", \"true\");\n    }\n    e.stopPropagation();\n  };\n\n  const handleBlur = (e: FocusEvent) => {\n    if (!editContainer.contains(e.relatedTarget as Node)) {\n      saveChanges();\n    }\n  };\n\n  // 绑定事件\n  titleInput.addEventListener(\"keydown\", handleKeyDown);\n  editContainer.addEventListener(\"blur\", handleBlur, true);\n}\n\n// 添加新书签\nexport async function addNewBookmarkNode(ev: Event) {\n  const doc = (ev.target as Element).ownerDocument;\n  const newBookmark = addNewBookmark();\n  const rootList = doc.getElementById(\"bookmark-root-list\")!;\n\n  // 清除空提示\n  doc.querySelector(\".empty-bookmark-prompt\")?.remove();\n\n  createBookmarkNodes([newBookmark], rootList, doc);\n\n  // 保存书签信息\n  await saveBookmarksToJSON();\n}\n\n// 删除选中的书签\nexport async function deleteSelectedBookmarkNode(ev: Event) {\n  const doc = (ev.target as Element).ownerDocument;\n  const selectedNode = doc.querySelector<HTMLElement>(\".bookmark-selected\")!;\n  const rootNode = doc.getElementById(\"bookmark-root-list\");\n  if (!selectedNode || !rootNode) return;\n\n  const listItem = selectedNode.closest(\"li\")!;\n  const parent = listItem.parentNode as HTMLElement;\n\n  // 移除节点\n  parent.removeChild(listItem);\n\n  // 保存书签信息\n  await saveBookmarksToJSON();\n\n  // 如果没有书签了，显示提示\n  if (!rootNode.hasChildNodes()) {\n    ztoolkit.UI.appendElement(\n      {\n        tag: \"div\",\n        namespace: \"html\",\n        classList: [\"empty-bookmark-prompt\"],\n        properties: { innerHTML: `请点击上方按钮${ICONS.add}创建书签` },\n      },\n      rootNode,\n    );\n  }\n  doc.getElementById(\"j-bookmark-viewer\")?.focus();\n}\n\n// 书签拖拽开始\nexport function handleBookmarkDragStart(e: DragEvent) {\n  ztoolkit.log(\"start to drag bookmark\");\n  const target = e.target as Element;\n  if (!target.classList.contains(\"bookmark-node\")) return;\n\n  const draggedNode = target.closest(\"li\") as HTMLElement;\n  e.dataTransfer!.setData(\"text/plain\", draggedNode.innerText);\n  e.dataTransfer!.effectAllowed = \"move\";\n\n  // 为拖拽中的元素添加样式\n  setTimeout(() => {\n    draggedNode.classList.add(\"dragging\");\n  }, 0);\n}\n\n// 书签拖拽经过目标元素\nexport function handleBookmarkDragOver(e: DragEvent) {\n  e.preventDefault();\n  e.dataTransfer!.dropEffect = \"move\";\n  const target = e.target as HTMLElement;\n  const doc = target.ownerDocument;\n  const draggedNode = doc.querySelector(\".dragging\");\n  if (!draggedNode) return;\n\n  // 找到最近的书签节点元素\n  const targetNode = target.closest(\".bookmark-node\");\n  if (!targetNode) {\n    hideBookmarkDropIndicator(doc);\n    return;\n  }\n\n  // 不能拖拽到自己\n  const targetLi = targetNode.closest(\"li\") as Element;\n  if (draggedNode === targetLi) {\n    hideBookmarkDropIndicator(doc);\n    return;\n  }\n\n  // 计算拖拽位置（上方或下方）\n  const rect = targetNode.getBoundingClientRect();\n  const mouseY = e.clientY;\n  const relativeY = mouseY - rect.top;\n  const height = rect.height;\n\n  let dropPosition;\n  if (relativeY < height * 0.5) {\n    dropPosition = \"before\";\n  } else {\n    dropPosition = \"after\";\n  }\n\n  // 更新指示器\n  updateBookmarkDropIndicator(targetNode, dropPosition);\n\n  // 添加可放置样式\n  doc.querySelectorAll(\".bookmark-dragover\").forEach((el) => {\n    el.classList.remove(\"bookmark-dragover\");\n  });\n  targetNode.classList.add(\"bookmark-dragover\");\n}\n\n// 更新书签拖拽指示器\nfunction updateBookmarkDropIndicator(targetNode: Element, position: string) {\n  const rect = targetNode.getBoundingClientRect();\n  const doc = targetNode.ownerDocument;\n  const dropIndicator = doc.querySelector(\n    \".bookmark-drop-indicator\",\n  ) as HTMLElement;\n\n  dropIndicator.classList.add(\"visible\");\n\n  if (position === \"before\") {\n    dropIndicator.style.left = `${rect.left}px`;\n    dropIndicator.style.top = `${rect.top - 2}px`;\n    dropIndicator.style.width = `${rect.width}px`;\n  } else {\n    // after position\n    dropIndicator.style.left = `${rect.left}px`;\n    dropIndicator.style.top = `${rect.bottom}px`;\n    dropIndicator.style.width = `${rect.width}px`;\n  }\n}\n\n// 隐藏书签拖拽指示器\nfunction hideBookmarkDropIndicator(doc: Document) {\n  const dropIndicator = doc.querySelector(\".bookmark-drop-indicator\")!;\n  dropIndicator.classList.remove(\"visible\");\n}\n\n// 书签拖拽离开目标元素\nexport function handleBookmarkDragLeave(e: DragEvent) {\n  const doc = (e.target as Element).ownerDocument;\n  if (\n    !e.relatedTarget ||\n    !(e.relatedTarget as Element).closest(\"#j-bookmark-viewer\")\n  ) {\n    hideBookmarkDropIndicator(doc);\n  }\n\n  const targetNode = (e.target as HTMLElement).closest(\".bookmark-node\");\n  if (targetNode) {\n    targetNode.classList.remove(\"bookmark-dragover\");\n  }\n}\n\n// 处理书签放置\nexport async function handleBookmarkDrop(e: DragEvent) {\n  e.preventDefault();\n  const target = e.target as HTMLElement;\n  const doc = target.ownerDocument;\n  const draggedNode = doc.querySelector(\".dragging\");\n\n  // 隐藏指示器\n  hideBookmarkDropIndicator(doc);\n\n  if (!draggedNode) return;\n\n  // 获取目标节点\n  const targetBookmarkNode = target.closest(\".bookmark-node\");\n  if (!targetBookmarkNode) return;\n\n  // 移除可放置样式\n  doc.querySelectorAll(\".bookmark-dragover\").forEach((el) => {\n    el.classList.remove(\"bookmark-dragover\");\n  });\n\n  // 获取目标列表项\n  const targetLi = targetBookmarkNode.closest(\"li\")!;\n\n  // 不能将节点拖到自己上\n  if (draggedNode === targetLi) {\n    return;\n  }\n\n  // 移除拖拽的节点\n  const oldParent = draggedNode.parentNode! as HTMLElement;\n  oldParent.removeChild(draggedNode);\n\n  // 判断放置位置\n  const rect = targetBookmarkNode.getBoundingClientRect();\n  const mouseY = e.clientY;\n  const relativeY = mouseY - rect.top;\n  const height = rect.height;\n\n  const targetParent = targetLi.parentNode!;\n\n  if (relativeY < height * 0.5) {\n    // 放在前面\n    targetParent.insertBefore(draggedNode, targetLi);\n  } else {\n    // 放在后面\n    targetParent.insertBefore(draggedNode, targetLi.nextSibling);\n  }\n\n  // 保存书签信息\n  await saveBookmarksToJSON();\n}\n\n// 书签拖拽结束\nexport function handleBookmarkDragEnd(e: DragEvent) {\n  const doc = (e.target as HTMLElement).ownerDocument;\n  const draggedNode = doc.querySelector(\".dragging\");\n  if (!draggedNode) return;\n\n  draggedNode.classList.remove(\"dragging\");\n\n  // 隐藏指示器\n  hideBookmarkDropIndicator(doc);\n\n  // 清除所有dragover样式\n  doc.querySelectorAll(\".bookmark-dragover\").forEach((el) => {\n    el.classList.remove(\"bookmark-dragover\");\n  });\n}\n\n// ========== 字体大小调整函数 ==========\n\nconst MIN_FONT_SIZE = 8;\nconst MAX_FONT_SIZE = 20;\n\n// Increase font size for both outline and bookmark\nasync function handleFontSizeIncrease(ev: Event) {\n  const doc = (ev.target as Element).ownerDocument;\n  const reader = Zotero.Reader.getByTabID(\n    ztoolkit.getGlobal(\"Zotero_Tabs\").selectedID,\n  );\n  if (!reader) return;\n\n  // Get current baseFontSize for outline\n  const outlineInfo = await loadOutlineInfoFromJSON(reader._item);\n  const currentOutlineSize =\n    outlineInfo?.baseFontSize ?? DEFAULT_BASE_FONT_SIZE;\n\n  // Get current baseFontSize for bookmark\n  const bookmarkInfo = await loadBookmarkInfoFromJSON(reader._item);\n  const currentBookmarkSize =\n    bookmarkInfo?.baseFontSize ?? DEFAULT_BOOKMARK_FONT_SIZE;\n\n  // Increase by 1, max 20\n  const newOutlineSize = Math.min(currentOutlineSize + 1, MAX_FONT_SIZE);\n  const newBookmarkSize = Math.min(currentBookmarkSize + 1, MAX_FONT_SIZE);\n\n  if (\n    newOutlineSize !== currentOutlineSize ||\n    newBookmarkSize !== currentBookmarkSize\n  ) {\n    // Update CSS\n    updateOutlineFontSize(doc, newOutlineSize);\n    updateBookmarkFontSize(doc, newBookmarkSize);\n\n    // Save to JSON\n    await saveOutlineToJSON(reader._item, undefined, newOutlineSize);\n    await saveBookmarksToJSON(reader._item, undefined, newBookmarkSize);\n\n    ztoolkit.log(\n      `Font size increased: outline=${newOutlineSize}px, bookmark=${newBookmarkSize}px`,\n    );\n  }\n}\n\n// Decrease font size for both outline and bookmark\nasync function handleFontSizeDecrease(ev: Event) {\n  const doc = (ev.target as Element).ownerDocument;\n  const reader = Zotero.Reader.getByTabID(\n    ztoolkit.getGlobal(\"Zotero_Tabs\").selectedID,\n  );\n  if (!reader) return;\n\n  // Get current baseFontSize for outline\n  const outlineInfo = await loadOutlineInfoFromJSON(reader._item);\n  const currentOutlineSize =\n    outlineInfo?.baseFontSize ?? DEFAULT_BASE_FONT_SIZE;\n\n  // Get current baseFontSize for bookmark\n  const bookmarkInfo = await loadBookmarkInfoFromJSON(reader._item);\n  const currentBookmarkSize =\n    bookmarkInfo?.baseFontSize ?? DEFAULT_BOOKMARK_FONT_SIZE;\n\n  // Decrease by 1, min 8\n  const newOutlineSize = Math.max(currentOutlineSize - 1, MIN_FONT_SIZE);\n  const newBookmarkSize = Math.max(currentBookmarkSize - 1, MIN_FONT_SIZE);\n\n  if (\n    newOutlineSize !== currentOutlineSize ||\n    newBookmarkSize !== currentBookmarkSize\n  ) {\n    // Update CSS\n    updateOutlineFontSize(doc, newOutlineSize);\n    updateBookmarkFontSize(doc, newBookmarkSize);\n\n    // Save to JSON\n    await saveOutlineToJSON(reader._item, undefined, newOutlineSize);\n    await saveBookmarksToJSON(reader._item, undefined, newBookmarkSize);\n\n    ztoolkit.log(\n      `Font size decreased: outline=${newOutlineSize}px, bookmark=${newBookmarkSize}px`,\n    );\n  }\n}\n"
  },
  {
    "path": "src/modules/outline/index.ts",
    "content": "import { wait } from \"zotero-plugin-toolkit\";\nimport { getString } from \"../../utils/locale\";\nimport { initEventListener } from \"./events\";\nimport {\n  addButton,\n  createTreeNodes,\n  getOutlineFromPDF,\n  registerOutlineCSS,\n  registerThemeChange,\n  updateOutlineFontSize,\n  loadOutlineInfoFromJSON,\n  DEFAULT_BASE_FONT_SIZE,\n} from \"./outline\";\nimport {\n  addBookmarkButton,\n  createBookmarkNodes,\n  loadBookmarksFromJSON,\n  updateBookmarkFontSize,\n  loadBookmarkInfoFromJSON,\n  DEFAULT_BOOKMARK_FONT_SIZE,\n} from \"./bookmark\";\nimport { ICONS } from \"./style\";\nimport { getPref } from \"../../utils/prefs\";\n\nexport function renderTree(\n  reader: _ZoteroTypes.ReaderInstance,\n  doc: Document,\n  data: OutlineNode[] | null,\n) {\n  const dropIndicator = ztoolkit.UI.createElement(doc, \"div\", {\n    classList: [\"drop-indicator\"],\n  });\n  const toolbar = ztoolkit.UI.createElement(doc, \"div\", {\n    namespace: \"html\",\n    id: \"j-outline-toolbar\",\n    classList: [\"j-hidden\"], // 默认隐藏\n    children: [\n      {\n        tag: \"button\",\n        id: \"j-outline-expand-all\",\n        classList: [\"j-outline-toolbar-button\", \"toolbar-button\"],\n        properties: { innerHTML: ICONS.expand },\n        attributes: { title: getString(\"outline-expand-all\") },\n      },\n      {\n        tag: \"button\",\n        id: \"j-outline-collapse-all\",\n        classList: [\"j-outline-toolbar-button\", \"toolbar-button\"],\n        properties: { innerHTML: ICONS.collapse },\n        attributes: { title: getString(\"outline-collapse-all\") },\n      },\n      {\n        tag: \"button\",\n        id: \"j-outline-add-node\",\n        classList: [\"j-outline-toolbar-button\", \"toolbar-button\"],\n        properties: { innerHTML: ICONS.add },\n        attributes: { title: getString(\"outline-add\") },\n      },\n      {\n        tag: \"button\",\n        id: \"j-outline-delete-node\",\n        classList: [\"j-outline-toolbar-button\", \"toolbar-button\"],\n        properties: { innerHTML: ICONS.del },\n        attributes: { title: getString(\"outline-delete\") },\n      },\n      {\n        tag: \"button\",\n        id: \"j-outline-save-pdf\",\n        classList: [\"j-outline-toolbar-button\", \"toolbar-button\"],\n        properties: { innerHTML: ICONS.save },\n        attributes: { title: getString(\"outline-save-to-pdf\") },\n      },\n    ],\n  });\n  const treeContainer = ztoolkit.UI.createElement(doc, \"div\", {\n    id: \"jasminum-outline\",\n    classList: [\"hidden\"], // 默认隐藏\n    namespace: \"html\",\n    children: [\n      {\n        tag: \"div\",\n        namespace: \"html\",\n        id: \"j-outline-viewer\",\n        classList: [\"outline-view\"],\n        attributes: {\n          tabindex: \"-1\",\n          \"data-tabstop\": \"1\",\n          role: \"tabpanel\",\n          \"aria-labelledby\": \"j-outline-button\",\n        },\n        children: [\n          {\n            tag: \"ul\",\n            namespace: \"html\",\n            id: \"root-list\",\n            classList: [\"tree-list\"],\n          },\n        ],\n      },\n      {\n        tag: \"div\",\n        namespace: \"html\",\n        classList: [\"jasminum-sidebar-bottom\"],\n        children: [\n          {\n            tag: \"button\",\n            namespace: \"html\",\n            id: \"j-outline-zoom-in\",\n            classList: [\"j-outline-toolbar-button\", \"toolbar-button\"],\n            properties: { innerHTML: ICONS.plus },\n            attributes: { title: \"字体变大\" },\n            styles: { paddingBottom: \"7px\" },\n          },\n          {\n            tag: \"button\",\n            namespace: \"html\",\n            id: \"j-outline-zoom-out\",\n            classList: [\"j-outline-toolbar-button\", \"toolbar-button\"],\n            properties: { innerHTML: ICONS.minus },\n            attributes: { title: \"字体变小\" },\n            styles: { paddingBottom: \"7px\" },\n          },\n        ],\n      },\n    ],\n  });\n  // 隐藏 Zotero 大纲按钮\n  if (getPref(\"disableZoteroOutline\")) {\n    doc.getElementById(\"viewOutline\")!.style.display = \"none\";\n  }\n  // 添加工具栏\n  doc\n    .getElementById(\"sidebarContainer\")!\n    .insertBefore(toolbar, doc.getElementById(\"sidebarContent\")!);\n  treeContainer.appendChild(dropIndicator);\n  createTreeNodes(data, treeContainer.querySelector(\"#root-list\")!, doc);\n  doc.querySelector(\"#sidebarContent\")?.appendChild(treeContainer);\n\n  return treeContainer;\n}\n\nexport function renderBookmarkTree(\n  reader: _ZoteroTypes.ReaderInstance,\n  doc: Document,\n  data: BookmarkNode[] | null,\n) {\n  const dropIndicator = ztoolkit.UI.createElement(doc, \"div\", {\n    classList: [\"bookmark-drop-indicator\"],\n  });\n  const toolbar = ztoolkit.UI.createElement(doc, \"div\", {\n    namespace: \"html\",\n    id: \"j-bookmark-toolbar\",\n    classList: [\"j-hidden\"], // 默认隐藏\n    children: [\n      {\n        tag: \"button\",\n        id: \"j-bookmark-add\",\n        classList: [\"j-bookmark-toolbar-button\", \"toolbar-button\"],\n        properties: { innerHTML: ICONS.add },\n        attributes: { title: getString(\"bookmark-add\") },\n      },\n      {\n        tag: \"button\",\n        id: \"j-bookmark-delete\",\n        classList: [\"j-bookmark-toolbar-button\", \"toolbar-button\"],\n        properties: { innerHTML: ICONS.del },\n        attributes: { title: getString(\"bookmark-delete\") },\n      },\n    ],\n  });\n  const bookmarkContainer = ztoolkit.UI.createElement(doc, \"div\", {\n    id: \"jasminum-bookmarks\",\n    classList: [\"hidden\"], // 默认隐藏\n    namespace: \"html\",\n    children: [\n      {\n        tag: \"div\",\n        namespace: \"html\",\n        id: \"j-bookmark-viewer\",\n        classList: [\"bookmark-view\"],\n        attributes: {\n          tabindex: \"-1\",\n          \"data-tabstop\": \"1\",\n          role: \"tabpanel\",\n          \"aria-labelledby\": \"j-bookmark-button\",\n        },\n        children: [\n          {\n            tag: \"ul\",\n            namespace: \"html\",\n            id: \"bookmark-root-list\",\n            classList: [\"bookmark-list\"],\n          },\n        ],\n      },\n    ],\n  });\n\n  // 添加工具栏\n  doc\n    .getElementById(\"sidebarContainer\")!\n    .insertBefore(toolbar, doc.getElementById(\"sidebarContent\")!);\n  bookmarkContainer.appendChild(dropIndicator);\n  createBookmarkNodes(\n    data,\n    bookmarkContainer.querySelector(\"#bookmark-root-list\")!,\n    doc,\n  );\n  doc.querySelector(\"#sidebarContent\")?.appendChild(bookmarkContainer);\n\n  return bookmarkContainer;\n}\n\nexport async function addOutlineToReader(reader: _ZoteroTypes.ReaderInstance) {\n  const doc = reader._iframeWindow!.document;\n  if (doc.querySelector(\"#j-outline-button\")) {\n    ztoolkit.log(\"Outline is already added, skip.\");\n    return;\n  }\n  // 等待元素加载\n  await wait.waitUtilAsync(\n    () => {\n      return doc.querySelector(\"#sidebarContainer div.start\") ? true : false;\n    },\n    5, // 减少图标出现延迟感\n    5000,\n  );\n  ztoolkit.log(\"Sidebar container is ready.\");\n  addButton(doc);\n  addBookmarkButton(doc); // 同时添加书签按钮\n\n  const joutline = await getOutlineFromPDF(reader);\n  if (!joutline) {\n    ztoolkit.log(\"No outline to add.\");\n  }\n  ztoolkit.log(\"++joutline\", joutline);\n\n  const bookmarks = await loadBookmarksFromJSON(reader._item);\n  ztoolkit.log(\"++bookmarks\", bookmarks);\n\n  // Load baseFontSize from JSON for outline\n  const outlineInfo = await loadOutlineInfoFromJSON(reader._item);\n  const outlineBaseFontSize =\n    outlineInfo?.baseFontSize ?? DEFAULT_BASE_FONT_SIZE;\n\n  // Load baseFontSize from JSON for bookmark\n  const bookmarkInfo = await loadBookmarkInfoFromJSON(reader._item);\n  const bookmarkBaseFontSize =\n    bookmarkInfo?.baseFontSize ?? DEFAULT_BOOKMARK_FONT_SIZE;\n\n  registerOutlineCSS(doc);\n  registerThemeChange(reader._iframeWindow!);\n\n  // Apply dynamic font size for both outline and bookmark\n  updateOutlineFontSize(doc, outlineBaseFontSize);\n  updateBookmarkFontSize(doc, bookmarkBaseFontSize);\n\n  renderTree(reader, doc, joutline);\n  renderBookmarkTree(reader, doc, bookmarks);\n  initEventListener(reader, doc);\n}\n\nexport async function registerOutline(tabID: string) {\n  if (!tabID) {\n    ztoolkit.log(`Tab ID is not valid. %{tabID}`);\n    return;\n  }\n  await Zotero.Reader.init();\n  const reader = Zotero.Reader.getByTabID(tabID as string);\n  try {\n    await reader._initPromise;\n    ztoolkit.log(\"Init \" + reader._isReaderInitialized);\n    // Only pdf\n    if (reader._item.attachmentContentType != \"application/pdf\") {\n      ztoolkit.log(\"Only support PDF reader.\");\n      return;\n    }\n    // This should add a waiting process.\n    // @ts-ignore - not typed\n    const doc = reader._iframeWindow?.document;\n    // ztoolkit.log(\"registerOutline\", new Date().toISOString());\n    await wait.waitUtilAsync(\n      () => {\n        return doc && doc.getElementById(\"sidebarToggle\") ? true : false;\n      },\n      5,\n      5000,\n    );\n    // ztoolkit.log(\"registerOutline\", new Date().toISOString());\n    ztoolkit.log(\"Sidebar toggle button is ready.\");\n    // Sidebar is already opened, add outline.\n    if (doc && doc.getElementById(\"sidebarContainer\")) {\n      addOutlineToReader(reader);\n    }\n    // Click toggle button to open sidebar.\n    doc\n      ?.getElementById(\"sidebarToggle\")\n      ?.addEventListener(\"click\", (ev: Event) => {\n        ztoolkit.log(\"outline is added by toggle click\");\n        addOutlineToReader(reader);\n      });\n  } catch (e) {\n    Zotero.debug(\n      \"********************* outline add error *********************\",\n    );\n    ztoolkit.log(\"Error in registerOutline\", e);\n    ztoolkit.log(`tabID: ${tabID}`);\n    ztoolkit.log(`reader: ${reader}`);\n  }\n}\n"
  },
  {
    "path": "src/modules/outline/outline.ts",
    "content": "import { wait } from \"zotero-plugin-toolkit\";\nimport { version } from \"../../../package.json\";\nimport { getString } from \"../../utils/locale\";\nimport { outline_css, ICONS } from \"./style\";\n\n// 2 : Add base font size = 12\nexport const OUTLINE_SCHEMA = 2;\nexport const DEFAULT_BASE_FONT_SIZE = 12; // Default base font size for level-1\n\n// Register custom CSS for Jasminum outline\nexport function registerOutlineCSS(doc: Document) {\n  ztoolkit.log(\"** Register css\");\n  ztoolkit.UI.appendElement(\n    {\n      tag: \"style\",\n      namespace: \"html\",\n      attributes: { type: \"text/css\" },\n      properties: {\n        textContent: outline_css,\n      },\n    },\n    doc.querySelector(\"head\")!,\n  );\n}\n\n// Update font size dynamically based on baseFontSize\nexport function updateOutlineFontSize(doc: Document, baseFontSize: number) {\n  const styleId = \"jasminum-dynamic-font-size\";\n  let styleElement = doc.getElementById(styleId) as HTMLStyleElement;\n\n  if (!styleElement) {\n    styleElement = doc.createElement(\"style\");\n    styleElement.id = styleId;\n    styleElement.type = \"text/css\";\n    doc.querySelector(\"head\")!.appendChild(styleElement);\n  }\n\n  // Calculate font sizes: level-1 = base, level-2 = base-1, level-3+ = base-2\n  const level1Size = baseFontSize;\n  const level2Size = baseFontSize - 1;\n  const level3PlusSize = baseFontSize - 2;\n\n  const dynamicCSS = `\n    .level-1 { font-size: ${level1Size}px !important; }\n    .level-2 { font-size: ${level2Size}px !important; }\n    .level-3, .level-4, .level-5, .level-6, .level-7 {\n      font-size: ${level3PlusSize}px !important;\n    }\n  `;\n\n  styleElement.textContent = dynamicCSS;\n  ztoolkit.log(`Updated font size: base=${baseFontSize}px`);\n}\n\n// Register for theme update\nexport function registerThemeChange(win: Window) {\n  win\n    ?.matchMedia(\"(prefers-color-scheme: dark)\")!\n    .addEventListener(\"change\", (e: MediaQueryListEvent) => {\n      if (e.matches) {\n        win.document.documentElement.setAttribute(\"data-theme\", \"dark\");\n      } else {\n        win.document.documentElement.setAttribute(\"data-theme\", \"light\");\n      }\n    });\n\n  // Init theme for outline tree.\n  // 窗口启动时为黑暗主题，将书签主题设置为黑暗模式\n  if (win.matchMedia(\"(prefers-color-scheme: dark)\")!.matches === true) {\n    win.document.documentElement.setAttribute(\"data-theme\", \"dark\");\n  }\n}\n\n// Add outline button and outline tree.\nexport function addButton(doc: Document) {\n  if (doc.querySelector(\"#sidebarContainer div.start\") === null) {\n    ztoolkit.log(\"Sidebar toolbar button is missing.\");\n  }\n  ztoolkit.UI.appendElement(\n    {\n      tag: \"button\",\n      namespace: \"html\",\n      id: \"j-outline-button\",\n      classList: [\"toolbar-button\"],\n      properties: { innerHTML: ICONS.outline },\n      attributes: {\n        title: getString(\"outline\"),\n        tabindex: \"-1\",\n        role: \"tab\",\n        \"aria-selected\": \"false\",\n        \"aria-controls\": \"j-outline-viewer\",\n      },\n      // listeners: [\n      //   {\n      //     type: \"click\",\n      //     listener: (e) => {\n      //       ztoolkit.log(\"Button.click\");\n      //       ztoolkit.log(e);\n      //       const d = (e.target! as HTMLButtonElement).ownerDocument;\n      //       const viewer = d.getElementById(\"j-outline-viewer\")?.parentElement;\n      //       // 显示工具栏\n      //       d\n      //         .getElementById(\"j-outline-toolbar\")\n      //         ?.classList.toggle(\"j-outline-hidden\", false);\n      //       if (!viewer?.classList.contains(\"hidden\")) {\n      //         ztoolkit.log(\"Already display\");\n      //       } else {\n      //         // 按钮的激活状态\n      //         d\n      //           .getElementById(\"viewThumbnail\")\n      //           ?.classList.toggle(\"active\", false);\n      //         d\n      //           .getElementById(\"viewOutline\")\n      //           ?.classList.toggle(\"active\", false);\n      //         d\n      //           .getElementById(\"viewAnnotations\")\n      //           ?.classList.toggle(\"active\", false);\n      //         d\n      //           .getElementById(\"j-outline-button\")\n      //           ?.classList.toggle(\"active\", true);\n      //         // 书签内容显示\n      //         d\n      //           .getElementById(\"thumbnailsView\")\n      //           ?.parentElement?.classList.toggle(\"hidden\", true);\n      //         d\n      //           .getElementById(\"annotationsView\")\n      //           ?.classList.toggle(\"hidden\", true);\n      //         d\n      //           .getElementById(\"outlineView\")\n      //           ?.parentElement?.classList.toggle(\"hidden\", true);\n      //         viewer?.classList.toggle(\"hidden\", false);\n\n      //         ztoolkit.log(\"Display jasminum outline.\");\n      //       }\n      //     },\n      //   },\n      // ],\n    },\n    doc.querySelector(\"#sidebarContainer div.start\")!,\n  );\n}\n\n// 有 JSON 文件优先读取JSON文件\n// 然后再获取PDF自带书签\nexport async function getOutlineFromPDF(\n  reader: _ZoteroTypes.ReaderInstance,\n): Promise<OutlineNode[] | null> {\n  const item = reader._item;\n  // 优先从JSON缓存中读取书签信息\n  const outlineJson = await loadOutlineFromJSON(item);\n  if (outlineJson) return outlineJson;\n  // 如果上面没有返回Outline信息，重新读取\n  await wait.waitUtilAsync(\n    () => {\n      return (reader._primaryView as _ZoteroTypes.Reader.PDFView)\n        ._iframeWindow &&\n        (reader._primaryView as _ZoteroTypes.Reader.PDFView)._iframeWindow!\n          .PDFViewerApplication.pdfDocument\n        ? true\n        : false;\n    },\n    200,\n    5000,\n  );\n  ztoolkit.log(\"PDFViewerApplication is ready\");\n  const PDFViewerApplication = (\n    reader._primaryView as _ZoteroTypes.Reader.PDFView\n  )._iframeWindow!.PDFViewerApplication;\n  await PDFViewerApplication.init;\n  const pdfDocument = PDFViewerApplication.pdfDocument;\n  if (!pdfDocument) {\n    ztoolkit.log(\"No pdfDocument\");\n    return null;\n  }\n  // @ts-ignore - Not typed\n  const originOutline: PdfOutlineNode[] = await pdfDocument.getOutline2();\n\n  if (originOutline.length == 0) return null;\n  ztoolkit.log(originOutline);\n  async function convert(\n    node: PdfOutlineNode,\n    level = 0,\n  ): Promise<OutlineNode> {\n    level += 1;\n    const title = node.title;\n    // Default position\n    const outlineNode: OutlineNode = {\n      level,\n      title,\n      page: 1,\n      x: 100,\n      y: 100,\n      children: [],\n    };\n    // Some pdf missing dest, position instead.\n    if (node.location && \"dest\" in node.location) {\n      // @ts-ignore - Not typed\n      const page = await pdfDocument.getPageIndex(node.location.dest);\n      outlineNode.page = page;\n    } else if (node.location && \"position\" in node.location) {\n      outlineNode.page = node.location.position.pageIndex + 1;\n      outlineNode.x = node.location.position.rects[0][0];\n      outlineNode.y = node.location.position.rects[0][1];\n    }\n\n    if (node.items.length > 0) {\n      outlineNode.children = await Promise.all(\n        node.items.map((n) => convert(n, level)),\n      );\n    }\n    return outlineNode;\n  }\n  const outline = await Promise.all(\n    originOutline.map((node) => convert(node, 0)),\n  );\n  await saveOutlineToJSON(item, outline);\n  return outline;\n}\n\nexport function getOutlineFromPage(): OutlineNode[] {\n  function loop(ul: Element): OutlineNode[] {\n    const lis = Array.from(\n      ul.querySelectorAll(\":scope > li.tree-item\"),\n    )! as Element[];\n    return lis.map((li) => {\n      const titleSpan = li.querySelector(\"span.node-title\")!;\n      const nodeDiv = li.querySelector(\"div.tree-node\")!;\n      return {\n        level: parseInt(nodeDiv.getAttribute(\"level\")!),\n        title: titleSpan.textContent!,\n        page: parseInt(nodeDiv.getAttribute(\"page\")!),\n        x: parseFloat(nodeDiv.getAttribute(\"x\")!),\n        y: parseFloat(nodeDiv.getAttribute(\"y\")!),\n        children: li.classList.contains(\"has-children\")\n          ? loop(li.querySelector(\"ul\")!)\n          : [],\n        collapsed: li.classList.contains(\"collapsed\"),\n      };\n    });\n  }\n  const reader = Zotero.Reader.getByTabID(\n    ztoolkit.getGlobal(\"Zotero_Tabs\").selectedID,\n  );\n  const rootUL = reader._iframeWindow!.document.querySelector(\"#root-list \");\n  if (!rootUL) return [];\n  return loop(rootUL);\n}\n\n// 注意SCHEMA\n// 注意打开PDF时，默认打开书签\nexport async function saveOutlineToJSON(\n  item?: Zotero.Item,\n  outline?: OutlineNode[],\n  baseFontSize?: number,\n) {\n  if (!outline) {\n    outline = getOutlineFromPage();\n  }\n  if (!item) {\n    const reader = Zotero.Reader.getByTabID(\n      ztoolkit.getGlobal(\"Zotero_Tabs\").selectedID,\n    );\n    item = reader._item;\n  }\n  // Get current baseFontSize if not provided\n  if (baseFontSize === undefined) {\n    const currentInfo = await loadOutlineInfoFromJSON(item);\n    baseFontSize = currentInfo?.baseFontSize ?? DEFAULT_BASE_FONT_SIZE;\n  }\n  const outlineInfo: OutlineInfo = {\n    info: {\n      itemID: item.id,\n      schema: OUTLINE_SCHEMA,\n      jasminumVersion: version,\n      baseFontSize: baseFontSize,\n    },\n    outline: outline,\n  };\n  const outlineStr = JSON.stringify(outlineInfo);\n  const outlinePath = PathUtils.join(\n    Zotero.DataDirectory.dir,\n    \"storage\",\n    item.key,\n    \"jasminum-outline.json\",\n  );\n  await Zotero.File.putContentsAsync(outlinePath, outlineStr);\n  ztoolkit.log(\"Save outline to JSON\");\n}\n\nfunction migrateOutlineInfo(\n  raw: any,\n  fromSchema: number,\n): { outline: OutlineNode[]; baseFontSize: number } {\n  let outline: OutlineNode[] = raw.outline ?? [];\n  let baseFontSize = DEFAULT_BASE_FONT_SIZE;\n\n  // v1 → v2: add baseFontSize\n  if (fromSchema < 2) {\n    baseFontSize = raw.info?.baseFontSize ?? DEFAULT_BASE_FONT_SIZE;\n  }\n\n  // Future v2 → v3 migrations go here\n\n  return { outline, baseFontSize };\n}\n\n// 加载时要考虑JSON文件的版本信息，如果版本低，要重新从原文件加载信息\nexport async function loadOutlineInfoFromJSON(\n  item: Zotero.Item,\n): Promise<{ outline: OutlineNode[]; baseFontSize: number } | null> {\n  const outlinePath = PathUtils.join(\n    Zotero.DataDirectory.dir,\n    \"storage\",\n    item.key,\n    \"jasminum-outline.json\",\n  );\n  const isFileExist = await IOUtils.exists(outlinePath);\n  if (!isFileExist) {\n    ztoolkit.log(`Outline json is missing: ${outlinePath}`);\n    return null;\n  } else {\n    const content = (await Zotero.File.getContentsAsync(outlinePath)) as string;\n    const tmp = JSON.parse(content);\n    const fileSchema = tmp.info?.schema ?? 1;\n    if (fileSchema < OUTLINE_SCHEMA) {\n      // Migrate old outline data instead of discarding\n      const migrated = migrateOutlineInfo(tmp, fileSchema);\n      await saveOutlineToJSON(item, migrated.outline, migrated.baseFontSize);\n      return migrated;\n    } else {\n      const outlineInfo = JSON.parse(content) as OutlineInfo;\n      return {\n        outline: outlineInfo.outline,\n        baseFontSize: outlineInfo.info.baseFontSize ?? DEFAULT_BASE_FONT_SIZE,\n      };\n    }\n  }\n}\n\nexport async function loadOutlineFromJSON(\n  item: Zotero.Item,\n): Promise<OutlineNode[] | null> {\n  const info = await loadOutlineInfoFromJSON(item);\n  return info?.outline ?? null;\n}\n\nexport function createTreeNodes(\n  nodes: OutlineNode[] | null,\n  parentElement: HTMLElement,\n  doc: Document,\n) {\n  if (nodes === null || nodes.length == 0) {\n    ztoolkit.UI.appendElement(\n      {\n        tag: \"div\",\n        namespace: \"html\",\n        classList: [\"empty-outline-prompt\"],\n        properties: { innerHTML: `请点击上方按钮${ICONS.add}创建书签` },\n      },\n      parentElement,\n    );\n  } else {\n    nodes.forEach((node) => {\n      const li = ztoolkit.UI.createElement(doc, \"li\", {\n        namespace: \"html\",\n        classList:\n          node.children && node.children.length > 0\n            ? [\"tree-item\", \"has-children\"]\n            : [\"tree-item\"],\n        children: [\n          {\n            tag: \"div\",\n            namespace: \"html\",\n            classList: [\"tree-node\", `level-${node.level}`],\n            attributes: {\n              draggable: \"true\",\n              level: node.level,\n              x: node.x,\n              y: node.y,\n              page: node.page,\n            },\n            children: [\n              {\n                tag: \"span\",\n                namespace: \"html\",\n                classList: [\"expander\"],\n                properties: {\n                  innerHTML:\n                    node.children && node.children.length > 0\n                      ? node.collapsed === false\n                        ? ICONS.down\n                        : ICONS.right\n                      : \" \",\n                },\n              },\n              {\n                tag: \"div\",\n                namespace: \"html\",\n                classList: [\"node-content\"],\n                children: [\n                  {\n                    tag: \"span\",\n                    namespace: \"html\",\n                    classList: [\"node-title\"],\n                    properties: { textContent: node.title },\n                    attributes: {\n                      title: `${node.title}, Page: ${node.page}`,\n                    },\n                  },\n                ],\n              },\n            ],\n          },\n        ],\n      });\n\n      // Collapsed node\n      if (node.collapsed) {\n        li.classList.add(\"collapsed\");\n      }\n\n      // Add children node\n      if (node.children && node.children.length > 0) {\n        const ul = ztoolkit.UI.createElement(doc, \"ul\", {\n          namespace: \"html\",\n          classList: [\"tree-list\"],\n        });\n        createTreeNodes(node.children, ul, doc);\n        li.appendChild(ul);\n      }\n      // Now append the node to the parentElement.\n      parentElement.appendChild(li);\n      return li;\n    });\n  }\n}\n"
  },
  {
    "path": "src/modules/outline/style.ts",
    "content": "export const ICONS = {\n  outline: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 7.8q-.3-.375-.775-.587T10.2 7q-.9 0-1.55.65T8 9.2q0 .475.137.875t.563.95q.35.45.975 1.075t1.6 1.525q.15.15.337.225t.388.075t.387-.075t.338-.225q.95-.875 1.575-1.487t.975-1.088q.425-.55.575-.963T16 9.2q0-.9-.65-1.55T13.8 7q-.525 0-1.013.212T12 7.8M12 18l-4.2 1.8q-1 .425-1.9-.162T5 17.975V5q0-.825.588-1.412T7 3h10q.825 0 1.413.588T19 5v12.975q0 1.075-.9 1.663t-1.9.162zm0-2.2l5 2.15V5H7v12.95zM12 5H7h10z\"/></svg>`,\n  bookmark: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2m0 15l-5-2.18L7 18V5h10z\"/></svg>`,\n  expand: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12 12.675L9.625 10.3q-.275-.275-.687-.275t-.713.275q-.3.3-.3.713t.3.712L11.3 14.8q.3.3.7.3t.7-.3l3.1-3.1q.3-.3.287-.7t-.312-.7q-.3-.275-.7-.288t-.7.288zM12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4T6.325 6.325T4 12t2.325 5.675T12 20m0-8\"/></svg>`,\n  collapse: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M12.675 12L10.3 14.375q-.275.275-.275.688t.275.712q.3.3.713.3t.712-.3L14.8 12.7q.3-.3.3-.7t-.3-.7l-3.1-3.1q-.3-.3-.7-.287t-.7.312q-.275.3-.288.7t.288.7zM12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4T6.325 6.325T4 12t2.325 5.675T12 20m0-8\"/></svg>`,\n  add: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"m12 18l-4.2 1.8q-1 .425-1.9-.162T5 17.975V5q0-.825.588-1.412T7 3h5q.425 0 .713.288T13 4t-.288.713T12 5H7v12.95l5-2.15l5 2.15V12q0-.425.288-.712T18 11t.713.288T19 12v5.975q0 1.075-.9 1.663t-1.9.162zm0-13H7h6zm5 2h-1q-.425 0-.712-.288T15 6t.288-.712T16 5h1V4q0-.425.288-.712T18 3t.713.288T19 4v1h1q.425 0 .713.288T21 6t-.288.713T20 7h-1v1q0 .425-.288.713T18 9t-.712-.288T17 8z\"/></svg>`,\n  del: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M16 7q-.425 0-.712-.288T15 6t.288-.712T16 5h4q.425 0 .713.288T21 6t-.288.713T20 7zm-4 11l-4.2 1.8q-1 .425-1.9-.162T5 17.975V5q0-.825.588-1.412T7 3h5q.425 0 .713.288T13 4t-.288.713T12 5H7v12.95l5-2.15l5 2.15V12q0-.425.288-.712T18 11t.713.288T19 12v5.975q0 1.075-.9 1.663t-1.9.162zm0-13H7h6z\"/></svg>`,\n  save: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h11.175q.4 0 .763.15t.637.425l2.85 2.85q.275.275.425.638t.15.762V19q0 .825-.587 1.413T19 21zM19 7.85L16.15 5H5v14h14zM12 18q1.25 0 2.125-.875T15 15t-.875-2.125T12 12t-2.125.875T9 15t.875 2.125T12 18m-5-8h7q.425 0 .713-.288T15 9V7q0-.425-.288-.712T14 6H7q-.425 0-.712.288T6 7v2q0 .425.288.713T7 10M5 7.85V19V5z\"/></svg>`,\n  down: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\" viewBox=\"0 2 20 20\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.5\" color=\"currentColor\"><path d=\"M16 10.5s-2.946 3-4 3s-4-3-4-3\"/></g></svg>`,\n  right: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\" viewBox=\"0 2 20 20\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.5\" color=\"currentColor\"><path d=\"M10.5 8s3 2.946 3 4s-3 4-3 4\"/></g></svg>`,\n  plus: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M4.194 14.867L2.881 18.16q-.067.157-.189.249q-.12.091-.296.091q-.288 0-.443-.228t-.051-.49l4.9-11.994q.048-.119.16-.204t.242-.084h.357q.149 0 .254.084q.104.085.152.204l4.887 11.943q.104.28-.063.525q-.167.244-.452.244q-.173 0-.322-.097q-.15-.098-.211-.268l-1.308-3.268zm.36-.967h5.584l-2.71-6.8h-.132zm13.83-1.4h-2.5q-.212 0-.356-.144t-.143-.357t.143-.356t.357-.143h2.5V9q0-.213.143-.356t.357-.144t.356.144t.144.356v2.5h2.5q.212 0 .356.144t.143.357t-.143.356t-.357.143h-2.5V15q0 .213-.143.356q-.144.144-.357.144t-.356-.144t-.144-.356z\" /></svg>`,\n  minus: `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"m1.616 18.5l5.288-13h.962l5.288 13h-1.208l-1.448-3.633H4.194L2.746 18.5zm2.938-4.6h5.584L7.436 7.1h-.139zm10.83-1.4v-1h7v1z\" /></svg>`,\n};\n\nexport const outline_css = `\n:root {\n  /* Light mode variables */\n  --background-color: #f5f5f5;\n  --container-bg: white;\n  --text-color: #333;\n  --heading-color: #2c3e50;\n  --border-color: #ddd;\n  --button-bg: #e8f4fd;\n  --button-hover-bg: #e8f4fd;\n  --node-hover-bg: #f0f0f0;\n  --selected-node-bg: #e8f4fd;\n  --shadow-color: rgba(0, 0, 0, 0.1);\n  --dragover-bg: rgba(52, 152, 219, 0.1);\n  --drop-indicator-color: #3498db;\n}\n\n#j-outline-viewer {\n  max-width: 1000px;\n  margin: 0 auto 37px auto;\n  background: var(--container-bg);\n  border-bottom-left-radius: 4px;\n  border-bottom-right-radius: 4px;\n  box-shadow: 0 2px 10px var(--shadow-color);\n  font-family: Arial, sans-serif;\n  line-height: 1.6;\n  color: var(--text-color);\n  padding: 2px 8px 8px 8px;\n  transition:\n    background-color 0.3s,\n    color 0.3s;\n}\n\n[data-theme=\"dark\"] {\n  /* Dark mode variables */\n  --background-color: #1a1a1a;\n  --container-bg: #2c2c2c;\n  --text-color: #e0e0e0;\n  --heading-color: #90caf9;\n  --border-color: #444;\n  --button-bg: #2196f3;\n  --button-hover-bg: #1976d2;\n  --node-hover-bg: #3e3e3e;\n  --selected-node-bg: #2a4055;\n  --shadow-color: rgba(0, 0, 0, 0.3);\n  --dragover-bg: rgba(33, 150, 243, 0.2);\n  --drop-indicator-color: #64b5f6;\n}\n\n.j-hidden {\ndisplay: none !important;}\n\n#j-outline-toolbar {\n  display: inline-flex;\n  gap: 6px;\n  padding: 4px 4px 4px 8px;\n  border-top: 1px solid #ddd;\n  border-bottom: 1px solid #ddd;\n}\n\n.j-outline-toolbar-button {\n  padding: 0;\n  margin: 0;\n  border: none;\n  background: none;\n  cursor: pointer;\n}\n\n.j-outline-toolbar-button svg {\n  display: block;\n  width: 24px;\n  height: 24px;\n}\n\nbutton:hover.j-outline-toolbar-button {\n  background: var(--button-hover-bg);\n}\n\n.tree-container {\n  border: 1px solid var(--border-color);\n  border-radius: 4px;\n  padding: 15px;\n  background: var(--container-bg);\n  width: 350px;\n  overflow: auto;\n  transition:\n    background 0.3s,\n    border-color 0.3s;\n}\n.tree-list {\n  list-style-type: none;\n  padding-left: 0;\n  position: relative;\n}\n.tree-list li {\n  margin: 1px 0;\n  position: relative;\n}\n.tree-list ul {\n  list-style-type: none;\n  padding-left: 25px;\n  padding-top: 2px;\n  position: relative;\n}\n.tree-node {\n  display: flex;\n  align-items: center;\n  padding: 1px;\n  border-radius: 2px;\n  cursor: pointer;\n  transition:\n    background 0.2s,\n    border-left-color 0.2s;\n  border-left: 2px solid transparent;\n  border-left-width: 2px;\n  position: relative;\n}\n.tree-node:hover {\n  background: var(--node-hover-bg);\n}\n.node-selected {\n  background: var(--selected-node-bg);\n}\n.tree-node.dragging {\n  opacity: 0.5;\n}\n.dragover {\n  background-color: var(--dragover-bg);\n}\n.drop-indicator {\n  position: absolute;\n  height: 2px;\n  background-color: var(--drop-indicator-color);\n  left: 0;\n  right: 0;\n  pointer-events: none;\n  display: none;\n  transition: background-color 0.3s;\n}\n.drop-indicator.visible {\n  display: block;\n}\n.drop-indicator::before {\n  content: \"\";\n  position: absolute;\n  width: 6px;\n  height: 6px;\n  border-radius: 50%;\n  background-color: var(--drop-indicator-color);\n  left: -3px;\n  top: -2px;\n  transition: background-color 0.3s;\n}\n.drop-indicator.top {\n  top: 0;\n}\n.drop-indicator.bottom {\n  bottom: 0;\n}\n.drop-indicator.middle {\n  top: 50%;\n  box-shadow: 0 0 3px var(--shadow-color);\n}\n.expander {\n  width: 20px;\n  height: 20px;\n  cursor: pointer;\n  margin-right: 2px;\n  margin-left: -6px;\n  text-align: center;\n  line-height: 10px;\n  flex-shrink: 0;\n}\n.node-content {\n  flex-grow: 1;\n  overflow: hidden;\n  white-space: nowrap;\n  display: flex;\n}\n.node-title {\n  display: inline-block;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  width: 100%;\n}\n.node-edit {\n  padding: 2px;\n  border: 1px solid var(--border-color);\n  border-radius: 3px;\n  font-family: inherit;\n  font-size: inherit;\n  width: 100%;\n  background-color: var(--container-bg);\n  color: var(--text-color);\n}\n/* Rainbow hierarchy indicators for different levels - maintained in both themes */\n.level-1 {\n  font-size: 12px;\n  border-left-color: #ff5252; /* Red */\n}\n.level-2 {\n  font-size: 11px;\n  border-left-color: #ff9800; /* Orange */\n}\n.level-3 {\n  font-size: 10px;\n  border-left-color: #ffeb3b; /* Yellow */\n}\n.level-4 {\n  font-size: 10px;\n  border-left-color: #4caf50; /* Green */\n}\n.level-5 {\n  font-size: 10px;\n  border-left-color: #2196f3; /* Blue */\n}\n.level-6 {\n  font-size: 10px;\n  border-left-color: #673ab7; /* Purple */\n}\n.level-7 {\n  font-size: 10px;\n  border-left-color: #e91e63; /* Pink */\n}\n.collapsed > ul {\n  display: none;\n}\n\n.hidden {\n  display: none\n}\n\n/* 书签相关样式 */\n#j-bookmark-toolbar {\n  display: inline-flex;\n  gap: 6px;\n  padding: 4px 4px 4px 8px;\n  border-top: 1px solid #ddd;\n  border-bottom: 1px solid #ddd;\n}\n\n.j-bookmark-toolbar-button {\n  padding: 0;\n  margin: 0;\n  border: none;\n  background: none;\n  cursor: pointer;\n}\n\n.j-bookmark-toolbar-button svg {\n  display: block;\n  width: 24px;\n  height: 24px;\n}\n\nbutton:hover.j-bookmark-toolbar-button {\n  background: var(--button-hover-bg);\n}\n\n#j-bookmark-viewer {\n  max-width: 1000px;\n  margin: 0 auto 37px auto;\n  background: var(--container-bg);\n  border-bottom-left-radius: 4px;\n  border-bottom-right-radius: 4px;\n  box-shadow: 0 2px 10px var(--shadow-color);\n  font-family: Arial, sans-serif;\n  line-height: 1.6;\n  color: var(--text-color);\n  padding: 2px 8px 8px 8px;\n  transition:\n    background-color 0.3s,\n    color 0.3s;\n}\n\n.bookmark-list {\n  list-style-type: none;\n  padding-left: 0;\n  position: relative;\n}\n\n.bookmark-list li {\n  margin: 2px 0;\n  position: relative;\n}\n\n.bookmark-node {\n  display: flex;\n  align-items: center;\n  padding: 6px 8px;\n  border-radius: 4px;\n  cursor: pointer;\n  transition:\n    background 0.2s,\n    border-left-color 0.2s;\n  border-left: 3px solid #3498db;\n  position: relative;\n  font-size: 13px;\n}\n\n.bookmark-node:hover {\n  background: var(--node-hover-bg);\n}\n\n.bookmark-selected {\n  background: var(--selected-node-bg);\n}\n\n.bookmark-node.dragging {\n  opacity: 0.5;\n}\n\n.bookmark-dragover {\n  background-color: var(--dragover-bg);\n}\n\n.bookmark-drop-indicator {\n  position: absolute;\n  height: 2px;\n  background-color: var(--drop-indicator-color);\n  left: 0;\n  right: 0;\n  pointer-events: none;\n  display: none;\n  transition: background-color 0.3s;\n  z-index: 1000;\n}\n\n.bookmark-drop-indicator.visible {\n  display: block;\n}\n\n.bookmark-drop-indicator::before {\n  content: \"\";\n  position: absolute;\n  width: 6px;\n  height: 6px;\n  border-radius: 50%;\n  background-color: var(--drop-indicator-color);\n  left: -3px;\n  top: -2px;\n  transition: background-color 0.3s;\n}\n\n.bookmark-content {\n  flex-grow: 1;\n  overflow: hidden;\n  white-space: nowrap;\n  display: flex;\n}\n\n.bookmark-title {\n  display: inline-block;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  width: 100%;\n}\n\n.empty-bookmark-prompt {\n  text-align: center;\n  color: #999;\n  font-style: italic;\n  padding: 20px;\n  border: 2px dashed #ddd;\n  border-radius: 4px;\n  margin: 10px 0;\n}\n\n/* 书签颜色选择器 */\n.bookmark-color-picker {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 4px;\n  padding: 4px;\n  background: var(--bookmark-container-bg);\n  border: 1px solid var(--bookmark-border-color);\n  border-radius: 4px;\n}\n\n.bookmark-color-option {\n  width: 20px;\n  height: 20px;\n  border-radius: 50%;\n  cursor: pointer;\n  border: 1px solid var(--bookmark-border-color);\n  transition: transform 0.2s, border 0.2s;\n}\n\n.bookmark-color-option:hover {\n  transform: scale(1.1);\n}\n\n.bookmark-color-option.selected {\n  border: 2px solid var(--bookmark-text-color);\n}\n\n/* 书签编辑容器 */\n.bookmark-edit-container {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n  min-width: 200px;\n  padding: 8px;\n  background: var(--bookmark-container-bg);\n  border: 1px solid var(--bookmark-border-color);\n  border-radius: 4px;\n}\n\n.bookmark-edit-container input {\n  padding: 4px 8px;\n  border: 1px solid var(--bookmark-border-color);\n  border-radius: 3px;\n  background: var(--bookmark-container-bg);\n  color: var(--bookmark-text-color);\n  font-family: inherit;\n  font-size: inherit;\n}\n\n/* 书签编辑分隔线 */\n.bookmark-edit-separator {\n  height: 1px;\n  background: linear-gradient(90deg, transparent, var(--bookmark-border-color), transparent);\n  margin: 4px 0;\n  opacity: 0.6;\n}\n\n/* 底部功能栏 */\n.jasminum-sidebar-bottom {\n  padding: 8px 8px;\n  border-top: var(--material-panedivider);\n  z-index: 1;\n  height: 37px;\n  overflow: hidden;\n  position: absolute;\n  bottom: 0;\n  width: 100%;\n  display: flex;\n  gap: 8px;\n  background-color: var(--background-color);\n}\n`;\n"
  },
  {
    "path": "src/modules/preferences/main.ts",
    "content": "import { config } from \"../../../package.json\";\nimport { isMainlandChina } from \"../../utils/http\";\nimport { getString } from \"../../utils/locale\";\nimport { getPref, setPref } from \"../../utils/prefs\";\nimport { updateTranslators, bestSpeedBaseUrl } from \".././translators\";\nimport type { PluginPrefsMap } from \"../../utils/prefs\";\nimport { onShowTable } from \"./translators\";\n\nexport function registerPrefsPane() {\n  Zotero.PreferencePanes.register({\n    pluginID: config.addonID,\n    src: `chrome://${config.addonRef}/content/preferences-main.xhtml`,\n    label: getString(\"plugin-name\"),\n    image: `chrome://${config.addonRef}/content/icons/icon.png`,\n  });\n}\n\n/**\n * This function is called when the prefs window is opened\n   See addon/chrome/content/preferences.xul onpaneload\n * @param _window Preference window\n */\nexport async function onPrefsWindowLoad(_window: Window) {\n  if (!addon.data.prefs) {\n    addon.data.prefs = {\n      window: _window,\n    };\n  } else {\n    addon.data.prefs.window = _window;\n  }\n  updatePrefsUI(addon.data.prefs.window.document);\n  bindPrefEvents(addon.data.prefs.window.document);\n}\n\n/**\n * Initialize platform specific preferences\n */\nexport async function initPrefs() {\n  ztoolkit.log(\"init some prefs\");\n\n  if (addon.data.env == \"development\") {\n    setPref(\"firstRun\", true);\n  }\n\n  if (getPref(\"firstRun\")) {\n    // For Zotero 6\n    migratePrefs(\"extensions.zotero.jasminum.\");\n    // For Zotero 7\n    migratePrefs(\"extensions.jasminum.\");\n\n    const inMainlandChina = await isMainlandChina();\n    setPref(\"isMainlandChina\", inMainlandChina);\n\n    setPref(\"firstRun\", false);\n  }\n\n  if (!getPref(\"pdfMatchFolder\")) {\n    setPref(\n      \"pdfMatchFolder\",\n      Services.dirsvc.get(\"DfltDwnld\", Ci.nsIFile).path,\n    );\n  }\n\n  if (\n    !getPref(\"translatorSource\") ||\n    getPref(\"translatorSource\") ===\n      \"https://ftp.linxingzhong.top/translators_CN\"\n  ) {\n    setPref(\"translatorSource\", await bestSpeedBaseUrl());\n  }\n\n  const translatortUpdateTime = getPref(\"translatorUpdateTime\");\n  if (\n    typeof translatortUpdateTime !== \"string\" ||\n    /\\D/.test(translatortUpdateTime)\n  ) {\n    Zotero.Prefs.clear(`${config.prefsPrefix}.translatorUpdateTime`);\n    setPref(\"translatorUpdateTime\", \"0\");\n  }\n}\n\n/**\n * Keep preferences startswith extensions.jasminum, clear deprecated preferences.\n * This function should be called only once when updating from old version extension.\n * @param prefix prefix with following dot\n */\nfunction migratePrefs(prefix: string) {\n  ztoolkit.log(`migrate prefs with prefix ${prefix}`);\n\n  const acceptPrefsMap: Record<string, keyof PluginPrefsMap> = {\n    firstrun: \"firstRun\",\n    /* tools */\n    zhnamesplit: \"autoSplitName\",\n    ennamesplit: \"splitEnName\",\n    language: \"language\",\n    /* retrieve metadata */\n    autoupdate: \"autoUpdateMetadata\",\n    namepattern: \"namePattern\",\n    namepatternCustom: \"namePatternCustom\",\n    metadataSource: \"metadataSource\",\n    /* match pdf */\n    pdfMatchFolder: \"pdfMatchFolder\",\n    /* update translators */\n    autoUpdateTranslators: \"autoUpdateTranslators\",\n    translatorSource: \"translatorSource\",\n  };\n  function isPrefKey(key: string): key is keyof typeof acceptPrefsMap {\n    return key in acceptPrefsMap;\n  }\n\n  const oldPrefs = Services.prefs.getBranch(prefix).getChildList(\"\");\n  for (const oldPrefKey of oldPrefs) {\n    const oldFullKey = `${prefix}${oldPrefKey}`;\n    const prefValue = Zotero.Prefs.get(oldFullKey);\n    if (prefValue !== undefined) {\n      if (isPrefKey(oldPrefKey)) {\n        const newPrefKey = acceptPrefsMap[oldPrefKey];\n        // New preference key is compatible with old preference value\n        setPref(newPrefKey, prefValue as PluginPrefsMap[keyof PluginPrefsMap]);\n        ztoolkit.log(\n          `Migrate preference ${oldFullKey} -> ${config.prefsPrefix}.${newPrefKey}, ${prefValue}`,\n        );\n      } else {\n        Zotero.Prefs.clear(oldFullKey);\n      }\n    }\n  }\n}\n\n/**\n * Initialize UI elements on prefs window with addon.data.prefs.window.document\n */\nasync function updatePrefsUI(doc: Document) {\n  const namePatterns: Record<string, number> = {\n    auto: 1,\n    \"{%t}_{%g}\": 2,\n    \"{%t}\": 3,\n    custom: 4,\n  };\n  (\n    doc.querySelector(\n      \"#zotero-prefpane-jasminum-namepattern-menulist\",\n    ) as XULMenuListElement\n  ).selectedIndex = namePatterns[getPref(\"namePattern\")] - 1;\n}\n\nfunction bindPrefEvents(doc: Document) {\n  /* PDF file name patttern */\n  doc\n    .getElementById(`zotero-prefpane-${config.addonRef}-namepattern-menulist`)\n    ?.addEventListener(\"click\", (event: Event) => {\n      const pName = \"namePattern\";\n      const value = (event.target as XULMenuItemElement).getAttribute(\"value\")!;\n      const customInput = doc.getElementById(\n        `zotero-prefpane-${config.addonRef}-namepatternCustom-input`,\n      );\n      const input = doc.getElementById(\n        `zotero-prefpane-${config.addonRef}-namepattern-input`,\n      );\n\n      const isCustom = value === \"custom\";\n      if (isCustom) setPref(\"namePattern\", \"custom\");\n      customInput?.classList.toggle(\"hidden\", !isCustom);\n      input?.classList.toggle(\"hidden\", isCustom);\n      setPref(pName, value);\n    });\n\n  /* Update translators */\n  doc\n    .getElementById(`zotero-prefpane-${config.addonRef}-force-update`)\n    ?.addEventListener(\"click\", async (event) => {\n      const button = event.target as HTMLButtonElement;\n      button.disabled = true;\n      if (addon.data.translators.updating) {\n        ztoolkit.log(\"Chinese translators are under updating.\");\n        addon.data.prefs?.window.alert(\n          getString(\"info-translators-cn-updaing\"),\n        );\n      } else {\n        await updateTranslators(true);\n      }\n      addon.data.prefs?.window.setTimeout(() => {\n        button.disabled = false;\n      }, 3000);\n    });\n\n  doc\n    .querySelector(`#zotero-prefpane-${config.addonRef}-open-translator-table`)\n    ?.addEventListener(\"click\", async (event) => {\n      onShowTable();\n    });\n\n  doc\n    .getElementById(`zotero-prefpane-${config.addonRef}-best-speed-button`)\n    ?.addEventListener(\"click\", async (event) => {\n      const button = event.target as HTMLButtonElement;\n      button.disabled = true;\n      try {\n        const bestUrl = await bestSpeedBaseUrl();\n        setPref(\"translatorSource\", bestUrl);\n        addon.data.prefs?.window.alert(\n          getString(\"info-best-speed-source-updated\", {\n            args: { source: bestUrl },\n          }),\n        );\n      } catch (error) {\n        ztoolkit.log(`select best speed source failed: ${error}`);\n        addon.data.prefs?.window.alert(\n          getString(\"info-best-speed-source-failed\"),\n        );\n      } finally {\n        button.disabled = false;\n      }\n    });\n\n  // metadata source dropdown\n  // doc\n  //   .querySelector(`#zotero-prefpane-${config.addonRef}-metadata-source-button`)\n  //   ?.addEventListener(\"click\", (e) => {\n  //     e.stopPropagation(); // 阻止事件冒泡\n  //     const pvalues = (getPref(\"metadataSource\") as string).split(\", \");\n  //     doc.querySelectorAll(\"checkbox.metadata-drop-item\")!.forEach((e: any) => {\n  //       e.checked = pvalues.includes(e.getAttribute(\"value\")!);\n  //     });\n  //     doc.querySelector(\"#metadata-source-dropdown\")?.classList.toggle(\"show\");\n  //   });\n\n  // doc\n  //   .querySelector(\"#metadata-source-dropdown\")\n  //   ?.addEventListener(\"click\", (e) => {\n  //     const checkbox = (e.target as HTMLElement).closest(\n  //       \".metadata-drop-item\",\n  //     )!;\n  //     let pvalues = getPref(\"metadataSource\").split(\", \") || [\"CNKI\"];\n  //     if (checkbox.getAttribute(\"checked\") == \"true\") {\n  //       const checkedSource = checkbox.getAttribute(\"value\")!;\n  //       if (!pvalues.includes(checkedSource)) {\n  //         pvalues.push(checkedSource);\n  //       }\n  //     } else {\n  //       pvalues = pvalues.filter(\n  //         (option) => option !== checkbox.getAttribute(\"value\")!,\n  //       );\n  //     }\n  //     setPref(\"metadataSource\", pvalues.join(\", \"));\n  //   });\n\n  doc\n    .querySelector(\n      `#zotero-prefpane-${config.addonRef}-pdf-match-folder-button`,\n    )\n    ?.addEventListener(\"click\", async (e) => {\n      const path = await new ztoolkit.FilePicker(\n        getString(\"select-download-folder\"),\n        \"folder\",\n        [],\n      ).open();\n      if (path) setPref(\"pdfMatchFolder\", path);\n    });\n\n  // doc\n  //   .querySelector(\n  //     `#zotero-prefpane-${config.addonRef}-install-wps-plugin-button`,\n  //   )\n  //   ?.addEventListener(\"click\", async (e) => {\n  //     ztoolkit.getGlobal(\"window\").alert(\"等待更新\");\n  //   });\n}\n"
  },
  {
    "path": "src/modules/preferences/translators.ts",
    "content": "import { isWindowAlive } from \"../../utils/window\";\nimport { getLastUpdatedFromFile, getLastUpdatedMap } from \"../translators\";\nimport { config } from \"../../../package.json\";\nimport { getString } from \"../../utils/locale\";\n\nasync function onWindowLoad(_window: Window) {\n  addon.data.translators.window = _window;\n  await updateRowData();\n  addon.data.translators.rows = addon.data.translators.allRows;\n  const columns = [\n    {\n      dataKey: \"filename\",\n      label: getString(\"th-filename\"),\n      fixedWidth: false,\n    },\n    {\n      dataKey: \"label\",\n      label: getString(\"th-label\"),\n      fixedWidth: false,\n    },\n    {\n      dataKey: \"localUpdateTime\",\n      label: getString(\"th-local-update-time\"),\n      fixedWidth: true,\n      width: 145,\n    },\n    {\n      dataKey: \"remoteUpdateTime\",\n      label: getString(\"th-remote-update-time\"),\n      fixedWidth: true,\n      width: 145,\n    },\n  ];\n  addon.data.translators.helper = new ztoolkit.VirtualizedTable(\n    addon.data.translators.window,\n  )\n    .setContainerId(\"table-container\")\n    .setProp({\n      id: \"translators-table\",\n      columns,\n      showHeader: true,\n      staticColumns: false,\n    })\n    .setProp(\"getRowCount\", () => addon.data.translators.rows.length)\n    .setProp(\n      \"getRowData\",\n      (index: number) => addon.data.translators.rows[index],\n    )\n    .setProp(\"onColumnSort\", (columnIndex, ascending) => {\n      // columnIndex from sort event is always valid, so assert its type\n      const sortKey = columns[columnIndex].dataKey as keyof TableRow;\n      addon.data.translators.rows.sort((a, b) => {\n        return ascending > 0\n          ? a[sortKey].localeCompare(b[sortKey])\n          : b[sortKey].localeCompare(a[sortKey]);\n      });\n      updateTableUI();\n    })\n    .render();\n  updateTableUI();\n}\n\nasync function updateRowData() {\n  const map = await getLastUpdatedMap(addon.data.env !== \"development\");\n  ztoolkit.log(\"updateRowData\", map);\n  const rows: TableRow[] = [];\n  for (const [filename, { label, lastUpdated }] of Object.entries(map)) {\n    rows.push({\n      filename,\n      label,\n      localUpdateTime: (await getLastUpdatedFromFile(filename)) || \"--\",\n      remoteUpdateTime: lastUpdated,\n    });\n  }\n  addon.data.translators.allRows = rows;\n}\n\nasync function updateTableUI() {\n  return new Promise<void>((resolve) => {\n    addon.data.translators.helper?.render(undefined, () => {\n      resolve();\n    });\n  });\n}\n\nfunction bindEvents(doc: Document) {\n  doc.getElementById(\"github-link\")?.addEventListener(\"click\", (event) => {\n    Zotero.launchURL(\"https://github.com/l0o0/translators_CN\");\n  });\n\n  const searchBox = doc.getElementById(\"search-box\");\n  searchBox?.addEventListener(\"command\", async (event) => {\n    ztoolkit.log(\"search\", event);\n    const value = (event.target as XULTextBoxElement).value;\n    if (!value) {\n      addon.data.translators.rows = addon.data.translators.allRows;\n    } else {\n      addon.data.translators.rows = addon.data.translators.allRows.filter(\n        (row) => {\n          function ignoreCaseIncludes(str: string, search: string) {\n            return str.toLowerCase().includes(search.toLowerCase());\n          }\n          return (\n            ignoreCaseIncludes(row.filename, value) ||\n            ignoreCaseIncludes(row.label, value)\n          );\n        },\n      );\n    }\n    await updateTableUI();\n    ztoolkit.log(`Updated table for search: ${value}`);\n  });\n  searchBox?.focus();\n\n  doc\n    .getElementById(\"request-new-translator\")\n    ?.addEventListener(\"click\", (event) => {\n      Zotero.launchURL(\n        \"https://github.com/l0o0/translators_CN/issues/new?template=T3_new_translator.yaml\",\n      );\n    });\n\n  doc\n    .getElementById(\"report-translator-bug\")\n    ?.addEventListener(\"click\", (event) => {\n      Zotero.launchURL(\n        \"https://github.com/l0o0/translators_CN/issues/new?template=T1_bug.yaml\",\n      );\n    });\n}\n\nexport async function onShowTable() {\n  if (isWindowAlive(addon.data.translators.window)) {\n    addon.data.translators.window!.focus();\n    await updateRowData();\n    await updateTableUI();\n  } else {\n    const windowArgs = {\n      _initPromise: Zotero.Promise.defer(),\n    };\n    const win = Zotero.getMainWindow().openDialog(\n      `chrome://${config.addonRef}/content/preferences-translators.xhtml`,\n      \"_blank\",\n      \"chrome,centerscreen,resizable\",\n      windowArgs,\n    );\n    await windowArgs._initPromise.promise;\n    addon.data.translators.window = win!;\n    await updateRowData();\n    onWindowLoad(addon.data.translators.window);\n    bindEvents(addon.data.translators.window!.document);\n  }\n}\n"
  },
  {
    "path": "src/modules/progress.ts",
    "content": "import {\n  ElementProps,\n  TagElementProps,\n} from \"zotero-plugin-toolkit/dist/tools/ui\";\nimport { getString } from \"../utils/locale\";\n\nexport class Progress {\n  public progressWindow: Window | null;\n  private statusIcons: Record<string, string> = {};\n\n  constructor() {\n    this.progressWindow = null;\n    this.statusIcons = {\n      waiting: \"chrome://jasminum/content/icons/loading-loop.svg\",\n      processing: \"chrome://jasminum/content/icons/loading-loop.svg\",\n      multiple_results: \"chrome://jasminum/content/icons/loading-loop.svg\",\n      success: \"chrome://jasminum/content/icons/check.svg\",\n      fail: \"chrome://jasminum/content/icons/cross.svg\",\n    };\n  }\n\n  public async openProgressWindow(): Promise<void> {\n    ztoolkit.log(`Open progress window.`);\n    const win = Services.wm.getMostRecentWindow(\"navigator:browser\") as Window;\n    const htmlUrl = \"chrome://jasminum/content/progress.xhtml\";\n    const chromeArgs =\n      \"chrome,centerscreen,width=960,height=400,dialog=yes,resizable=no,status=no\";\n\n    const windowArgs = { _initPromise: Zotero.Promise.defer() };\n\n    if (win) {\n      this.progressWindow = win.openDialog(htmlUrl, \"\", chromeArgs, windowArgs);\n      this.progressWindow!.onbeforeunload = (e) => {\n        this.progressWindow = null;\n        addon.taskRunner.tasks = [];\n      };\n      // For close button in header bar\n      this.progressWindow!.onclose = (e) => {\n        this.progressWindow = null;\n        addon.taskRunner.tasks = [];\n      };\n      let t = 0;\n      // Wait for window\n      while (\n        t < 500 &&\n        this.progressWindow!.document.readyState !== \"complete\"\n      ) {\n        // @ts-ignore -- Delay is not typed.\n        await ztoolkit.getGlobal(\"Zotero\").Promise.delay(10);\n        t += 1;\n      }\n      await windowArgs._initPromise.promise;\n    } else {\n      ztoolkit.log(`Maybe this is an error. No main window found.`);\n      // return Services.ww.openWindow(null, htmlUrl, \"\", chromeArgs, io);\n    }\n  }\n\n  private createSearchResultProps(\n    task: Task,\n    searchResults: (ScrapeSearchResult | AttachmentSearchResult)[],\n  ): TagElementProps {\n    return {\n      tag: \"div\",\n      classList: [\"search-results-container\"],\n      id: `search-results-container-${task.id}`,\n      children: [\n        {\n          namespace: \"html\",\n          tag: \"button\",\n          classList: [\"confirm-button\"],\n          properties: {\n            innerText: \"确认\",\n          },\n          attributes: { \"data-task-id\": task.id },\n        },\n        {\n          tag: \"div\",\n          classList: [\"search-results\"],\n          id: `search-results-${task.id}`,\n          children: searchResults.map((result, index) => ({\n            tag: \"div\",\n            classList: [\"search-result\"],\n            children: [\n              {\n                tag: \"input\",\n                properties: {\n                  type: \"radio\",\n                  name: `task-${task.id}`,\n                },\n                attributes: {\n                  \"data-task-id\": `${task.id}`,\n                  \"data-result-index\": `${index}`,\n                },\n              },\n              {\n                tag: \"div\",\n                classList: [\"info\"],\n                children: [\n                  {\n                    tag: \"span\",\n                    classList: [\"source\"],\n                    properties: { innerText: `来源: ${result.source}` },\n                  },\n                  {\n                    tag: \"span\",\n                    classList: [\"title\"],\n                    properties: { innerText: `${result.title}` },\n                  },\n                ],\n              },\n            ],\n          })),\n        },\n      ],\n    };\n  }\n\n  // Add new task to progress window.\n  public async addTaskToProgressWindow(task: Task): Promise<void> {\n    if (task.silent === true) return;\n\n    if (this.progressWindow == null) {\n      await this.openProgressWindow();\n    }\n\n    ztoolkit.log(\"Add task to progress window.\");\n    const taskNodeProps: ElementProps = {\n      classList: [\"task\"],\n      children: [\n        {\n          tag: \"div\",\n          classList: [\"task-header\"],\n          id: `task-header-${task.id}`,\n        },\n      ],\n      attributes: { \"data-task-id\": task.id },\n    };\n    const searchContainer: TagElementProps = {\n      tag: \"div\",\n      classList: [\"search-results-container\"],\n      id: `search-results-container-${task.id}`,\n      properties: { style: \"display: none;\" },\n      children: [\n        {\n          namespace: \"html\",\n          tag: \"button\",\n          classList: [\"confirm-button\"],\n          properties: { innerText: \"确认\" },\n          attributes: { \"data-task-id\": task.id },\n        },\n      ],\n    };\n    // <object type=\"image/svg+xml\" data=\"A.svg\"></object>\n    const taskHeaderChildren: TagElementProps[] = [\n      {\n        tag: \"img\",\n        classList: [\"task-status\"],\n        id: `task-status-${task.id}`,\n        properties: { src: this.statusIcons[task.status] },\n      },\n      {\n        tag: \"span\",\n        classList: [\"task-title\"],\n        properties: { innerText: task.item.getField(\"title\") },\n      },\n    ];\n\n    // if (task.searchResult && task.searchResult.length > 0) {\n\n    // }\n\n    taskNodeProps.children![0].children = taskHeaderChildren;\n    taskNodeProps.children?.push(searchContainer);\n\n    const taskNode = ztoolkit.UI.createElement(\n      this.progressWindow!.document,\n      \"div\",\n      taskNodeProps,\n    );\n\n    this.progressWindow!.document.querySelector(\"#task-list\")?.appendChild(\n      taskNode,\n    );\n  }\n  // Convert [text](url) and bare URLs in text to clickable <a> elements\n  private linkifyMessage(doc: Document, message: string): DocumentFragment {\n    const fragment = doc.createDocumentFragment();\n    // Match [text](url) first, then bare URLs\n    const linkRegex = /\\[([^\\]]+)\\]\\((https?:\\/\\/[^)]+)\\)|(https?:\\/\\/[^\\s]+)/g;\n    const lines = message.split(\"\\n\");\n\n    lines.forEach((line, lineIndex) => {\n      let lastIndex = 0;\n      let match: RegExpExecArray | null;\n\n      linkRegex.lastIndex = 0;\n\n      while ((match = linkRegex.exec(line)) !== null) {\n        if (match.index > lastIndex) {\n          fragment.appendChild(\n            doc.createTextNode(line.slice(lastIndex, match.index)),\n          );\n        }\n        const link = doc.createElement(\"a\");\n        link.setAttribute(\"href\", \"#\");\n        if (match[1]) {\n          // [text](url) format\n          link.textContent = match[1];\n          link.setAttribute(\"data-url\", match[2]);\n        } else {\n          // Bare URL\n          link.textContent = match[3];\n          link.setAttribute(\"data-url\", match[3]);\n        }\n        fragment.appendChild(link);\n        lastIndex = match.index + match[0].length;\n      }\n\n      if (lastIndex < line.length) {\n        fragment.appendChild(doc.createTextNode(line.slice(lastIndex)));\n      }\n\n      if (lineIndex < lines.length - 1) {\n        fragment.appendChild(doc.createElement(\"br\"));\n      }\n    });\n\n    return fragment;\n  }\n\n  // Update task status icon. Display error msgs when task fails.\n  public updateTaskStatus(task: Task, status: string): void {\n    if (this.progressWindow) {\n      ztoolkit.log(`Progress windows update task status: ${task.id} ${status}`);\n      this.progressWindow.document\n        .querySelector(`#task-status-${task.id}`)\n        ?.setAttribute(\"src\", this.statusIcons[status]);\n      // Display a popover with error msg.\n      if (status == \"fail\") {\n        const doc = this.progressWindow.document;\n\n        // Create wrapper for hover area\n        const wrapper = doc.createElement(\"span\");\n        wrapper.className = \"task-msg-wrapper\";\n\n        // Create notify icon\n        const icon = ztoolkit.UI.createElement(doc, \"img\", {\n          id: `task-msg-${task.id}`,\n          classList: [\"task-msg\"],\n          properties: {\n            src: \"chrome://jasminum/content/icons/notify.svg\",\n          },\n        });\n\n        // Create popover container\n        const popover = doc.createElement(\"div\");\n        popover.className = \"task-msg-popover\";\n        popover.id = `task-msg-popover-${task.id}`;\n\n        if (task.message) {\n          popover.appendChild(this.linkifyMessage(doc, task.message));\n        }\n\n        // Handle link clicks with Zotero.launchURL\n        popover.addEventListener(\"click\", (e) => {\n          const target = e.target as HTMLElement;\n          if (target.tagName === \"A\") {\n            e.preventDefault();\n            e.stopPropagation();\n            const url = target.getAttribute(\"data-url\");\n            if (url) {\n              Zotero.launchURL(url);\n            }\n          }\n        });\n\n        // Show popover on hover\n        wrapper.addEventListener(\"mouseenter\", () => {\n          // Close other popovers first\n          doc.querySelectorAll(\".task-msg-popover.visible\").forEach((p) => {\n            p.classList.remove(\"visible\");\n          });\n          doc.querySelectorAll(\".task-msg.active\").forEach((i) => {\n            i.classList.remove(\"active\");\n          });\n          popover.classList.add(\"visible\");\n          icon.classList.add(\"active\");\n        });\n\n        // Close popover on click outside\n        doc.addEventListener(\"click\", (e) => {\n          const target = e.target as HTMLElement;\n          if (\n            !target.closest(\".task-msg-popover\") &&\n            !target.closest(\".task-msg-wrapper\")\n          ) {\n            popover.classList.remove(\"visible\");\n            icon.classList.remove(\"active\");\n          }\n        });\n\n        wrapper.appendChild(icon);\n        wrapper.appendChild(popover);\n\n        doc\n          .querySelector(`#task-header-${task.id} > span.task-title`)\n          ?.appendChild(wrapper);\n      }\n    }\n  }\n\n  public updateTaskSearchResult(\n    task: Task,\n    searchResults: (ScrapeSearchResult | AttachmentSearchResult)[],\n  ): void {\n    if (this.progressWindow) {\n      ztoolkit.log(searchResults);\n      const props = this.createSearchResultProps(task, searchResults);\n      const taskSearchNode = ztoolkit.UI.createElement(\n        this.progressWindow.document,\n        \"div\",\n        props,\n      );\n      const toggle = ztoolkit.UI.createElement(\n        this.progressWindow.document,\n        \"span\",\n        {\n          classList: [\"toggle-icon\"],\n          id: `toggle-icon-${task.id}`,\n          properties: { innerText: \"▼\" },\n        },\n      );\n      // Replace the old search result node with the new one.\n      this.progressWindow.document\n        .querySelector(`#search-results-container-${task.id}`)!\n        .replaceWith(taskSearchNode);\n      this.progressWindow.document\n        .querySelector(`#task-header-${task.id}`)!\n        .appendChild(toggle);\n    }\n  }\n}\n"
  },
  {
    "path": "src/modules/services/cnki.ts",
    "content": "import { requestDocument } from \"../../utils/http\";\nimport { DocTools, jsonToFormUrlEncoded, text2HTMLDoc } from \"../../utils/http\";\nimport { getPref } from \"../../utils/prefs\";\nimport { ScraperTask } from \"../../utils/task\";\n\n/**\n * Create post data for CNKI search.\n * @param searchOption\n * @returns\n */\nfunction createSearchPostOptions(searchOption: SearchOption) {\n  let url;\n  const headers = {\n    Host: \"kns.cnki.net\",\n    \"User-Agent\":\n      \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0\",\n    Accept: \"*/*\",\n    \"Accept-Language\": \"zh-CN,en-US;q=0.9,en;q=0.8\",\n    \"Content-Type\": \"application/x-www-form-urlencoded; charset=UTF-8\",\n    \"X-Requested-With\": \"XMLHttpRequest\",\n    Origin: \"https://kns.cnki.net\",\n    Referer: `https://kns.cnki.net/kns8s/defaultresult/index?crossids=YSTT4HG0%2CLSTPFY1C%2CJUP3MUPD%2CMPMFIG1A%2CWQ0UVIAA%2CBLZOG7CK%2CPWFIRAGL%2CEMRPGLPA%2CNLBO1Z6R%2CNN3FJMUV&korder=SU&kw=`,\n    \"Sec-Fetch-Dest\": \"empty\",\n    \"Sec-Fetch-Mode\": \"cors\",\n  }; // SU may find more results than TI. SU %= | TI %=\n  let searchExp: string;\n  if (searchOption.title.includes(\" \")) {\n    // 过滤掉短的主题词，可以避免出现大量无关结果\n    const titleParts = searchOption.title\n      .split(\" \")\n      .filter((i) => i.length > 4);\n    searchExp =\n      \"(TI %= \" +\n      titleParts.map((_i) => `'${_i}'`).join(\" % \") +\n      \" OR SU %= \" +\n      titleParts.join(\"+\") +\n      \")\";\n  } else {\n    searchExp = `TI %= '${searchOption.title}'`;\n  }\n  if (searchOption.author)\n    searchExp = searchExp + ` AND AU='${searchOption.author}'`;\n  ztoolkit.log(\"Search expression: \", searchExp);\n  const searchExpAside = searchExp.replace(/'/g, \"&#39;\");\n  let queryJson;\n\n  if (getPref(\"isMainlandChina\")) {\n    ztoolkit.log(\"CNKI in mainland China.\");\n    url = \"https://kns.cnki.net/kns8s/brief/grid\";\n\n    queryJson = {\n      boolSearch: \"true\",\n      QueryJson: {\n        Platform: \"\",\n        Resource: \"CROSSDB\",\n        Classid: \"WD0FTY92\",\n        Products: \"\",\n        QNode: {\n          QGroup: [\n            {\n              Key: \"Subject\",\n              Title: \"\",\n              Logic: 0,\n              Items: [\n                {\n                  Key: \"Expert\",\n                  Title: \"\",\n                  Logic: 0,\n                  Field: \"EXPERT\",\n                  Operator: 0,\n                  Value: searchExp,\n                  Value2: \"\",\n                },\n              ],\n              ChildItems: [],\n            },\n            {\n              Key: \"ControlGroup\",\n              Title: \"\",\n              Logic: 0,\n              Items: [],\n              ChildItems: [],\n            },\n          ],\n        },\n        ExScope: \"1\",\n        SearchType: 4,\n        Rlang: \"CHINESE\",\n        KuaKuCode:\n          \"YSTT4HG0,LSTPFY1C,JUP3MUPD,MPMFIG1A,WQ0UVIAA,BLZOG7CK,PWFIRAGL,EMRPGLPA,NLBO1Z6R,NN3FJMUV\",\n        SearchFrom: 1,\n      },\n      pageNum: \"1\",\n      pageSize: \"20\",\n      sortField: \"\",\n      sortType: \"\",\n      dstyle: \"listmode\",\n      productStr:\n        \"YSTT4HG0,LSTPFY1C,RMJLXHZ3,JQIRZIYA,JUP3MUPD,1UR4K4HZ,BPBAFJ5S,R79MZMCB,MPMFIG1A,WQ0UVIAA,NB3BWEHK,XVLO76FD,HR1YT1Z9,BLZOG7CK,PWFIRAGL,EMRPGLPA,J708GVCE,ML4DRIDX,NLBO1Z6R,NN3FJMUV,\",\n      aside: `(${searchExpAside})`,\n      searchFrom: \"资源范围：总库;++中英文扩展;++时间范围：更新时间：不限;++\",\n      CurPage: \"1\",\n    };\n  } else {\n    ztoolkit.log(\"Using CNKI oversea.\");\n    url = \"https://chn.oversea.cnki.net/kns/Brief/GetGridTableHtml\";\n    headers.Host = \"www.cnki.net\";\n    headers.Referer = \"https://www.cnki.net/kns/defaultresult/index\";\n    headers.Origin = \"https://www.cnki.net\";\n    headers.Accept = \"text/html, */*; q=0.01\";\n    headers[\"Accept-Language\"] = \"zh-CN,zh;q=0.9\";\n\n    queryJson = {\n      IsSearch: \"true\",\n      QueryJson: {\n        Platform: \"\",\n        DBCode: \"CFLS\",\n        KuaKuCode:\n          \"CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFN\",\n        QNode: {\n          QGroup: [\n            {\n              Key: \"Subject\",\n              Title: \"\",\n              Logic: 4,\n              Items: [\n                {\n                  Key: \"Expert\",\n                  Title: \"\",\n                  Logic: 0,\n                  Name: \"\",\n                  Operate: \"\",\n                  Value: searchExp,\n                  ExtendType: 12,\n                  ExtendValue: \"中英文对照\",\n                  Value2: \"\",\n                  BlurType: \"\",\n                },\n              ],\n              ChildItems: [],\n            },\n            {\n              Key: \"ControlGroup\",\n              Title: \"\",\n              Logic: 1,\n              Items: [],\n              ChildItems: [],\n            },\n          ],\n        },\n        ExScope: 1,\n        CodeLang: \"\",\n      },\n      PageName: \"AdvSearch\",\n      DBCode: \"CFLS\",\n      KuaKuCodes:\n        \"CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFN\",\n      CurPage: \"1\",\n      RecordsCntPerPage: \"20\",\n      CurDisplayMode: \"listmode\",\n      CurrSortField: \"\",\n      CurrSortFieldType: \"desc\",\n      IsSentenceSearch: \"false\",\n      Subject: \"\",\n    };\n  }\n  // ztoolkit.log(queryJson);\n  // ztoolkit.log(jsonToFormUrlEncoded(queryJson));\n  return {\n    url: url,\n    data: jsonToFormUrlEncoded(queryJson),\n    headers: headers,\n  };\n}\n\nasync function getRefworksText(\n  searchResult: ScrapeSearchResult,\n): Promise<string | null> {\n  const headers = {\n    Accept: \"text/plain, */*; q=0.01\",\n    \"Accept-Language\": \"zh-CN,en-US;q=0.7,en;q=0.3\",\n    \"Content-Type\": \"application/x-www-form-urlencoded\",\n    Host: \"kns.cnki.net\",\n    Origin: \"https://www.cnki.net\",\n    Priority: \"u=0\",\n    \"User-Agent\":\n      \"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0\",\n    Referer: searchResult.url,\n  };\n  const isMainlandChina = getPref(\"isMainlandChina\");\n  if (getPref(\"isMainlandChina\")) {\n    // \"1\": row's sequence in search result page, defualt 1; \"0\": index of page in search result pages, defualt 0.\n    const platform = \"NZKPT\";\n    const apiUrl = \"https://kns.cnki.net/dm8/API/GetExport\";\n    let responseText: string;\n    let postData = isMainlandChina\n      ? `filename=${searchResult.exportID}&uniplatform=${platform}`\n      : `filename=${searchResult.dbname}!${searchResult.filename}!1!0`;\n    postData += \"&displaymode=GBTREFER%2Celearning%2CEndNote\";\n    const resp = await Zotero.HTTP.request(\"POST\", apiUrl, {\n      body: postData,\n      headers: headers,\n      cookieSandbox: await addon.data.myCookieSandbox.getCNKIHomeCookieBox(),\n      timeout: 10000,\n      successCodes: [200, 403],\n    });\n    ztoolkit.log(`Endnote reference text from CNKI: ${resp.responseText}`);\n    responseText = resp.responseText;\n    if (resp.status === 403) {\n      ztoolkit.log(\n        \"CNKI access forbidden (403). This is likely due to missing or invalid cookies.\",\n      );\n      const respJson = JSON.parse(resp.responseText);\n\n      ztoolkit.log(\"Retrying CNKI search after updating cookies...\");\n      headers[\"Referer\"] = respJson.message;\n      const resp2 = await Zotero.HTTP.request(\"POST\", apiUrl, {\n        headers: headers,\n        body: postData,\n        cookieSandbox: await addon.data.myCookieSandbox.passCaptchaToCookieBox(\n          respJson.message,\n          \"CNKI:Home\",\n        ),\n        timeout: 10000,\n        successCodes: [200, 403],\n      });\n      responseText = resp2.responseText;\n    }\n    const returnJson = JSON.parse(responseText);\n    if (returnJson.code != 1) {\n      return null;\n    } else {\n      const endnoteRef = returnJson.data.find(\n        (i: Record<string, string>) => i.key === \"EndNote\",\n      );\n      if (endnoteRef) {\n        return endnoteRef.value[0].replace(/<br>/g, \"\\n\");\n      } else {\n        return null;\n      }\n    }\n  } else {\n    ztoolkit.log(\"CNKI oversea export reference.\");\n    const apiUrl = \"https://chn.oversea.cnki.net/kns/Manage/APIGetExport\";\n    // TODO: implement oversea export\n    return null;\n  }\n}\n\nasync function getSnapshotItem(\n  item: Zotero.Item,\n): Promise<Zotero.Item | undefined> {\n  const regx = new RegExp(\n    \"/(kns8?s?|kcms2?)/(article/abstract\\\\?|detail/detail\\\\.aspx\\\\?)\",\n    \"i\",\n  );\n  if (item.itemType == \"webpage\" && regx.test(item.getField(\"url\"))) {\n    const attachmentItem = Zotero.Items.get(item.getAttachments()).find(\n      (attachment) => {\n        return (\n          attachment.isSnapshotAttachment() &&\n          regx.test(attachment.getField(\"url\"))\n        );\n      },\n    );\n    if (attachmentItem === undefined) return undefined;\n    const filePath = await attachmentItem.getFilePathAsync();\n    if (filePath) return attachmentItem;\n  }\n  return undefined;\n}\n\n// Update addtional information to the item.\n// Citations from CNKI, Use keyword: CNKICite\nasync function updateItem(\n  item: Zotero.Item | null,\n  searchResult: ScrapeSearchResult,\n): Promise<Zotero.Item | null> {\n  if (item) {\n    if (searchResult.citation) {\n      ztoolkit.ExtraField.setExtraField(\n        item,\n        \"CNKICite\",\n        `${searchResult.citation}`,\n      );\n    }\n\n    if (searchResult.netFirst) {\n      ztoolkit.ExtraField.setExtraField(\n        item,\n        \"Status\",\n        \"advance online publication\",\n      );\n    }\n\n    // Remove unmatched Zotero fields note.\n    if (item.getNotes().length > 0) {\n      item.getNotes().forEach(async (nid) => {\n        const nItem = Zotero.Items.get(nid);\n        await nItem.eraseTx();\n      });\n    }\n\n    if (!item.getField(\"date\") && searchResult.date) {\n      item.setField(\"date\", searchResult.date);\n    }\n  }\n  return item;\n}\n\nexport class CNKI implements ScrapeService {\n  async search(\n    searchOption: SearchOption,\n  ): Promise<ScrapeSearchResult[] | null> {\n    ztoolkit.log(\"serch options: \", searchOption);\n    const postOption = createSearchPostOptions(searchOption);\n    let responseText: string;\n    const resp = await Zotero.HTTP.request(\"POST\", postOption.url, {\n      headers: postOption.headers,\n      body: postOption.data,\n      cookieSandbox: await addon.data.myCookieSandbox.getCNKIHomeCookieBox(),\n      timeout: 10000,\n      successCodes: [200, 403],\n    });\n    ztoolkit.log(\"CNKI search response: \", resp);\n    responseText = resp.responseText;\n    if (resp.status === 403) {\n      ztoolkit.log(\n        \"CNKI search access forbidden (403). This is likely due to missing or invalid cookies.\",\n      );\n      const respJson = JSON.parse(resp.responseText);\n      ztoolkit.log(\"Retrying CNKI search after updating cookies...\");\n      await addon.data.myCookieSandbox.passCaptchaToCookieBox(\n        respJson.message,\n        \"CNKI:Home\",\n      );\n      postOption.headers[\"Referer\"] = respJson.message;\n      ztoolkit.log(\"Refer\", postOption.headers);\n      const resp2 = await Zotero.HTTP.request(\"POST\", postOption.url, {\n        headers: postOption.headers,\n        body: postOption.data,\n        cookieSandbox: await addon.data.myCookieSandbox.getCNKIHomeCookieBox(),\n        timeout: 10000,\n        successCodes: [200, 403],\n      });\n      ztoolkit.log(\"CNKI retry search response: \", resp2);\n      responseText = resp2.responseText;\n    }\n    ztoolkit.log(\"CNKI final search response: \", responseText);\n    const searchDoc = text2HTMLDoc(responseText);\n    const resultRows = searchDoc.querySelectorAll(\n      \"table.result-table-list > tbody > tr\",\n    );\n    ztoolkit.log(`CNKI search result: ${resultRows.length}`);\n    if (resultRows.length == 0) {\n      ztoolkit.log(\"CNKI no items found.\");\n      return null;\n    } else {\n      const resultData = Array.from(resultRows).map((r) => {\n        const dt = new DocTools(r as HTMLElement);\n        let url = dt.attr(\"a.fz14\", \"href\")!;\n        // Missing host in CNKI oversea.\n        if (!url.startsWith(\"http\")) {\n          url = \"https://chn.oversea.cnki.net\" + url;\n        }\n        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\")}`;\n        return {\n          source: \"CNKI\",\n          title: title,\n          articleTitle: dt.innerText(\"td.name a\"),\n          url: url,\n          date: Zotero.Date.strToISO(dt.innerText(\"td.date\")) || \"\",\n          netFirst: dt.innerText(\"td.name > b.marktip\"),\n          citation: dt.innerText(\"td.quote\"),\n          exportID: dt.attr(\"td.seq input\", \"value\"),\n          dbname: dt.attr(\"td.operat > [data-dbname]\", \"data-dbname\"),\n          filename: dt.attr(\"td.operat > [data-dbname]\", \"data-filename\"),\n        };\n      });\n      return resultData;\n    }\n  }\n\n  async translate(\n    searchResult: ScrapeSearchResult,\n    libraryID: number,\n    saveAttachments: false,\n  ): Promise<Zotero.Item[]> {\n    let translatedItems: Zotero.Item[] = [];\n    let isWebTranslated = true;\n    try {\n      const doc = await requestDocument(searchResult.url, {\n        headers: {\n          Accept:\n            \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\n          Referer: \"https://kns.cnki.net/kns8s/AdvSearch\",\n          \"Accept-Language\": \"zh-CN,en-US;q=0.7,en;q=0.3\",\n          \"User-Agent\":\n            \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0\",\n        },\n        cookieSandbox: await addon.data.myCookieSandbox.getCNKIHomeCookieBox(),\n      });\n      ztoolkit.log(`Document title: ${doc.title}`);\n      if (doc.title != \"知网节超时验证\" && doc.title != \"captcha\") {\n        // @ts-ignore - Translate is not typed.\n        const translator = new Zotero.Translate.Web();\n        // CNKI.js\n        // If the loading of translators fails, the following code might return nothing.\n        translator.setTranslator(\"5c95b67b-41c5-4f55-b71a-48d5d7183063\");\n        translator.setDocument(doc);\n        translatedItems = await translator.translate({\n          libraryID: libraryID,\n          saveAttachments: saveAttachments,\n        });\n      } else {\n        isWebTranslated = false;\n      }\n    } catch (e) {\n      ztoolkit.log(`CNKI web translation failed: ${e}`);\n      addon.taskRunner.runningTask?.addMsg(`CNKI web translation failed: ${e}`);\n      isWebTranslated = false;\n    }\n\n    // Another translation for CNKI.\n    if (isWebTranslated == false) {\n      try {\n        ztoolkit.log(\"知网网页出现验证码或其他异常，准备获取其他格式文献信息\");\n        const refworksText = await getRefworksText(searchResult);\n        if (!refworksText) {\n          ztoolkit.log(\"CNKI reference text is null.\");\n          addon.taskRunner.runningTask?.addMsg(\"CNKI reference text is null.\");\n          return [];\n        }\n        ztoolkit.log(\"Formated Refworks text: \", refworksText);\n        const translate = new Zotero.Translate.Import();\n        translate.setTranslator(\"7b6b135a-ed39-4d90-8e38-65516671c5bc\");\n        translate.setString(refworksText);\n        translatedItems = await translate.translate({\n          libraryID: libraryID,\n          saveAttachments: false,\n        });\n      } catch (e) {\n        ztoolkit.log(`CNKI refwork translation failed: ${e}`);\n        throw `CNKI refwork translation failed: ${e}`;\n      }\n    }\n    return translatedItems;\n  }\n\n  // CNKI webpage item or snapshot item.\n  async searchSnapshot(\n    task: ScraperTask,\n  ): Promise<ScrapeSearchResult[] | null> {\n    ztoolkit.log(\"Start to search for snapshot\");\n    let webpageItem: Zotero.Item;\n    let attachmentItem: Zotero.Item | undefined;\n    let searchResults: ScrapeSearchResult[] | null = null;\n\n    if (task.item.isTopLevelItem()) {\n      webpageItem = task.item;\n      attachmentItem = await getSnapshotItem(task.item);\n    } else {\n      // Snapshot item must have an valid parent item?\n      webpageItem = task.item.parentItem!;\n      attachmentItem = task.item;\n    }\n    // Find snapshot attachment,\n    if (attachmentItem) {\n      const filePath = (await attachmentItem.getFilePathAsync()) as string;\n      // Maybe we can find some usefull data from the snapshot page.\n      const doc = text2HTMLDoc(\n        (await Zotero.File.getContentsAsync(filePath)) as string,\n        attachmentItem.getField(\"url\"),\n      );\n      const dt = new DocTools(doc);\n      // http://x.cnki.net/search/common/testlunbo?dbcode=CJFQ&tablename=CJFDAUTO&filename=ZWBH202405039&filesourcetype=1\n      const noteUrl = dt.attr(\"li[title='记笔记'].btn-note > a\", \"href\");\n      // https://aiplus.cnki.net/aiplus/direct?cid=Pe2nFq1PBOM11SpCErZ-LwM1UHjV0uMR_icN4IXwgidjURR2ddM6CTa9OS-R4yps7kfD7g5Wa4sKEufH3KeS74nDa1x0Roidi_RcpyaNH-4!&mimetype=XML\n      const aiUrl = dt.attr(\"li.btn-cnki-ai > a\", \"href\");\n      const noteParams = new URLSearchParams(noteUrl.split(\"?\")[1]);\n      const aiParams = new URLSearchParams(aiUrl.split(\"?\")[1]);\n      searchResults = [\n        {\n          source: \"CNKI\",\n          title: attachmentItem.getField(\"title\"),\n          url: attachmentItem.getField(\"url\"),\n          dbcode: noteParams.get(\"dbcode\"),\n          dbname: noteParams.get(\"tablename\"),\n          filename: noteParams.get(\"filename\"),\n          exportID: aiParams.get(\"cid\"),\n        },\n      ];\n      ztoolkit.log(\"Found searchResult in snapshot page\", searchResults[0]);\n    }\n\n    // Found nothing in the snapshot page. Use CNKI search.\n    if (searchResults === null) {\n      const searchOption: SearchOption = {\n        title: webpageItem.getField(\"title\").replace(/ - 中国知网$/g, \"\"),\n      };\n      searchResults = await this.search(searchOption);\n      ztoolkit.log(\"Found searchResult from CNKI search\", searchResults);\n    }\n    return searchResults || null;\n  }\n}\n"
  },
  {
    "path": "src/modules/services/index.ts",
    "content": "import { getArgsFromPattern } from \"../../utils/pattern\";\nimport { getPDFTitle } from \"../../utils/pdfParser\";\nimport { getPref } from \"../../utils/prefs\";\nimport { ScraperTask } from \"../../utils/task\";\nimport { isChineseTopAttachment, isChinsesSnapshot } from \"../../utils/detect\";\nimport { CNKI } from \"./cnki\";\n// import { PubScholar } from \"./pubscholar\";\nimport { Yiigle } from \"./yiigle\";\nimport { compareTwoStrings } from \"string-similarity\";\n\nconst cnki = new CNKI();\n// const pubscholar = new PubScholar();\nconst yiigle = new Yiigle();\n\nasync function getSearchOption(\n  item: Zotero.Item,\n): Promise<SearchOption | null> {\n  let namepattern = getPref(\"namePattern\");\n  // Get title from pdf page content.\n  // 1: title from PDF, 2: {%t}_{%g}\n  if (namepattern == \"auto\") {\n    let title = undefined;\n    try {\n      title = await getPDFTitle(item.id);\n    } catch (e) {\n      ztoolkit.log(`Pdf parsing error ${e}`);\n    }\n    if (title) return { title };\n\n    return getArgsFromPattern(item.attachmentFilename, \"{%t}_{%g}\");\n  } else {\n    if (namepattern == \"custom\") namepattern = getPref(\"namePatternCustom\");\n    return getArgsFromPattern(item.attachmentFilename, namepattern);\n  }\n}\n\nexport async function metaSearch(\n  task: ScraperTask,\n  options?: any,\n): Promise<void> {\n  // const scrapeServices = getPref(\"metadataSource\").split(\", \") || [\"CNKI\"];\n  if (!isChineseTopAttachment(task.item) && !isChinsesSnapshot(task.item)) {\n    ztoolkit.log(\"No Chinese attachment or snapshot items found. Stop search.\");\n    return;\n  }\n\n  ztoolkit.log(\"search task\", task);\n  task.status = \"processing\";\n  // Searching by different scrape services\n  let scrapeSearchResults: ScrapeSearchResult[] = [];\n  if (task.type == \"attachment\") {\n    const searchOption = await getSearchOption(task.item);\n    task.addMsg(\n      `Region: ${getPref(\"isMainlandChina\") ? \"Mainland China\" : \"Overseas\"}`,\n    );\n    task.addMsg(`Search pattern: ${getPref(\"namePattern\")}`);\n    task.addMsg(`Search option: ${JSON.stringify(searchOption)}`);\n    if (searchOption) {\n      const cnkiSearchResult = await cnki.search(searchOption);\n      ztoolkit.log(\"cnki results\", cnkiSearchResult);\n      if (cnkiSearchResult) {\n        task.addMsg(`Found ${cnkiSearchResult.length} results from CNKI`);\n        scrapeSearchResults = scrapeSearchResults.concat(cnkiSearchResult);\n      }\n      // const pubscholarSearchResult = await pubscholar.search(searchOption);\n      // ztoolkit.log(\"pubscholar results\", pubscholarSearchResult);\n      // if (pubscholarSearchResult) {\n      //   task.addMsg(\n      //     `Found ${pubscholarSearchResult.length} results from PubScholar`,\n      //   );\n      //   scrapeSearchResults = scrapeSearchResults.concat(\n      //     pubscholarSearchResult,\n      //   );\n      // }\n      const yiigleSearchResult = await yiigle.search(searchOption);\n      ztoolkit.log(\"yiigle results\", yiigleSearchResult);\n      if (yiigleSearchResult) {\n        task.addMsg(`Found ${yiigleSearchResult.length} results from Yiigle`);\n        scrapeSearchResults = scrapeSearchResults.concat(yiigleSearchResult);\n      }\n\n      // Filter search results\n      const filteredResults1 = scrapeSearchResults.filter((result) => {\n        return (result.articleTitle as string).includes(searchOption.title);\n      });\n\n      const filteredResults2 = scrapeSearchResults.filter((result) => {\n        const score = compareTwoStrings(\n          searchOption.title,\n          result.articleTitle as string,\n        );\n        ztoolkit.log(`Similarity score for \"${result.articleTitle}\": ${score}`);\n        return (\n          !(result.articleTitle as string).includes(searchOption.title) &&\n          score > parseFloat(getPref(\"similarityThresholdForMetaData\"))\n        );\n      });\n      scrapeSearchResults = filteredResults1.concat(filteredResults2);\n      task.addMsg(\n        `After filtering, ${scrapeSearchResults.length} results left.`,\n      );\n    } else {\n      task.addMsg(\"Filename parsing error\");\n      task.status = \"fail\";\n    }\n  } else if (task.type == \"snapshot\") {\n    const tmp = await cnki.searchSnapshot!(task);\n    if (tmp) scrapeSearchResults = scrapeSearchResults.concat(tmp);\n  }\n\n  ztoolkit.log(\"all results: \", scrapeSearchResults);\n  if (scrapeSearchResults.length == 0) {\n    task.addMsg(\"No search results\");\n    task.status = \"fail\";\n  } else if (scrapeSearchResults.length > 1) {\n    task.status = \"multiple_results\";\n  }\n  task.searchResults = scrapeSearchResults;\n}\n\nexport async function metaTranslate(task: ScraperTask): Promise<void> {\n  if (task.searchResults.length === 0) {\n    task.addMsg(\"No search results found.\");\n    task.status = \"fail\";\n  }\n\n  try {\n    const resultIndex = task.resultIndex || 0; // default is 0\n    task.resultIndex = resultIndex;\n    const searchResult = task.searchResults[resultIndex];\n    const libraryID = task.item.libraryID;\n    ztoolkit.log(`start translate for search result: ${searchResult.title}`);\n    let translatedItems: Zotero.Item[] = [];\n    try {\n      switch (searchResult.source) {\n        case \"CNKI\":\n          ztoolkit.log(\"translated by CNKI\");\n          translatedItems = await cnki.translate(\n            searchResult,\n            libraryID,\n            false,\n          );\n          break;\n        // case \"PubScholar\":\n        //   ztoolkit.log(\"translated by PubScholar\");\n        //   newItem = await pubscholar.translate(task, false);\n        //   break;\n        case \"中华医学\":\n          ztoolkit.log(\"translated by Yiigle\");\n          translatedItems = await yiigle.translate(\n            searchResult,\n            libraryID,\n            false,\n          );\n          break;\n        default:\n          break;\n      }\n      ztoolkit.log(translatedItems);\n    } catch (e) {\n      ztoolkit.log(`Translation error: ${e}`);\n      task.addMsg(`Translation error: ${e}`);\n    }\n\n    if (translatedItems.length === 1) {\n      // if (addon.data.env != \"development\")\n      const translatedItem = await globalItemFix(task.item, translatedItems[0]);\n      if (task.type == \"attachment\") {\n        task.item.parentID = translatedItem.id;\n      } else if (task.type == \"snapshot\") {\n        if (task.item.isTopLevelItem()) {\n          ztoolkit.log(\"Translate snapshot item for webpage item\");\n          const tmpJSON = translatedItem.toJSON();\n          task.item.fromJSON(tmpJSON);\n          await translatedItem.eraseTx();\n        } else {\n          ztoolkit.log(\"Translate snapshot attachment item\");\n          const oldParentItem = task.item.parentItem!;\n          const collectionIDs = oldParentItem.getCollections();\n          task.item.parentID = translatedItem.id;\n          // When parent item is erased, the attachment item will be erased. Set new parent item before the old parent will be earsed.\n          await task.item.saveTx();\n          await oldParentItem.eraseTx();\n          translatedItem.setCollections(collectionIDs);\n          await translatedItem.saveTx();\n        }\n      }\n      await task.item.saveTx();\n      task.status = \"success\";\n    } else if (translatedItems.length > 1) {\n      task.addMsg(\n        `Multiple items (${translatedItems.length}) translated, please check details.`,\n      );\n      task.status = \"fail\";\n    } else {\n      task.addMsg(\"Translation error\");\n      task.status = \"fail\";\n    }\n  } catch (e) {\n    task.addMsg(`ERROR: ${e}`);\n    task.status = \"fail\";\n  }\n}\n\n// Need to update data in item returned by translator.\nasync function globalItemFix(\n  oldItem: Zotero.Item,\n  newItem: Zotero.Item,\n): Promise<Zotero.Item> {\n  if (Zotero.Prefs.get(\"extensions.zotero.automaticTags\", true)) {\n    // Keyword tag type is automatic.\n    ztoolkit.log(\"update auto tags\");\n    newItem.setTags(\n      newItem.getTags().map((t: { tag: string; type?: number }) => ({\n        tag: t.tag,\n        type: 1,\n      })),\n    );\n  } else {\n    // Remove automatic tags\n    ztoolkit.log(\"remove all tags\");\n    newItem.removeAllTags();\n  }\n  // Preserve collections\n  oldItem.getCollections().forEach((cid) => newItem!.addToCollection(cid));\n  await newItem.saveTx();\n  return newItem;\n}\n"
  },
  {
    "path": "src/modules/services/pubscholar.ts",
    "content": "import { requestDocument } from \"../../utils/http\";\nimport { DocTools, text2HTMLDoc } from \"../../utils/http\";\nimport { ScraperTask } from \"../../utils/task\";\n\nconst BASE_URL = \"https://pubscholar.cn\";\n\n/**\n * Parse search results from PubScholar response.\n */\nfunction parseSearchResults(doc: Document): ScrapeSearchResult[] {\n  // TODO: Update selector based on actual PubScholar page structure\n  const resultRows = doc.querySelectorAll(\".result-item\");\n  if (resultRows.length === 0) {\n    ztoolkit.log(\"PubScholar: no items found.\");\n    return [];\n  }\n  return Array.from(resultRows).map((r) => {\n    const dt = new DocTools(r as HTMLElement);\n    // TODO: Update selectors to match PubScholar's HTML structure\n    const title = dt.innerText(\".result-title\") || \"\";\n    const url = dt.attr(\".result-title a\", \"href\") || \"\";\n    const author = dt.innerText(\".result-author\") || \"\";\n    const source = dt.innerText(\".result-source\") || \"\";\n    const date = dt.innerText(\".result-date\") || \"\";\n    return {\n      source: \"PubScholar\",\n      title: `${title} ${author} ${source} ${date}`,\n      url: url.startsWith(\"http\") ? url : `${BASE_URL}${url}`,\n      date: Zotero.Date.strToISO(date) || \"\",\n    };\n  });\n}\n\n/**\n * Build a Zotero item from PubScholar detail page metadata.\n */\nasync function createItemFromMetadata(\n  metadata: Record<string, string>,\n  libraryID: number,\n): Promise<Zotero.Item | null> {\n  // TODO: Map PubScholar metadata fields to Zotero item fields\n  // Example:\n  // const item = new Zotero.Item(\"journalArticle\");\n  // item.libraryID = libraryID;\n  // item.setField(\"title\", metadata.title);\n  // item.setField(\"date\", metadata.date);\n  // ...\n  // await item.saveTx();\n  // return item;\n  return null;\n}\n\nexport class PubScholar implements ScrapeService {\n  async search(\n    searchOption: SearchOption,\n  ): Promise<ScrapeSearchResult[] | null> {\n    ztoolkit.log(\"PubScholar search options: \", searchOption);\n\n    let query = searchOption.title;\n    if (searchOption.author) {\n      query += ` ${searchOption.author}`;\n    }\n\n    // TODO: Implement PubScholar search API call\n    // Step 1: Build search URL and parameters\n    const searchUrl = `${BASE_URL}/api/search`;\n    // Step 2: Send HTTP request\n    // const resp = await Zotero.HTTP.request(\"POST\", searchUrl, {\n    //   headers: {\n    //     \"Content-Type\": \"application/json\",\n    //     \"User-Agent\": \"Mozilla/5.0 ...\",\n    //   },\n    //   body: JSON.stringify({ q: query, page: 1, pageSize: 20 }),\n    //   timeout: 10000,\n    // });\n    // Step 3: Parse response\n    // const doc = text2HTMLDoc(resp.responseText);\n    // const results = parseSearchResults(doc);\n    // return results.length > 0 ? results : null;\n\n    return null;\n  }\n\n  async translate(\n    searchResult: ScrapeSearchResult,\n    libraryID: number,\n    saveAttachments: false,\n  ): Promise<Zotero.Item[]> {\n    ztoolkit.log(`PubScholar translate: ${searchResult.title}`);\n\n    // TODO: Implement PubScholar translation\n    // Strategy 1: Use Zotero Web Translator if a matching translator exists\n    // try {\n    //   const doc = await requestDocument(searchResult.url, {\n    //     headers: { ... },\n    //   });\n    //   const translator = new Zotero.Translate.Web();\n    //   translator.setTranslator(\"TRANSLATOR_ID\");\n    //   translator.setDocument(doc);\n    //   const items = await translator.translate({\n    //     libraryID: task.item.libraryID,\n    //     saveAttachments: saveAttachments,\n    //   });\n    //   if (items.length === 1) return items[0];\n    // } catch (e) {\n    //   ztoolkit.log(`PubScholar web translation failed: ${e}`);\n    // }\n\n    // Strategy 2: Fetch metadata from detail page and build item manually\n    // const metadata = await this.fetchDetailMetadata(searchResult.url);\n    // return createItemFromMetadata(metadata, task.item.libraryID);\n\n    return [];\n  }\n}\n"
  },
  {
    "path": "src/modules/services/yiigle.ts",
    "content": "import { compareTwoStrings } from \"string-similarity\";\nimport { DocTools, requestDocument } from \"../../utils/http\";\nconst { HiddenBrowser } = ChromeUtils.importESModule(\n  \"chrome://zotero/content/HiddenBrowser.mjs\",\n);\n\nexport class Yiigle implements ScrapeService {\n  async search(\n    searchOption: SearchOption,\n  ): Promise<ScrapeSearchResult[] | null> {\n    ztoolkit.log(\"Yiigle search started.\");\n    const url = `https://www.yiigle.com/Paper/Search?type=&q=${encodeURIComponent(searchOption.title)}&searchType=pt`;\n    ztoolkit.log(\"Yiigle search URL: \" + url);\n    // @ts-ignore not typed\n    const browser = new HiddenBrowser();\n    const extractArticleData = (node: HTMLElement): ScrapeSearchResult => {\n      const dt = new DocTools(node);\n      const title = dt.attr(\"a[title].el-link--default\", \"title\");\n\n      const url = dt.attr('a[href*=\"rs.yiigle.com/cmaid/\"]', \"href\");\n\n      // 3. 提取引用量（兼容PC/移动端DOM结构）\n      const citation = parseInt(dt.innerText(\"span > samp\", 2)) || 0;\n\n      // 4. 提取articleID（从URL末尾截取数字）\n      const articleIDMatch = url.match(/\\/(\\d+)$/); // 匹配 /xxx 最后一段的数字\n      const articleID = articleIDMatch ? articleIDMatch[1] : \"\";\n\n      // 期刊类型\n      const jtype = dt.innerText(\n        \"div.s_searchResult_li_top.el-row.el-row--flex > span.w_span.hidden-sm-and-down:not([style*='display: none'])\",\n        0,\n      );\n      // 作者等信息\n      const infoText = dt\n        .innerText(\"div.s_searchResult_li_author.el-row\", 0)\n        .replaceAll(\"\\n\", \"\");\n      // 返回标准化对象\n      const result: ScrapeSearchResult = {\n        source: \"中华医学\",\n        title: ` ${jtype} ${title} ${infoText}`,\n        url: url,\n        articleID: articleID,\n        articleTitle: title,\n      };\n      if (citation > 0) {\n        result.citation = citation;\n      }\n      return result;\n    };\n    try {\n      await browser.load(url);\n      await browser.waitForDocument({ allowInteractiveAfter: 5000 });\n      setTimeout(() => {\n        ztoolkit.log(\"1秒延迟到了！\");\n      }, 1000);\n      const doc = await browser.getDocument();\n      ztoolkit.log(`Yiigle search document title: ${doc.title}`);\n      const items = doc.querySelectorAll(\"div.s_searchResult_li.el-row\");\n      ztoolkit.log(`Yiigle search: found ${items.length} items.`, items);\n      if (items.length === 0) {\n        ztoolkit.log(\"Yiigle search: no results found.\");\n        return null;\n      } else {\n        return Array.from(items).map((item) =>\n          extractArticleData(item as HTMLElement),\n        );\n      }\n    } catch (error) {\n      ztoolkit.log(\"Yiigle search error: \" + error);\n    } finally {\n      browser.destroy();\n    }\n    return null;\n  }\n  async translate(\n    searchResult: ScrapeSearchResult,\n    libraryID: number,\n    saveAttachments: false,\n  ): Promise<Zotero.Item[]> {\n    ztoolkit.log(\"Yiigle translate started.\");\n    const doc = await requestDocument(searchResult.url, {\n      headers: {\n        Accept:\n          \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\n        \"Accept-Encoding\": \"gzip, deflate, br\",\n        \"Accept-Language\": \"zh-CN,en-US;q=0.9,en;q=0.8\",\n        Referer: \"https://www.yiigle.com/\",\n        \"User-Agent\":\n          \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0\",\n      },\n    });\n    ztoolkit.log(`Document title: ${doc.title}`);\n    const translator = new Zotero.Translate.Web();\n    translator.setTranslator(\"f5189d31-18ea-4e84-bdec-f1d0e75b818b\");\n    translator.setDocument(doc);\n    const translatedItems = await translator.translate({\n      libraryID: libraryID,\n      saveAttachments: saveAttachments,\n    });\n    return translatedItems;\n  }\n}\n"
  },
  {
    "path": "src/modules/styles.ts",
    "content": "import { get } from \"http\";\nimport { getString } from \"../utils/locale\";\nimport { findWindow, observeWindowLoad, waitElmLoaded } from \"../utils/window\";\nimport { setDashPattern } from \"pdf-lib\";\n\nfunction injectToDocument(doc: Document) {\n  const labelId = \"zotero-chinese-styles-link\";\n  // Already injected\n  if (doc.getElementById(labelId)) {\n    ztoolkit.log(\"Chinese styles link already injected\");\n    return;\n  }\n  function injectToParent() {\n    // ztoolkit.log(\"Injecting Chinese styles link to preferences\");\n    waitElmLoaded(doc, \"#styleManager-buttons\", 8000).then(() => {\n      //   ztoolkit.log(\"Preferences loaded, injecting link\");\n      const firstChild = doc.querySelector<HTMLElement>(\n        \"#styleManager-buttons > :nth-child(1)\",\n      );\n      const secondChild = doc.querySelector<HTMLElement>(\n        \"#styleManager-buttons > :nth-child(2)\",\n      );\n      //   ztoolkit.log(firstChild?.tagName);\n      if (!firstChild || !secondChild) return;\n      if (firstChild.tagName === \"button\") {\n        const hbox_copy = secondChild\n          .querySelector(\"hbox\")!\n          .cloneNode(true) as HTMLElement;\n        hbox_copy\n          .querySelector(\"label\")!\n          .setAttribute(\"value\", getString(\"get-Chinese-styles\"));\n        const button = doc.createElement(\"button\");\n        button.style.padding = \"0px\";\n        button.id = labelId;\n        button.setAttribute(\"label\", getString(\"get-Chinese-styles\"));\n        button.addEventListener(\"click\", function (event) {\n          Zotero.launchURL(\"https://zotero-chinese.com/styles/\");\n          event.preventDefault();\n        });\n        button.appendChild(hbox_copy);\n        secondChild.insertAdjacentElement(\"beforebegin\", button);\n      } else if (firstChild.tagName === \"label\") {\n        // For Zotero 7\n        const label = doc.createElement(\"label\");\n        label.id = labelId;\n        label.classList.add(\"zotero-text-link\");\n        label.setAttribute(\"is\", \"zotero-text-link\");\n        label.setAttribute(\"role\", \"link\");\n        label.textContent = getString(\"get-Chinese-styles\");\n        label.addEventListener(\"click\", function (event) {\n          Zotero.launchURL(\"https://zotero-chinese.com/styles/\");\n          event.preventDefault();\n        });\n        firstChild.removeAttribute(\"flex\");\n        firstChild.style.marginRight = \"12px\";\n        firstChild.insertAdjacentElement(\"afterend\", label);\n      }\n      ztoolkit.log(\"Chinese styles link injected\");\n    });\n  }\n  const isCitePaneSelected = doc.querySelector(\n    \"richlistitem[value='zotero-prefpane-cite'][selected='true']\",\n  );\n  // If cite pane is selected, insert immediately\n  if (isCitePaneSelected) {\n    injectToParent();\n  } else {\n    const navigation = doc.getElementById(\"prefs-navigation\");\n    if (!navigation) return;\n    function onSelect(event: Event) {\n      // Inject link only one time in window lifetime\n      navigation!.removeEventListener(\"select\", onSelect);\n      injectToParent();\n    }\n    navigation.addEventListener(\"select\", onSelect);\n  }\n}\n\n/**\n * Inject a link to the Chinese styles page into the preferences window.\n */\nexport async function injectStylesLink() {\n  const prefsUri = \"chrome://zotero/content/preferences/preferences.xhtml\";\n  const existingWindow = findWindow(prefsUri);\n  if (existingWindow) {\n    injectToDocument(existingWindow.document);\n  }\n  // Wait for preference window loaded next time\n  observeWindowLoad(prefsUri, (win) => injectToDocument(win.document));\n}\n"
  },
  {
    "path": "src/modules/tools.ts",
    "content": "import { config } from \"../../package.json\";\nimport { isChineseTopItem } from \"./../utils/detect\";\nimport { getString } from \"../utils/locale\";\nimport { getPref } from \"../utils/prefs\";\nimport { CNKI } from \"../modules/services/cnki\";\nimport { findAttachmentsInFolder } from \"./attachments/localMatch\";\nimport { actionAfterImport } from \"./attachments\";\n\n// 中国稀有姓氏统计小组发布于小红书ID4975028282\n// https://www.xiaohongshu.com/discovery/item/67c017cb000000001203db3d\nconst compoundSurnames = [\n  /* A */\n  \"奥屯\",\n  /* B */\n  \"百里\",\n  \"比干\",\n  \"单于\",\n  /* C */\n  \"陈留\",\n  \"成公\",\n  \"成功\",\n  \"叱干\",\n  \"褚师\",\n  \"淳于\",\n  /* D */\n  \"达奚\",\n  \"第二\",\n  \"第五\",\n  \"第伍\",\n  \"第一\",\n  \"丁若\",\n  \"东方\",\n  \"东里\",\n  \"东门\",\n  \"东野\",\n  \"豆卢\",\n  \"独孤\",\n  \"端木\",\n  \"段干\",\n  /* E */\n  \"尔朱\",\n  /* F */\n  \"伏羲\",\n  \"状阳\",\n  \"傅阳\",\n  /* G */\n  \"高堂\",\n  \"高阳\",\n  \"哥舒\",\n  \"葛天\",\n  \"公乘\",\n  \"公上\",\n  \"公孙\",\n  \"公羊\",\n  \"公冶\",\n  \"共工\",\n  \"古野\",\n  \"关龙\",\n  \"毌丘\",\n  /* H */\n  \"韩城\",\n  \"贺兰\",\n  \"贺楼\",\n  \"贺若\",\n  \"赫连\",\n  \"呼延\",\n  \"胡母\",\n  \"胡毋\",\n  \"斛律\",\n  \"华原\",\n  \"皇甫\",\n  \"皇父\",\n  /* K */\n  \"可汗\",\n  /* J */\n  \"即墨\",\n  \"夹谷\",\n  \"揭阳\",\n  /* L */\n  \"令狐\",\n  \"闾丘\",\n  \"闾邱\",\n  /* M */\n  \"马服\",\n  \"万矣\",\n  \"墨台\",\n  \"默台\",\n  \"母丘\",\n  \"木易\",\n  \"慕容\",\n  /* N */\n  \"南宫\",\n  \"南门\",\n  \"女娲\",\n  /* O */\n  \"欧侯\",\n  \"欧阳\",\n  /* P */\n  \"濮阳\",\n  \"蒲察\",\n  /* Q */\n  \"漆雕\",\n  \"亓官\",\n  \"綦连\",\n  \"綦毋\",\n  \"气伏\",\n  \"青阳\",\n  \"屈男\",\n  \"屈突\",\n  /* S */\n  \"上官\",\n  \"申徒\",\n  \"申屠\",\n  \"石抹\",\n  \"士孙\",\n  \"侍其\",\n  \"水丘\",\n  \"司城\",\n  \"司空\",\n  \"司寇\",\n  \"司马\",\n  \"司徒\",\n  \"司星\",\n  \"澹台\",\n  /* T */\n  \"拓跋\",\n  \"太史\",\n  \"太叔\",\n  \"徒单\",\n  \"涂山\",\n  \"脱脱\",\n  /* W */\n  \"完颜\",\n  \"闻人\",\n  \"武城\",\n  \"毋丘\",\n  /* X */\n  \"西门\",\n  \"夏侯\",\n  \"夏后\",\n  \"鲜于\",\n  \"相里\",\n  \"轩辕\",\n  /* Y */\n  \"延陵\",\n  \"羊舌\",\n  \"耶律\",\n  \"宇文\",\n  \"尉迟\",\n  \"乐正\",\n  /* Z */\n  \"宰父\",\n  \"长孙\",\n  \"钟离\",\n  \"诸葛\",\n  \"术虎\",\n  \"主父\",\n  \"祝融\",\n  \"颛孙\",\n  \"颛项\",\n  \"子车\",\n  \"宗正\",\n  \"宗政\",\n  /* 璧联姓 */\n  \"邓李\",\n  \"刘付\",\n  \"陆费\",\n  \"吴刘\",\n];\n\nexport async function splitName(item: Zotero.Item): Promise<void> {\n  const creators = item.getCreators();\n  for (const creator of creators) {\n    if (creator.fieldMode === 0 && creator.firstName !== \"\") continue;\n    if (\n      /\\p{Unified_Ideograph}/u.test(`${creator.lastName}${creator.firstName}`)\n    ) {\n      const fullName = creator.lastName;\n      const surname = compoundSurnames.find((surname) =>\n        creator.lastName.startsWith(surname),\n      );\n      if (fullName.includes(\"·\")) {\n        const nameParts = fullName.split(\"·\");\n        creator.lastName = nameParts.shift()!;\n        creator.firstName = nameParts.join(\"·\");\n      } else if (surname) {\n        creator.lastName = surname;\n        creator.firstName = fullName.slice(surname.length);\n      } else {\n        creator.lastName = fullName.charAt(0);\n        creator.firstName = fullName.slice(1);\n      }\n      creator.fieldMode = 0;\n    } else if (getPref(\"splitEnName\") && /[a-z]/i.test(creator.lastName)) {\n      const nameParts = creator.lastName.split(/\\s+/g);\n      if (nameParts.length > 1) {\n        creator.lastName = nameParts.pop()!;\n        creator.firstName = nameParts.join(\" \");\n        creator.fieldMode = 0;\n      }\n    }\n  }\n  item.setCreators(creators);\n  await item.saveTx();\n}\n\nexport async function mergeName(item: Zotero.Item): Promise<void> {\n  const creators = item.getCreators();\n  for (const creator of creators) {\n    if (\n      /\\p{Unified_Ideograph}/u.test(`${creator.firstName}${creator.lastName}`)\n    ) {\n      if (\n        // Chinese Name in One field.\n        creator.fieldMode === 1 &&\n        creator.lastName.length - 2 === creator.lastName.indexOf(\" \")\n      ) {\n        creator.lastName = creator.lastName.split(\" \").reverse().join(\"\");\n      } else {\n        // 由于拆分后信息丢失，难以判断少数民族的姓氏，这里的条件是充分不必要的\n        const delimiter = creator.firstName.includes(\"·\") ? \"·\" : \"\";\n        creator.lastName = `${creator.lastName}${delimiter}${creator.firstName}`;\n      }\n      creator.firstName = \"\";\n      creator.fieldMode = 1;\n    } else if (getPref(\"splitEnName\") && /[a-z]/i.test(creator.lastName)) {\n      creator.lastName = `${creator.firstName} ${creator.lastName}`.trimStart();\n      creator.firstName = \"\";\n      creator.fieldMode = 1;\n    }\n  }\n  item.setCreators(creators);\n  await item.saveTx();\n}\n\nexport async function getCNKICite(item: Zotero.Item): Promise<string> {\n  const cnki = new CNKI();\n  const searchOption = {\n    title: item.getField(\"title\"),\n    author: item.getCreators()[0].lastName + item.getCreators()[0].firstName,\n  };\n  let cite = \"\";\n  const searchResults = await cnki.search(searchOption);\n  if (searchResults && searchResults.length > 0) {\n    cite = searchResults[0].citation as string;\n    ztoolkit.log(`CNKI citation: ${cite}`);\n    if (cite) {\n      ztoolkit.ExtraField.setExtraField(item, \"CNKICite\", cite);\n    }\n  }\n  return cite;\n}\n\nexport async function updateCNKICite(items: Zotero.Item[]) {\n  const items2 = items.filter((i) => isChineseTopItem(i));\n  if (items2.length > 0) {\n    let popupWin;\n    for (let i = 0; i < items2.length; i++) {\n      const cite = await getCNKICite(items2[i]);\n      if (i == 0) {\n        popupWin = new ztoolkit.ProgressWindow(config.addonName, {\n          closeOnClick: true,\n          closeTime: 1500,\n        })\n          .createLine({\n            text: `${getString(\"citation\")}:${cite ? cite : \"0\"} ${items[i].getField(\"title\")}`,\n            type: \"default\",\n            icon: `chrome://${config.addonRef}/content/icons/cite.png`,\n          })\n          .show();\n      } else {\n        popupWin?.changeLine({\n          text: `${getString(\"citation\")}:${cite ? cite : \"0\"} ${items[i].getField(\"title\")}`,\n          type: \"default\",\n          icon: `chrome://${config.addonRef}/content/icons/cite.png`,\n        });\n      }\n    }\n  } else {\n    ztoolkit.log(\"No Chinese items to update citation.\");\n    new ztoolkit.ProgressWindow(config.addonName, {\n      closeOnClick: true,\n      closeTime: 3500,\n    })\n      .createLine({\n        text: getString(\"no-chinese-item-for-citation\"),\n        type: \"default\",\n        icon: `chrome://${config.addonRef}/content/icons/cite.png`,\n      })\n      .show();\n  }\n}\n\nasync function renameAttachmentFromParent(attachmentItem: Zotero.Item) {\n  if (\n    !attachmentItem.isAttachment() ||\n    attachmentItem.isTopLevelItem() ||\n    attachmentItem.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_URL\n  ) {\n    throw `Item ${attachmentItem.id} is not a child file attachment in ZoteroPane_Local.renameAttachmentFromParent()`;\n  }\n\n  const filePath = await attachmentItem.getFilePathAsync();\n  if (!filePath) return;\n\n  const parentItemID = attachmentItem.parentItemID as number;\n  const parentItem = await Zotero.Items.getAsync(parentItemID);\n  let newName = Zotero.Attachments.getFileBaseNameFromItem(parentItem);\n\n  const extRE = /\\.[^.]+$/;\n  const origFilename = PathUtils.filename(filePath);\n  const ext = origFilename.match(extRE);\n  if (ext) {\n    newName = newName + ext[0];\n  }\n  const origFilenameNoExt = origFilename.replace(extRE, \"\");\n\n  const renamed = await attachmentItem.renameAttachmentFile(\n    newName,\n    false,\n    true,\n  );\n  if (renamed !== true) {\n    ztoolkit.log(`Could not rename file (${renamed})`);\n  }\n\n  // If the attachment title matched the filename, change it now\n  const origTitle = attachmentItem.getField(\"title\");\n  if ([origFilename, origFilenameNoExt].includes(origTitle)) {\n    attachmentItem.setField(\"title\", newName);\n    await attachmentItem.saveTx();\n  }\n}\n\nexport async function importAttachmentsFromFolder(): Promise<void> {\n  let msgType = \"default\";\n  let msg: string = \"\";\n  if (addon.data.isImportingAttachments) {\n    Zotero.getMainWindow().alert(getString(\"importing-attachments-is-running\"));\n    return;\n  }\n  try {\n    const folder = getPref(\"pdfMatchFolder\");\n    const collectionID =\n      Zotero.getActiveZoteroPane().getSelectedCollection()!.id;\n    const attachmentFilenames = await findAttachmentsInFolder(folder);\n    ztoolkit.log(collectionID, attachmentFilenames);\n    if (attachmentFilenames.length === 0) {\n      msg = getString(\"no-attachments-found\");\n      msgType = \"success\";\n    } else {\n      for (const filename of attachmentFilenames) {\n        const importOptions: _ZoteroTypes.Attachments.OptionsFromFile = {\n          collections: [collectionID],\n          file: filename,\n        };\n        await Zotero.Attachments.importFromFile(importOptions);\n        ztoolkit.log(`${filename} imported.`);\n        await actionAfterImport(filename);\n      }\n      msg = getString(\"import-attachments-success\");\n      msgType = \"success\";\n    }\n  } catch (e) {\n    ztoolkit.log(e);\n    msg = String(e);\n    msgType = \"fail\";\n  } finally {\n    addon.data.isImportingAttachments = false;\n    new ztoolkit.ProgressWindow(config.addonName, {\n      closeOnClick: true,\n      closeTime: 1500,\n    })\n      .createLine({\n        text: msg,\n        type: msgType,\n        icon: `chrome://${config.addonRef}/content/icons/icon.png`,\n      })\n      .show();\n  }\n}\n\n/**\n * 分类或选中的条目查找附件，从本地或远程下载。\n * 注意，此处会过滤掉已有附件的条目。\n * TODO: Exclude some attachment file types.\n */\nexport async function handleAttachmentMenu(menuType: \"collection\" | \"item\") {\n  let selectedItems: Zotero.Item[] = [];\n  if (menuType === \"item\") {\n    selectedItems = Zotero.getActiveZoteroPane().getSelectedItems();\n  } else if (menuType === \"collection\") {\n    const collectionID =\n      Zotero.getActiveZoteroPane().getSelectedCollection()?.id;\n    if (!collectionID) return;\n    selectedItems = Zotero.Collections.get(collectionID).getChildItems();\n  } else {\n    return;\n  }\n\n  const targetItemTypes = [\n    \"journalArticle\",\n    \"thesis\",\n    \"book\",\n    \"bookSection\",\n    \"conferencePaper\",\n    \"report\",\n    \"patent\",\n  ];\n  const noAttachmentItems = selectedItems.filter((item) => {\n    if (!item.isRegularItem() || !targetItemTypes.includes(item.itemType))\n      return false;\n    // Exclude snapshot attachments\n    const aItems = item\n      .getAttachments()\n      .filter((i) => !Zotero.Items.get(i).isSnapshotAttachment());\n    ztoolkit.log(aItems);\n    return aItems.length === 0;\n  });\n  if (noAttachmentItems.length === 0) {\n    new ztoolkit.ProgressWindow(config.addonName, {\n      closeOnClick: true,\n      closeTime: 1500,\n    })\n      .createLine({\n        text: getString(\"no-item-need-attachment\"),\n        type: \"default\",\n        icon: `chrome://${config.addonRef}/content/icons/cite.png`,\n      })\n      .show();\n  } else {\n    for (const item of noAttachmentItems) {\n      await addon.taskRunner.createAndAddTask(item, \"local\");\n    }\n  }\n}\n"
  },
  {
    "path": "src/modules/translators.ts",
    "content": "import { getString } from \"../utils/locale\";\nimport { getPref, setPref } from \"../utils/prefs\";\n\nexport async function bestSpeedBaseUrl() {\n  const baseUrls = [\n    \"https://oss.wwang.de/translators_CN\",\n    \"https://www.wieke.cn/translators_CN\",\n    \"https://ftp.zotero-chinese.com/translators_CN\",\n  ];\n\n  const testUrl = async (\n    url: string,\n  ): Promise<{ url: string; time: number }> => {\n    const startTime = Date.now();\n    try {\n      await Zotero.HTTP.request(\"HEAD\", `${url}/data/translators.json`, {\n        timeout: 5000,\n      });\n      const time = Date.now() - startTime;\n      ztoolkit.log(`${url} response time: ${time}ms`);\n      return { url, time };\n    } catch (error) {\n      ztoolkit.log(`${url} request failed: ${error}`);\n      return { url, time: Infinity };\n    }\n  };\n\n  const results = await Promise.all(baseUrls.map(testUrl));\n  const fastest = results.reduce((prev, curr) =>\n    curr.time < prev.time ? curr : prev,\n  );\n\n  ztoolkit.log(`use fastest base url: ${fastest.url} (${fastest.time}ms)`);\n  return fastest.url;\n}\n\n/**\n * Get lastUpdated time from translator file\n * @param filename translator filename with extension\n * @returns lastUpdated time or false if failed\n */\nexport async function getLastUpdatedFromFile(\n  filename: string,\n): Promise<string | false> {\n  const desPath = PathUtils.join(\n    Zotero.DataDirectory.dir,\n    \"translators\",\n    filename,\n  );\n  const isFileExist = await IOUtils.exists(desPath);\n  if (isFileExist === false) {\n    ztoolkit.log(`get lastUpdated from file ${desPath} failed: file not exist`);\n    return false;\n  }\n  try {\n    // Assert source is a string in try block\n    const source = (await Zotero.File.getContentsAsync(desPath)) as string;\n    const infoRe = /^\\s*{[\\S\\s]*?}\\s*?[\\r\\n]/;\n    const metaData = JSON.parse(infoRe.exec(source)![0]);\n    ztoolkit.log(\n      `get lastUpdated from file ${desPath}: ${metaData.lastUpdated}`,\n    );\n    return metaData.lastUpdated;\n  } catch (error) {\n    ztoolkit.log(`get lastUpdated from file ${desPath} failed: ${error}`);\n    return false;\n  }\n}\n\nexport async function getLastUpdatedMap(\n  refresh = true,\n): Promise<LastUpdatedMap> {\n  const cachePath = PathUtils.join(\n    Zotero.DataDirectory.dir,\n    \"translators_CN.json\",\n  );\n\n  if (refresh === false && (await IOUtils.exists(cachePath))) {\n    const contents = await Zotero.File.getContentsAsync(cachePath, \"utf8\");\n    ztoolkit.log(`translator data has been loaded from cache: ${cachePath}`);\n    return JSON.parse(contents as string);\n  }\n  try {\n    const baseUrl = getPref(\"translatorSource\");\n    const contents = await Zotero.File.getContentsFromURLAsync(\n      `${baseUrl}/data/translators.json`,\n    );\n    ztoolkit.log(`translator data has been loaded from remote: ${baseUrl}`);\n    await Zotero.File.putContentsAsync(cachePath, contents);\n    return JSON.parse(contents);\n  } catch (event) {\n    ztoolkit.log(`getTranslatorsData failed: ${event}`);\n    return {};\n  }\n}\n\nasync function mendTranslators() {\n  // Detect Endnote XML translator, if it's missing, it means the translators are broken, try to reset them.\n  // Return False if missing.\n  const endNoteTranslator = await Zotero.Translators.get(\n    \"eb7059a4-35ec-4961-a915-3cf58eb9784b\",\n  );\n  // 727 is the number of translators at the time of writing\n  if (\n    !getPref(\"firstRun\") &&\n    !getPref(\"translatorsMended\") &&\n    !endNoteTranslator\n  ) {\n    ztoolkit.log(\n      \"jasminum has been installed, and translators seems to be missing, try to reset them\",\n    );\n    const reset = await Zotero.Schema.resetTranslators();\n    ztoolkit.log(`reset translators ${reset ? \"successfully\" : \"failed\"}`);\n    setPref(\"translatorsMended\", true);\n  }\n}\n\n/**\n * Download outdated translators from the source, with 12 hours interval by default.\n *\n * TODO: Download error when file is read-only in windows.\n * @param force Whether ignore the time interval and force to download\n */\nexport async function updateTranslators(force = false): Promise<boolean> {\n  if (addon.data.translators.updating) {\n    ztoolkit.log(\"translators are updating, skip this update\");\n    return false;\n  }\n  try {\n    addon.data.translators.updating = true;\n    return await _updateTranslators(force);\n  } catch (error) {\n    return false;\n  } finally {\n    addon.data.translators.updating = false;\n  }\n}\n\nasync function _updateTranslators(force = false): Promise<boolean> {\n  await Zotero.Schema.schemaUpdatePromise;\n  await mendTranslators();\n  let needUpdate = false;\n  const lastUpdateTime = parseInt(getPref(\"translatorUpdateTime\"));\n  const now = Date.now();\n  if (force == true || lastUpdateTime === undefined) {\n    ztoolkit.log(\n      `need to update translators, force: ${force}, lastUpdateTime: ${lastUpdateTime}`,\n    );\n    needUpdate = true;\n  } else {\n    if (now - lastUpdateTime > 1000 * 60 * 60 * 12) {\n      ztoolkit.log(\n        \"need to update translators, it has been over 12 hours since the last update\",\n      );\n      needUpdate = true;\n    } else {\n      ztoolkit.log(\n        \"no need to update translators, it has been less than 12 hours since the last update\",\n      );\n    }\n  }\n\n  if (needUpdate === false) return false;\n\n  const translatorData = await getLastUpdatedMap(needUpdate);\n  const baseUrl = getPref(\"translatorSource\");\n  ztoolkit.log(`update translators from base: ${baseUrl}`);\n  const popupWin = new ztoolkit.ProgressWindow(getString(\"plugin-name\"), {\n    closeOnClick: true,\n    closeTime: -1,\n  })\n    .createLine({\n      text: getString(\"update-translators-start\"),\n      type: \"default\",\n      progress: 0,\n    })\n    .show();\n  const progressStep = 100 / Object.keys(translatorData).length;\n  let progress = 0;\n  let successCounts = 0;\n  let skipCounts = 0;\n  let failCounts = 0;\n  const translatorUpdateTasks = Object.keys(translatorData).map(\n    async (filename) => {\n      let type = \"default\",\n        text = \"\";\n      const localUpdateTime = await getLastUpdatedFromFile(filename);\n      const remoteUpdateTime = translatorData[filename].lastUpdated;\n      if (\n        localUpdateTime === false ||\n        new Date(remoteUpdateTime) > new Date(localUpdateTime)\n      ) {\n        try {\n          const url = `${baseUrl}/${filename}`;\n          const code = await Zotero.File.getContentsFromURLAsync(url);\n          const desPath = PathUtils.join(\n            Zotero.DataDirectory.dir,\n            \"translators\",\n            filename,\n          );\n          await IOUtils.writeUTF8(desPath, code);\n          type = \"success\";\n          text = getString(\"update-successfully\", {\n            args: { name: filename },\n          });\n          successCounts += 1;\n        } catch (error) {\n          type = \"fail\";\n          text = getString(\"update-failed\", {\n            args: { name: filename },\n          });\n          failCounts += 1;\n          ztoolkit.log(`update translator ${filename} failed: ${error}`);\n        }\n      } else {\n        skipCounts += 1;\n        type = \"default\";\n        text = getString(\"update-skipped\", {\n          args: { name: filename },\n        });\n        ztoolkit.log(`translator ${filename} is already up to date, skipped`);\n      }\n      progress += progressStep;\n      popupWin.changeLine({\n        type,\n        text,\n        progress,\n      });\n    },\n  );\n  await Promise.all(translatorUpdateTasks);\n  // @ts-ignore Translators is missing\n  await Zotero.Translators.reinit({ fromSchemaUpdate: false });\n  setPref(\"translatorUpdateTime\", now.toString());\n  popupWin.changeLine({\n    text: getString(\"update-translators-complete\", {\n      args: { successCounts, failCounts, skipCounts },\n    }),\n    type: \"default\",\n    progress: 100,\n  });\n  popupWin.startCloseTimer(3000);\n  ztoolkit.log(\n    `translators updated at ${new Date(now)}, success: ${successCounts}, skip: ${skipCounts}, fail: ${failCounts}`,\n  );\n  return true;\n}\n"
  },
  {
    "path": "src/modules/workers/index.ts",
    "content": "import { test, addOutlineToPDF } from \"./outline\";\n\nself.onmessage = async (e) => {\n  console.log(\"Minimal Worker收到:\", e.data);\n  const data = e.data;\n  if (data && data.action === \"test\") {\n    const result = test(data.title);\n    self.postMessage({\n      action: \"testReturn\",\n      jobID: data.jobID,\n      status: \"success\",\n      result,\n    });\n  } else if (data && data.action === \"addOutline\") {\n    const { filePath, outlineNodes } = data;\n    await addOutlineToPDF(filePath, outlineNodes);\n    self.postMessage({\n      action: \"addOutlineReturn\",\n      jobID: data.jobID,\n      status: \"success\",\n    });\n  }\n};\n"
  },
  {
    "path": "src/modules/workers/outline.ts",
    "content": "import {\n  PDFArray,\n  PDFDict,\n  PDFDocument,\n  PDFHexString,\n  PDFName,\n  PDFNull,\n  PDFNumber,\n  PDFPageLeaf,\n  PDFRef,\n} from \"pdf-lib\";\n\nexport function test(title: string) {\n  const startTimestamp = Date.now();\n  let result = title;\n  for (let i = 0; i < 100; i++) {\n    result = result\n      .split(\"\")\n      .map((c) => String.fromCharCode(c.charCodeAt(0) + 1))\n      .join(\"\");\n  }\n  const endTimestamp = Date.now();\n  const time = endTimestamp - startTimestamp;\n  return { result, time };\n}\n\nfunction prepareData(\n  outlineNodes: OutlineNode[],\n  pdfDoc: PDFDocument,\n): [OutlineNode[], number] {\n  let counts = 0;\n  outlineNodes.forEach((node: OutlineNode) => {\n    node.ref = pdfDoc.context.nextRef();\n    if (node.children && node.children.length > 0) {\n      node.children = prepareData(node.children, pdfDoc)[0];\n      counts = counts + 1;\n    }\n  });\n  return [outlineNodes, counts];\n}\n\nfunction createOutlineItem(\n  pdfDoc: PDFDocument,\n  node: OutlineNode,\n  parentRef: PDFRef,\n  prev: PDFRef | null,\n  next: PDFRef | null,\n  page: PDFRef,\n) {\n  const outlineItemDictMap = new Map();\n  outlineItemDictMap.set(PDFName.Title, PDFHexString.fromText(node.title));\n  outlineItemDictMap.set(PDFName.Parent, parentRef);\n\n  if (node.children && node.children.length > 0) {\n    outlineItemDictMap.set(PDFName.of(\"First\"), node.children[0].ref);\n    outlineItemDictMap.set(\n      PDFName.of(\"Last\"),\n      node.children[node.children.length - 1].ref,\n    );\n    outlineItemDictMap.set(\n      PDFName.of(\"Count\"),\n      PDFNumber.of(node.children.length),\n    );\n  }\n\n  if (prev != null) {\n    outlineItemDictMap.set(PDFName.of(\"Prev\"), prev);\n  }\n  if (next != null) {\n    outlineItemDictMap.set(PDFName.of(\"Next\"), next);\n  }\n  // Set the destination\n  const array = PDFArray.withContext(pdfDoc.context);\n  array.push(page);\n  array.push(PDFName.of(\"XYZ\"));\n  array.push(PDFNumber.of(node.x)); // X\n  array.push(PDFNumber.of(node.y)); // Y\n  array.push(PDFNull); // Zoom\n  outlineItemDictMap.set(PDFName.of(\"Dest\"), array);\n  const outlineItem = PDFDict.fromMapWithContext(\n    outlineItemDictMap,\n    pdfDoc.context,\n  );\n  pdfDoc.context.assign(node.ref, outlineItem);\n  console.log(`Outline item dict: ${node.level}, ${node.title}`);\n}\n\nfunction createOutlineDict(\n  outlineNodes: OutlineNode[],\n  counts: number,\n  pdfDoc: PDFDocument,\n): PDFDict {\n  const outlinesDictMap = new Map();\n  outlinesDictMap.set(PDFName.Type, PDFName.of(\"Outlines\"));\n  outlinesDictMap.set(PDFName.of(\"First\"), outlineNodes[0].ref!);\n  outlinesDictMap.set(\n    PDFName.of(\"Last\"),\n    outlineNodes[outlineNodes.length - 1].ref!,\n  );\n  outlinesDictMap.set(PDFName.of(\"Count\"), PDFNumber.of(counts));\n  return PDFDict.fromMapWithContext(outlinesDictMap, pdfDoc.context);\n}\n\nexport async function addOutlineToPDF(\n  pdfPath: string,\n  outlineNodes: OutlineNode[],\n) {\n  const pdfBytes = await IOUtils.read(pdfPath);\n  const pdfDoc = await PDFDocument.load(pdfBytes);\n  // PDF Page reference\n  const pageRefs: PDFRef[] = [];\n  pdfDoc.catalog.Pages().traverse((kid, ref) => {\n    if (kid instanceof PDFPageLeaf) pageRefs.push(ref);\n  });\n\n  const rootRef = pdfDoc.context.nextRef();\n  const [preparedOutlineNodes, totalCounts] = prepareData(outlineNodes, pdfDoc);\n  // Create outline item dict\n  const outlinesDict = createOutlineDict(\n    preparedOutlineNodes,\n    totalCounts,\n    pdfDoc,\n  );\n  //Pointing the \"Outlines\" property of the PDF's \"Catalog\" to the first object of your outlines\n  pdfDoc.catalog.set(PDFName.of(\"Outlines\"), rootRef);\n  //First 'Outline' object. Refer to table H.3 in Annex H.6 of PDF Specification doc.\n  pdfDoc.context.assign(rootRef, outlinesDict);\n\n  console.log(\"Prepared outline nodes: \", preparedOutlineNodes);\n  // Add outline item dict\n  const loop = (nodes: OutlineNode[]) => {\n    nodes.forEach((node: OutlineNode, idx: number) => {\n      // Create outline item dict\n      createOutlineItem(\n        pdfDoc,\n        node,\n        node.level === 1 ? rootRef : node.ref,\n        idx > 0 ? nodes[idx - 1].ref : null,\n        idx < nodes.length - 1 ? nodes[idx + 1].ref : null,\n        pageRefs[node.page - 1],\n      );\n      if (node.children && node.children.length > 0) {\n        const children = node.children;\n        loop(children);\n      }\n    });\n  };\n\n  loop(preparedOutlineNodes);\n\n  const pdfBytesWithOutline = await pdfDoc.save();\n  await IOUtils.write(pdfPath, pdfBytesWithOutline);\n  console.log(\"Add outline to pdf complete.\");\n}\n"
  },
  {
    "path": "src/modules/wps.ts",
    "content": "async function unZip(filename: string, outDir: string) {\n  ztoolkit.log(outDir, filename);\n  const zipFile = Zotero.File.pathToFile(filename);\n  // @ts-ignore -- Not typed.\n  const zipReader = Components.classes[\n    \"@mozilla.org/libjar/zip-reader;1\"\n  ].createInstance(Components.interfaces.nsIZipReader);\n  zipReader.open(zipFile);\n  // Extract files\n  const entries = zipReader.findEntries(\"*\");\n  const subfolders = new Set<string>();\n  const entryFiles: any = {};\n  while (entries.hasMore()) {\n    const entry = entries.getNext();\n    // Unix Mac Windows, path seperator.\n    const pathParts = entry.split(/[/\\\\]/);\n    if (pathParts.length > 1)\n      subfolders.add(PathUtils.join(outDir, pathParts.slice(0, -1)));\n    if (entry.endsWith(\"/\") || entry.endsWith(\"\\\\\")) {\n      continue;\n    }\n    entryFiles[entry] = PathUtils.join(outDir, pathParts);\n  }\n  for (const e of subfolders) {\n    ztoolkit.log(\"Create subfolder: \" + e);\n    await IOUtils.makeDirectory(e, { ignoreExisting: true });\n    ztoolkit.log(`${await IOUtils.exists(e)}`);\n  }\n\n  Object.keys(entryFiles).forEach((e) => {\n    ztoolkit.log(e, entryFiles[e]);\n    zipReader.extract(e, Zotero.File.pathToFile(entryFiles[e]));\n  });\n\n  zipReader.close();\n}\n\nexport async function downloadWpsPlugin() {\n  const baseDir = PathUtils.join(Zotero.DataDirectory.dir, \"jasminum\");\n  const wpsFolder = PathUtils.join(baseDir, \"wps\");\n  const unzipFolder = PathUtils.join(wpsFolder, \"unzip\");\n  const zipFilename = PathUtils.join(wpsFolder, \"wps.zip\");\n  await IOUtils.makeDirectory(unzipFolder, {\n    ignoreExisting: true,\n    createAncestors: true,\n  });\n  const wpsUrl = \"https://ftp.linxingzhong.top/\";\n  const tmpContent = await Zotero.File.getContentsFromURLAsync(wpsUrl);\n  await Zotero.File.putContentsAsync(zipFilename, tmpContent);\n  ztoolkit.log(\"WPS plugins download complete\");\n  await unZip(zipFilename, unzipFolder);\n  ztoolkit.log(\"Unzip completed. \" + unzipFolder);\n}\n\nexport async function installWpsPlugin() {\n  let runStatus: true | Error;\n  if (Zotero.isWin) {\n    runStatus = await Zotero.Utilities.Internal.exec(\"安装.exe\", []);\n  } else {\n    runStatus = await Zotero.Utilities.Internal.exec(\"python\", [\"install.py\"]);\n  }\n\n  if (runStatus == true) {\n    ztoolkit.log(\"Install completed.\");\n  } else {\n    ztoolkit.log(\"Install errors\", runStatus);\n  }\n}\n"
  },
  {
    "path": "src/utils/cookiebox.ts",
    "content": "export class MyCookieSandbox {\n  public searchCookieBox: Zotero.CookieSandbox | null = null;\n  //   public attachmentCookieBox: Zotero.CookieSandbox | null = null;\n  //   public refCookieBox: Zotero.CookieSandbox | null = null;\n  userAgent =\n    \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36\";\n  baseUrl = \"https://www.cnki.net\";\n\n  private _CNKIHomeCookieBox: Zotero.CookieSandbox | null = null;\n  private _cnkiHomeCookieLastUpdateTime: number = 0;\n  private _initPromise: Promise<void> | null = null;\n  private _captchaPromise: Promise<Zotero.CookieSandbox> | null = null;\n  private static readonly COOKIE_EXPIRE_MS = 5 * 60 * 1000; // 10 minutes\n\n  constructor() {\n    this._CNKIHomeCookieBox = null;\n  }\n\n  public async getCookieBoxFromUrl(\n    url: string,\n    hintText: string = \"请完成验证码，验证成功后，点击此按钮\",\n  ): Promise<Zotero.CookieSandbox> {\n    // @ts-ignore - Not typed.\n    const cookieSandbox = new Zotero.CookieSandbox();\n\n    ztoolkit.log(\"Opening URL in viewer: \" + url);\n    const win = Zotero.openInViewer(url, {\n      cookieSandbox: cookieSandbox,\n    }) as any as Window;\n\n    return new Promise((resolve, reject) => {\n      let promiseSettled = false;\n      let cookieRetrieved = false;\n\n      win.addEventListener(\"close\", function () {\n        ztoolkit.log(\"Window closed\");\n        if (!promiseSettled) {\n          promiseSettled = true;\n          if (cookieRetrieved) {\n            ztoolkit.log(\"Cookie sandbox returned successfully\");\n            resolve(cookieSandbox);\n          } else {\n            ztoolkit.log(\"Window closed without retrieving cookies\");\n            reject(new Error(`用户关闭窗口，未完成验证: ${url}`));\n          }\n        }\n      });\n\n      win.addEventListener(\"load\", function () {\n        ztoolkit.log(\"Window loaded, adding button\");\n\n        const buttonContainer = ztoolkit.UI.createElement(win.document, \"box\", {\n          namespace: \"html\",\n          attributes: { id: \"captcha-button-container\" },\n          styles: {\n            position: \"fixed\",\n            top: \"10px\",\n            right: \"10px\",\n            zIndex: \"10000\",\n            padding: \"15px\",\n            backgroundColor: \"white\",\n            border: \"3px solid red\",\n            borderRadius: \"8px\",\n            boxShadow: \"0 4px 8px rgba(0,0,0,0.3)\",\n            cursor: \"pointer\",\n            userSelect: \"none\",\n            transition: \"left 0.3s ease, right 0.3s ease\",\n          },\n        });\n\n        let isOnRight = true;\n\n        const titleLabel = ztoolkit.UI.createElement(win.document, \"label\", {\n          namespace: \"html\",\n          attributes: { value: \"茉莉花提示：\" },\n          styles: {\n            fontWeight: \"bold\",\n            color: \"black\",\n            fontSize: \"14px\",\n            marginBottom: \"5px\",\n            display: \"block\",\n          },\n        });\n\n        const hintLabel = ztoolkit.UI.createElement(\n          win.document,\n          \"description\",\n          {\n            namespace: \"html\",\n            properties: { textContent: hintText },\n            styles: {\n              color: \"black\",\n              fontSize: \"12px\",\n              marginBottom: \"10px\",\n              lineHeight: \"1.5\",\n              maxWidth: \"250px\",\n              whiteSpace: \"normal\",\n              wordWrap: \"break-word\",\n            },\n          },\n        );\n\n        const positionHint = ztoolkit.UI.createElement(\n          win.document,\n          \"description\",\n          {\n            namespace: \"html\",\n            properties: { textContent: \"(双击此框可切换左右位置)\" },\n            styles: {\n              color: \"#666\",\n              fontSize: \"10px\",\n              marginBottom: \"8px\",\n              fontStyle: \"italic\",\n            },\n          },\n        );\n\n        const button = ztoolkit.UI.createElement(win.document, \"button\", {\n          namespace: \"html\",\n          properties: { textContent: \"确认完成验证\" },\n          styles: {\n            fontSize: \"12px\",\n            padding: \"4px\",\n            cursor: \"pointer\",\n            backgroundColor: \"#4CAF50\",\n            background: \"#4CAF50\",\n            color: \"black\",\n            border: \"none\",\n            borderRadius: \"5px\",\n            width: \"50%\",\n            fontWeight: \"bold\",\n          },\n        });\n\n        button.addEventListener(\"mouseover\", function () {\n          if (!button.disabled) {\n            button.style.backgroundColor = \"#45a049\";\n            button.style.background = \"#45a049\";\n          }\n        });\n\n        button.addEventListener(\"mouseout\", function () {\n          if (!button.disabled) {\n            button.style.backgroundColor = \"#4CAF50\";\n            button.style.background = \"#4CAF50\";\n          }\n        });\n\n        button.addEventListener(\"click\", function () {\n          try {\n            const uri = Services.io.newURI(url);\n            const cookies = cookieSandbox.getCookiesForURI(uri);\n            ztoolkit.log(\"Cookies retrieved from sandbox.\", cookies);\n\n            if (cookies) {\n              for (const name in cookies) {\n                ztoolkit.log(`  ${name} = ${cookies[name]}`);\n              }\n\n              cookieRetrieved = true;\n\n              if (!promiseSettled) {\n                promiseSettled = true;\n                resolve(cookieSandbox);\n                ztoolkit.log(\"Promise resolved with cookieSandbox\");\n              }\n              win.close();\n              ztoolkit.log(\"Cookies retrieved successfully.\");\n            } else {\n              ztoolkit.log(\"未找到 cookies\");\n              button.setAttribute(\"label\", \"✗ 未找到 Cookie\");\n              button.style.backgroundColor = \"#f44336\";\n              button.style.color = \"white\";\n              hintLabel.textContent = \"未找到 Cookie，请确保已完成验证\";\n              hintLabel.style.color = \"#f44336\";\n            }\n          } catch (e: any) {\n            ztoolkit.log(\"获取 cookie 时出错: \" + e);\n            button.setAttribute(\"label\", \"✗ 出错了\");\n            button.style.backgroundColor = \"#f44336\";\n            button.style.color = \"white\";\n            hintLabel.textContent = \"出错了: \" + e.message;\n            hintLabel.style.color = \"#f44336\";\n          }\n        });\n\n        buttonContainer.appendChild(titleLabel);\n        buttonContainer.appendChild(hintLabel);\n        buttonContainer.appendChild(positionHint);\n        buttonContainer.appendChild(button);\n\n        buttonContainer.addEventListener(\"dblclick\", function (e) {\n          if (\n            e.target === button ||\n            (e.target as HTMLElement).closest(\"button\")\n          ) {\n            return;\n          }\n\n          if (isOnRight) {\n            buttonContainer.style.right = \"auto\";\n            buttonContainer.style.left = \"10px\";\n            isOnRight = false;\n            ztoolkit.log(\"Button moved to left\");\n          } else {\n            buttonContainer.style.left = \"auto\";\n            buttonContainer.style.right = \"10px\";\n            isOnRight = true;\n            ztoolkit.log(\"Button moved to right\");\n          }\n        });\n\n        const browserBox = win.document.getElementById(\"browser\");\n        if (browserBox) {\n          browserBox.appendChild(buttonContainer);\n        } else {\n          win.document.documentElement.appendChild(buttonContainer);\n        }\n\n        ztoolkit.log(\"Button with position toggle added successfully\");\n      });\n    });\n  }\n\n  public async getCNKIHomeCookieBox(): Promise<Zotero.CookieSandbox> {\n    const now = Date.now();\n    const isExpired =\n      now - this._cnkiHomeCookieLastUpdateTime >\n      MyCookieSandbox.COOKIE_EXPIRE_MS;\n\n    // If cookie exists and not expired, return directly\n    if (this._CNKIHomeCookieBox != null && !isExpired) {\n      return this._CNKIHomeCookieBox;\n    }\n\n    // Cookie expired or missing, reset for re-initialization\n    if (isExpired && this._CNKIHomeCookieBox != null) {\n      ztoolkit.log(\"CNKI Home cookie expired, re-initializing...\");\n      this._CNKIHomeCookieBox = null;\n      this._initPromise = null;\n    }\n\n    if (!this._initPromise) {\n      ztoolkit.log(\"homeCookieBox 为空，开始初始化...\");\n      this._initPromise = this.getCookieBoxFromUrl(\n        \"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\",\n        \"请等待知网网页正常打开后，再点击下方按钮关闭\",\n      ).then((cookieSandbox) => {\n        this._CNKIHomeCookieBox = cookieSandbox;\n        this._cnkiHomeCookieLastUpdateTime = Date.now();\n      });\n    }\n    await this._initPromise;\n    // 保险起见，再次检查是否成功获取到 cookieSandbox\n    if (this._CNKIHomeCookieBox == null) {\n      ztoolkit.log(\"homeCookieBox 还是为空，又开始初始化...\");\n      this._CNKIHomeCookieBox = await this.getCookieBoxFromUrl(\n        \"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\",\n        \"请等待知网网页正常打开后，再点击下方按钮关闭\",\n      );\n      this._cnkiHomeCookieLastUpdateTime = Date.now();\n    }\n    return this._CNKIHomeCookieBox!;\n  }\n\n  async passCaptchaToCookieBox(\n    url: string,\n    cookieType:\n      | \"CNKI:Search\"\n      | \"CNKI:Attachment\"\n      | \"CNKI:Reference\"\n      | \"CNKI:Home\",\n  ): Promise<Zotero.CookieSandbox> {\n    // 如果已经有验证码窗口在运行，等待它完成\n    if (this._captchaPromise) {\n      ztoolkit.log(\n        \"Captcha window is already running, waiting for it to complete...\",\n      );\n      return this._captchaPromise;\n    }\n\n    this._captchaPromise = this.getCookieBoxFromUrl(url).then(\n      (cookieSandbox) => {\n        // 根据 cookieType 设置对应的 cookieSandbox\n        switch (cookieType) {\n          case \"CNKI:Home\":\n            addon.data.myCookieSandbox._CNKIHomeCookieBox = cookieSandbox;\n            addon.data.myCookieSandbox._cnkiHomeCookieLastUpdateTime =\n              Date.now();\n            break;\n          // 其他类型...\n        }\n        ztoolkit.log(\"Cookies passed to addon CookieSandbox.\");\n        return cookieSandbox;\n      },\n    );\n\n    // 在 Promise 完成后清空，无论成功还是失败\n    this._captchaPromise.finally(() => {\n      this._captchaPromise = null;\n      ztoolkit.log(\"Captcha promise cleared, ready for next captcha request\");\n    });\n\n    return this._captchaPromise;\n  }\n}\n"
  },
  {
    "path": "src/utils/detect.ts",
    "content": "// 这里有许多类型判断，判断不同的条目类型\n\n/**\n * 主要检测知网等其他数据库下载的附件文件名是否至少有3个汉字\n * Created by DeepSeek\n * @param filename\n * @returns\n */\n\nconst CHINESE_FILENAME_REGEX =\n  /^(?=(.*?\\p{Unified_Ideograph}){3})(?=(.*\\p{Unified_Ideograph}){3}).+\\.(pdf|caj|kdh|nh)$/iu;\nexport function isChineseAttachmentFilename(filename: string): boolean {\n  return CHINESE_FILENAME_REGEX.test(filename);\n}\n\n/**\n * Return true when item is a top level Chinese PDF/CAJ item.\n */\nexport function isChineseTopAttachment(item: Zotero.Item): boolean {\n  return (\n    item.isAttachment() &&\n    item.isTopLevelItem() &&\n    isChineseAttachmentFilename(item.attachmentFilename)\n  );\n}\n\n/**\n * 检测是否是中文的顶层条目\n * @param item\n * @returns\n */\nexport function isChineseTopItem(item: Zotero.Item): boolean {\n  return (\n    item.isRegularItem() &&\n    item.isTopLevelItem() &&\n    /\\p{Unified_Ideograph}/iu.test(item.getField(\"title\"))\n  );\n}\n\n/**\n * CNKI Snapshot attachment item，注意是附件条目\n * CNKI Webpage top level item. 注意是网页类型条目\n * @param item\n * @returns\n */\nexport function isChinsesSnapshot(item: Zotero.Item): boolean {\n  return (\n    (item.isSnapshotAttachment() &&\n      item.getField(\"title\").includes(\"- 中国知网\")) ||\n    (item.isTopLevelItem() &&\n      item.itemType == \"webpage\" &&\n      item.getField(\"title\").includes(\"- 中国知网\"))\n  );\n}\n"
  },
  {
    "path": "src/utils/http.ts",
    "content": "function jsonToFormUrlEncoded(json: any) {\n  return Object.keys(json)\n    .map(\n      (key) =>\n        encodeURIComponent(key) +\n        \"=\" +\n        encodeURIComponent(\n          typeof json[key] === \"object\" ? JSON.stringify(json[key]) : json[key],\n        ),\n    )\n    .join(\"&\");\n}\n\nasync function requestDocument(\n  url: string,\n  options?: {\n    method?: string;\n    body?: string;\n    headers?: any;\n    responseType?: string;\n    responseCharset?: string;\n    successCodes?: number[] | false;\n    cookieSandbox?: Zotero.CookieSandbox;\n  },\n): Promise<Document> {\n  const xhr = await Zotero.HTTP.request(options?.method || \"GET\", url, {\n    ...options,\n    responseType: \"document\",\n  });\n  let doc = xhr.response;\n  if (doc && !doc.location) {\n    doc = Zotero.HTTP.wrapDocument(doc, xhr.responseURL);\n  }\n  return doc;\n}\n\nfunction text2HTMLDoc(text: string, url?: string): Document {\n  let doc = new DOMParser().parseFromString(text, \"text/html\");\n  if (url) {\n    doc = Zotero.HTTP.wrapDocument(doc, url);\n  }\n  return doc;\n}\n\n// Detect user is in mainland China.\n// Except 中国台湾，中国香港，中国澳门\nasync function isMainlandChina(): Promise<boolean> {\n  const mainlandChina = [\n    \"浙江省\",\n    \"江苏省\",\n    \"广东省\",\n    \"山东省\",\n    \"河南省\",\n    \"四川省\",\n    \"湖北省\",\n    \"河北省\",\n    \"湖南省\",\n    \"安徽省\",\n    \"辽宁省\",\n    \"福建省\",\n    \"陕西省\",\n    \"黑龙江省\",\n    \"吉林省\",\n    \"山西省\",\n    \"江西省\",\n    \"云南省\",\n    \"贵州省\",\n    \"内蒙古自治区\",\n    \"广西壮族自治区\",\n    \"西藏自治区\",\n    \"宁夏回族自治区\",\n    \"新疆维吾尔自治区\",\n    \"北京市\",\n    \"天津市\",\n    \"上海市\",\n    \"重庆市\",\n  ];\n  const html = await requestDocument(\"https://ip.chinaz.com/\", {\n    method: \"GET\",\n  });\n  const targets = Zotero.Utilities.xpath(\n    html,\n    \"//div[contains(text(), '您的本机IP地址')]\",\n  );\n  if (targets.length > 0) {\n    const targetContent = targets[0].textContent;\n    return mainlandChina.some((p) => targetContent?.includes(\"归属地：\" + p));\n  }\n  return true;\n}\n\n/**\n * A simple HTML selector and attribute extractor.\n */\nclass DocTools {\n  private node: Document | Element;\n  constructor(node: Document | Element) {\n    this.node = node;\n  }\n  attr(selector: string, attr: string, index?: number): string {\n    const elm = this.choose(selector, index);\n    return elm && elm.hasAttribute(attr) ? elm.getAttribute(attr)!.trim() : \"\";\n  }\n  text(selector: string, index?: number): string {\n    const elm = this.choose(selector, index);\n    return elm && elm.textContent ? elm.textContent!.trim() : \"\";\n  }\n  innerText(selector: string, index?: number): string {\n    const elm = this.choose(selector, index);\n    return elm && elm.textContent ? elm.textContent.trim() : \"\";\n  }\n  choose(selector: string, index?: number): Element | null {\n    if (index === undefined) {\n      return this.node.querySelector(selector);\n    } else {\n      const items = this.node.querySelectorAll(selector);\n      if (index >= 0) {\n        return items.item(index);\n      } else {\n        return items.item(items.length + index);\n      }\n    }\n  }\n}\n\nexport {\n  requestDocument,\n  jsonToFormUrlEncoded,\n  isMainlandChina,\n  DocTools,\n  text2HTMLDoc,\n};\n"
  },
  {
    "path": "src/utils/locale.ts",
    "content": "import { config } from \"../../package.json\";\n\nexport { initLocale, getString, getLocaleID };\n\n/**\n * Initialize locale data\n */\nfunction initLocale() {\n  const l10n = new (\n    typeof Localization === \"undefined\"\n      ? ztoolkit.getGlobal(\"Localization\")\n      : Localization\n  )([`${config.addonRef}-addon.ftl`], true);\n  addon.data.locale = {\n    current: l10n,\n  };\n}\n\n/**\n * Get locale string, see https://firefox-source-docs.mozilla.org/l10n/fluent/tutorial.html#fluent-translation-list-ftl\n * @param localString ftl key\n * @param options.branch branch name\n * @param options.args args\n * @example\n * ```ftl\n * # addon.ftl\n * addon-static-example = This is default branch!\n *     .branch-example = This is a branch under addon-static-example!\n * addon-dynamic-example =\n    { $count ->\n        [one] I have { $count } apple\n       *[other] I have { $count } apples\n    }\n * ```\n * ```js\n * getString(\"addon-static-example\"); // This is default branch!\n * getString(\"addon-static-example\", { branch: \"branch-example\" }); // This is a branch under addon-static-example!\n * getString(\"addon-dynamic-example\", { args: { count: 1 } }); // I have 1 apple\n * getString(\"addon-dynamic-example\", { args: { count: 2 } }); // I have 2 apples\n * ```\n */\nfunction getString(localString: string): string;\nfunction getString(localString: string, branch: string): string;\nfunction getString(\n  localeString: string,\n  options: { branch?: string | undefined; args?: Record<string, unknown> },\n): string;\nfunction getString(...inputs: any[]) {\n  if (inputs.length === 1) {\n    return _getString(inputs[0]);\n  } else if (inputs.length === 2) {\n    if (typeof inputs[1] === \"string\") {\n      return _getString(inputs[0], { branch: inputs[1] });\n    } else {\n      return _getString(inputs[0], inputs[1]);\n    }\n  } else {\n    throw new Error(\"Invalid arguments\");\n  }\n}\n\nfunction _getString(\n  localeString: string,\n  options: { branch?: string | undefined; args?: Record<string, unknown> } = {},\n): string {\n  const localStringWithPrefix = `${config.addonRef}-${localeString}`;\n  const { branch, args } = options;\n  const pattern = addon.data.locale?.current.formatMessagesSync([\n    { id: localStringWithPrefix, args },\n  ])[0];\n  if (!pattern) {\n    return localStringWithPrefix;\n  }\n  if (branch && pattern.attributes) {\n    for (const attr of pattern.attributes) {\n      if (attr.name === branch) {\n        return attr.value;\n      }\n    }\n    return pattern.attributes[branch] || localStringWithPrefix;\n  } else {\n    return pattern.value || localStringWithPrefix;\n  }\n}\n\nfunction getLocaleID(id: string) {\n  return `${config.addonRef}-${id}`;\n}\n"
  },
  {
    "path": "src/utils/pattern.ts",
    "content": "export function getArgsFromPattern(\n  filename: string,\n  pattern: string,\n): SearchOption | null {\n  // Make query parameters from filename\n  const prefix = filename\n    .replace(/\\.\\w+$/, \"\") // 删除文件后缀\n    .replace(/\\.ashx$/g, \"\") // 删除末尾.ashx字符\n    .replace(/^_|_$/g, \"\") // 删除前后的下划线\n    .replace(/[（(]\\d+[）)]$/, \"\") // 删除重复下载时文件名出现的数字编号 (1) （1）\n    .trim();\n  // 当文件名模板为\"{%t}_{%g}\"，文件名无下划线_时，将文件名认定为标题\n  if (pattern === \"{%t}_{%g}\" && !prefix.includes(\"_\")) {\n    return {\n      author: \"\",\n      title: prefix,\n    };\n  }\n  const patternSepArr: string[] = pattern.split(/{%[^}]+}/);\n  const patternSepRegArr: string[] = patternSepArr.map((x) =>\n    x.replace(/([[^$.|?*+()])/g, \"\\\\$&\"),\n  );\n  const patternMainArr: string[] | null = pattern.match(/{%[^}]+}/g);\n  //文件名中的作者姓名字段里不能包含下划线，请使用“&,，”等字符分隔多个作者，或仅使用第一个作者名（加不加“等”都行）。\n  const patternMainRegArr = patternMainArr!.map((x) =>\n    x.replace(\n      /.+/,\n      /{%y}/.test(x) ? \"(\\\\d+)\" : /{%g}/.test(x) ? \"([^_]+)\" : \"(.+)\",\n    ),\n  );\n  const regStrInterArr = patternSepRegArr.map((_, i) => [\n    patternSepRegArr[i],\n    patternMainRegArr[i],\n  ]);\n  const patternReg = new RegExp(\n    // eslint-disable-next-line prefer-spread\n    [].concat\n      .apply([], regStrInterArr as never)\n      .filter(Boolean)\n      .join(\"\"),\n    \"g\",\n  );\n\n  const prefixMainArr = patternReg.exec(prefix);\n  // 文件名识别结果为空，跳出警告弹窗\n  if (prefixMainArr === null) {\n    return null;\n  }\n  const titleIdx = patternMainArr!.indexOf(\"{%t}\");\n  const authorIdx = patternMainArr!.indexOf(\"{%g}\");\n  const titleRaw = titleIdx != -1 ? prefixMainArr[titleIdx + 1] : \"\";\n  const authors = authorIdx != -1 ? prefixMainArr[authorIdx + 1] : \"\";\n  const authorArr = authors.split(/[,，&]/);\n  let author = authorArr[0];\n  if (authorArr.length == 1) {\n    //删除名字后可能出现的“等”字，此处未能做到识别该字是否属于作者姓名。\n    //这种处理方式的问题：假如作者名最后一个字为“等”，例如：“刘等”，此时会造成误删。\n    //于是对字符数进行判断，保证删除“等”后，至少还剩两个字符，尽可能地避免误删。\n\n    author =\n      author.endsWith(\"等\") && author.length > 2\n        ? author.substring(0, author.length - 1)\n        : author;\n  }\n\n  //为了避免文件名中的标题字段里存在如下两种情况而导致的搜索失败:\n  //原标题过长，文件名出现“_省略_”；\n  //原标题有特殊符号（如希腊字母、上下标）导致的标题变动，此时标题也会出现“_”。\n  //于是只取用标题中用“_”分割之后的最长的部分作为用于搜索的标题。\n\n  //这种处理方式的问题：假如“最长的部分”中存在知网改写的部分，也可能搜索失败。\n  //不过这只是理论上可能存在的情形，目前还未实际遇到。\n\n  let title: string;\n  // Zotero.debug(titleRaw);\n  // if (/_/.test(titleRaw)) {\n\n  //     //getLongestText函数，用于拿到字符串数组中的最长字符\n  //     //摘自https://stackoverflow.com/a/59935726\n  //     const getLongestText = (arr) => arr.reduce(\n  //         (savedText, text) => (text.length > savedText.length ? text : savedText),\n  //         '',\n  //     );\n  //     title = getLongestText(titleRaw.split(/_/));\n  // } else {\n  //     title = titleRaw;\n  // }\n\n  // 去除_省略_ \"...\", 多余的 _ 换为空格\n  // 标题中含有空格，查询时会启用模糊模式\n  title = titleRaw.replace(\"_省略_\", \" \").replace(\"...\", \" \");\n  title = title.replace(/_/g, \" \");\n  return {\n    author: author,\n    title: title,\n  };\n}\n"
  },
  {
    "path": "src/utils/pdfParser.ts",
    "content": "async function getPDFTitle(itemID: number): Promise<string> {\n  // @ts-ignore - PDFWorker is not typed\n  const recognizerData = await Zotero.PDFWorker.getRecognizerData(itemID, true);\n  ztoolkit.log(\"recognizerData: \", debugDoc(recognizerData));\n  const pdfData = recognizerDataToPdfData(recognizerData);\n  ztoolkit.log(\"pdfData: \", pdfData);\n  const docType = detectDocType(pdfData);\n  ztoolkit.log(\"docType: \", docType);\n\n  /*\n   * 更好的做法是，仅将属性名称语义化的 PDF 数据传递给 get*() 函数，\n   * 由函数内部根据各文献类型的排版特点对数据进行重新组织。\n   */\n  switch (docType) {\n    case \"article\":\n      return getArticleTitle(pdfData);\n    case \"thesis\":\n      return getThesisTitle(pdfData);\n  }\n  return \"\";\n}\n\nfunction isValidTitle(line: PdfParagraph): boolean {\n  return (\n    line.classList.length === 0 && line.text.length > 3 && hasCJK(line.text)\n  );\n}\n\nfunction getThesisTitle(data: PdfData): string {\n  const contextLine = findParagraphInPagesReversed(data.pages, (pages) =>\n    findParagraphAfter(pages.paragraphs, keyPatterns.thesis[\"bfore-title\"]),\n  );\n  const maxSizeLine = findMaxSizeParagraph(\n    data.pages.flatMap((page) => page.paragraphs),\n  );\n  return (contextLine?.text ?? maxSizeLine?.text ?? \"\").replace(\n    /^(论文)?(颗|题)目(（.+?）)?：?/,\n    \"\",\n  );\n}\n\nfunction getArticleTitle(data: PdfData): string {\n  let mainPage = data.pages[0];\n  if (/《.+》网络首发论文/.test(mainPage.text)) {\n    ztoolkit.log(\"CNKI advanced online article\");\n    mainPage = data.pages[1];\n  }\n  return (findMaxSizeParagraph(mainPage.paragraphs)?.text ?? \"\").replace(\n    new RegExp(`[${footnoteMarkers}]+$`),\n    \"\",\n  );\n}\n\nfunction findParagraphInPages(\n  pages: PdfPage[],\n  finder: (\n    page: PdfPage,\n    index: number,\n    pages: PdfPage[],\n  ) => PdfParagraph | undefined,\n): PdfParagraph | undefined {\n  for (let i = pages.length - 1; i >= 0; i--) {\n    const paragraph = finder(pages[i], i, pages);\n    if (paragraph !== undefined) {\n      return paragraph;\n    }\n  }\n  return undefined;\n}\n\nfunction findParagraphInPagesReversed(\n  pages: PdfPage[],\n  finder: (\n    page: PdfPage,\n    index: number,\n    pages: PdfPage[],\n  ) => PdfParagraph | undefined,\n): PdfParagraph | undefined {\n  for (let i = 0; i < pages.length; i++) {\n    const paragraph = finder(pages[i], i, pages);\n    if (paragraph !== undefined) {\n      return paragraph;\n    }\n  }\n  return undefined;\n}\n\nfunction findParagraphAfter(paragraphs: PdfParagraph[], patterns: RegExp[]) {\n  return paragraphs.findLast((paragraph, index, paragraphs) => {\n    const anchorParagraph: PdfParagraph | undefined = paragraphs[index - 1];\n    return (\n      isValidTitle(paragraph) &&\n      anchorParagraph &&\n      patterns.some((regexp) => regexp.test(anchorParagraph.text))\n    );\n  });\n}\n\nfunction findParagraphBefore(paragraphs: PdfParagraph[], patterns: RegExp[]) {\n  return paragraphs.find((paragraph, index, paragraphs) => {\n    const anchorParagraph: PdfParagraph | undefined = paragraphs[index + 1];\n    return (\n      isValidTitle(paragraph) &&\n      anchorParagraph &&\n      patterns.some((regexp) => regexp.test(anchorParagraph.text))\n    );\n  });\n}\n\nfunction findMaxSizeParagraph(paragraphs: PdfParagraph[]) {\n  let candidateParagraph: PdfParagraph | undefined;\n  for (const paragraph of paragraphs) {\n    if (isValidTitle(paragraph)) {\n      if (\n        !candidateParagraph ||\n        parseFloat(paragraph.fontSize) > parseFloat(candidateParagraph.fontSize)\n      ) {\n        candidateParagraph = paragraph;\n      }\n    }\n  }\n  return candidateParagraph;\n}\n\nfunction detectDocType(data: PdfData): DocType {\n  const hitsCounter = {\n    article: 0,\n    thesis: 0,\n    book: 0,\n  };\n  if (data.totalPages > 10) {\n    pageLoop: for (const page of data.pages) {\n      for (const paragraph of page.paragraphs) {\n        if (breakMarks.some((regexp) => regexp.test(paragraph.text))) {\n          ztoolkit.log(\"stop at paragraph: \", paragraph.text);\n          break pageLoop;\n        }\n        typeLoop: for (const key in docTypePatterns) {\n          const docType = key as DocType;\n          for (const pattern of docTypePatterns[docType]) {\n            if (pattern.test(paragraph.text)) {\n              ztoolkit.log(paragraph.text, \"hits\", pattern);\n              hitsCounter[docType]++;\n              if (hitsCounter[docType] > 3) {\n                return docType;\n              }\n              break typeLoop;\n            }\n          }\n        }\n      }\n    }\n  }\n  ztoolkit.log(hitsCounter);\n  return Object.values(hitsCounter).some((count) => count > 0)\n    ? Object.keys(hitsCounter)\n        .map((key) => key as DocType)\n        .reduce((a, b) => (hitsCounter[a] > hitsCounter[b] ? a : b))\n    : \"article\";\n}\n\nfunction sortLines(lines: PdfLine[]): PdfLine[] {\n  return lines.sort((lineA, lineB) => {\n    if (lineA.baseline == lineB.baseline) {\n      return lineA.xMin - lineB.xMin;\n    }\n    return lineA.baseline - lineB.baseline;\n  });\n}\n\nfunction recognizerDataToPdfData(data: RecognizerData): PdfData {\n  return {\n    metadata: data.metadata,\n    totalPages: data.totalPages,\n    pages: data.pages.map((page) => recognizerPageToPdfPage(page)),\n  };\n}\n\nfunction recognizerPageToPdfPage(page: RecognizerPage): PdfPage {\n  const lines = page[2][0][0][0][4].map(recognizerLineToPdfLine);\n  // 这里会将双烂排序的段落打乱，甚至将尾注混合在正文段落中，但我们不关心这部分信息。\n  // 以后如果需要获取更细粒度的信息，需要对行盒子的位置关系进行比较进行多次分组和排序。\n  // 我已经对此进行了测试，但复杂度和性能都不太好。\n  // 考虑AI识别的普遍应用，将来可能会有更好的解决方案。\n  const paragraphs = pdfLinesToPdfParagraphs(sortLines(lines));\n  return {\n    width: page[0],\n    height: page[1],\n    text: paragraphs.map((paragraph) => paragraph.text).join(\"\\n\"),\n    classList: [],\n    paragraphs,\n  };\n}\n\nfunction pdfLinesToPdfParagraphs(lines: PdfLine[]): PdfParagraph[] {\n  const paragraphs: PdfParagraph[] = [];\n  for (let i = 0; i < lines.length; i++) {\n    const preLine = lines[i - 1];\n    const curLine = lines[i];\n    const paragraph = paragraphs.at(-1);\n    if (!paragraph) {\n      paragraphs.push({\n        fontSize: curLine.fontSize,\n        text: curLine.text,\n        classList: [],\n        lines: [curLine],\n      });\n    } else {\n      const fontSizeEqual = preLine.fontSize === curLine.fontSize;\n      const semanticCoherence =\n        /\\S([、，：～,:&]|—{1,2})$/iu.test(preLine.text) ||\n        /^—{1,2}\\S/iu.test(curLine.text);\n      function typographicConsistency() {\n        function getFontIndexies(words: PdfWord[]) {\n          return Array.from(new Set(words.map((word) => word.fontIndex)));\n        }\n        if (hasCJK(preLine.text) && hasCJK(curLine.text)) {\n          const preFontIndexies = getFontIndexies(\n            preLine.words.filter((word) => hasCJK(word.text)),\n          );\n          const curFontIndexies = getFontIndexies(\n            curLine.words.filter((word) => hasCJK(word.text)),\n          );\n          return curFontIndexies.some((fontIndex) =>\n            preFontIndexies.includes(fontIndex),\n          );\n        } else if (!hasCJK(preLine.text) && !hasCJK(curLine.text)) {\n          const preFontIndexies = getFontIndexies(\n            preLine.words.filter((word) => !hasCJK(word.text)),\n          );\n          const curFontIndexies = getFontIndexies(\n            curLine.words.filter((word) => !hasCJK(word.text)),\n          );\n          return curFontIndexies.some((fontIndex) =>\n            preFontIndexies.includes(fontIndex),\n          );\n        }\n        return false;\n      }\n      if ((fontSizeEqual || semanticCoherence) && typographicConsistency()) {\n        paragraph.lines.push(curLine);\n      } else {\n        paragraph.fontSize = preLine.fontSize;\n        let text = \"\";\n        if (paragraph.lines.length === 1) {\n          text = paragraph.lines[0].text;\n        } else if (paragraph.lines.length > 1) {\n          paragraph.lines.reduce((pre, cur) => {\n            let delimiter = \"\";\n            const wordAndWord = /\\w$/.test(pre.text) && /^\\w/.test(cur.text);\n            const punctuationAndWord =\n              /[!$%&\\]);:,.>]$/iu.test(pre.text) && /^\\w/.test(cur.text);\n            if (wordAndWord || punctuationAndWord) {\n              delimiter = \" \";\n            }\n            text += `${pre.text}${delimiter}${cur.text}`;\n            return cur;\n          });\n        }\n        paragraph.text = text;\n        paragraphs.push({\n          fontSize: curLine.fontSize,\n          text: curLine.text,\n          classList: [],\n          lines: [curLine],\n        });\n      }\n    }\n  }\n  return paragraphs;\n}\n\nfunction recognizerLineToPdfLine(line: RecognizerLine): PdfLine {\n  const words = line[0].map(recognizerWordToPdfWord);\n  return pdfWordsToPdfLine(words);\n}\n\nfunction pdfWordsToPdfLine(words: PdfWord[]): PdfLine {\n  const CJKWords = words.filter((word) => hasCJK(word.text));\n  const fontSize = average(\n    CJKWords.length ? CJKWords : words,\n    (word) => word.fontSize,\n  ).toFixed(2);\n  return {\n    xMin: Math.min(...words.map((word) => word.xMin)),\n    yMin: Math.min(...words.map((word) => word.yMin)),\n    xMax: Math.max(...words.map((word) => word.xMax)),\n    yMax: Math.max(...words.map((word) => word.yMax)),\n    fontSize,\n    baseline: average(words, (word) => word.baseline),\n    text: normalizeText(\n      words.map((word) => `${word.text}${word.spaceAfter ? \" \" : \"\"}`).join(\"\"),\n    ),\n    words,\n  };\n}\n\nfunction recognizerWordToPdfWord(word: RecognizerWord): PdfWord {\n  return {\n    xMin: word[0],\n    yMin: word[1],\n    xMax: word[2],\n    yMax: word[3],\n    fontSize: word[4],\n    spaceAfter: Boolean(word[5]),\n    baseline: word[6],\n    rotation: Boolean(word[7]),\n    underlined: Boolean(word[8]),\n    bold: Boolean(word[9]),\n    italic: Boolean(word[10]),\n    colorIndex: word[11],\n    fontIndex: word[12],\n    text: word[13],\n  };\n}\n\nfunction average<T>(arr: T[], callback: (arg: T) => number): number {\n  return arr.reduce((sum, cur) => sum + callback(cur), 0) / arr.length;\n}\n\nfunction hasCJK(str: string) {\n  return /\\p{Unified_Ideograph}/u.test(str);\n}\n\nfunction xnor(input1: boolean, input2: boolean) {\n  return (input1 && input2) || (!input1 && !input2);\n}\n\nfunction debugDoc(data: RecognizerData) {\n  function parsePage(page: RecognizerPage) {\n    return page[2][0][0][0][4].map((line) => {\n      return line[0].map((word) => {\n        return {\n          xMin: word[0],\n          yMin: word[1],\n          xMax: word[2],\n          yMax: word[3],\n          fontSize: word[4],\n          spaceAfter: Boolean(word[5]),\n          baseline: word[6],\n          rotation: Boolean(word[7]),\n          underlined: Boolean(word[8]),\n          bold: Boolean(word[9]),\n          italic: Boolean(word[10]),\n          colorIndex: word[11],\n          fontIndex: word[12],\n          text: word[13],\n        };\n      });\n    });\n  }\n  const pages = data.pages.map(parsePage);\n  return pages.map((page) =>\n    page.map((line) => ({\n      fontSize: average(line, (word) => word.fontSize).toFixed(2),\n      text: line\n        .map((word) => `${word.text}${word.spaceAfter ? \" \" : \"\"}`)\n        .join(\"\"),\n      baseLine: average(line, (word) => word.baseline),\n    })),\n  );\n}\n\nconst breakMarks = [\n  // 地址\n  /关键词[:：]/,\n  /^（?((\\d*\\.)?\\s*[\\p{Unified_Ideograph}，；\\s]+\\d{6}\\b)+）?/u,\n  /^[[【〔［]]?收稿日期/,\n  /^[[【〔［]]?\\**通(信|讯)作者/,\n  /(原|独)创性声明$/,\n  /使用授权(书|声明)$/,\n  /^目录$/,\n  /^(中文)?摘要$/,\n];\n\nconst keyPatterns: {\n  [type in DocType]: {\n    [className: string]: RegExp[];\n  };\n} = {\n  article: {},\n  thesis: {\n    \"before-title\": [\n      /（?\\p{Unified_Ideograph}*((硕|博)士)?(研究生)?.*([学宇字]位|毕业)论文）?$/u,\n      /^（?\\p{Unified_Ideograph}*(([硕博][士±])?(专业|[学宇字]术)|([硕博][士|±])(专业|[学宇字]术)?)[学宇字]位）?$/u,\n      /^（?\\p{Unified_Ideograph}*博士后研究工作报告）?$/u,\n      // 陕西师范大学《气候变化和人类活动对祁连山草地演变影响程度的研究》\n      /^（(专业|[学宇字]术)型）$/,\n      // 广西师范学院《农户耕地撂荒影响因素研究》\n      /^论文题目([(（]中英文[）)])$/,\n    ],\n  },\n  book: {},\n};\n\nfunction patternsInType(type: DocType): RegExp[] {\n  return Object.values(keyPatterns[type]).flat();\n}\n\nconst docTypePatterns: {\n  [type in DocType]: RegExp[];\n} = {\n  article: [],\n  thesis: [\n    ...patternsInType(\"thesis\"),\n    /(([学宇字]|院)校|单位)代码/,\n    /保?密等?级/,\n    /[学宇字]号/,\n    /(研究生|([学宇字]位)?申请人)(姓名)?/,\n    /所在([学宇字]院|单位)|培养单位/,\n    /(指导教师|导师)(姓名)?/,\n    /(专业|[学宇字]科)(领域)?(名称)?/,\n    /(论文)?(答辩|提交|完成)(日期|时间)/,\n    /答辩委员会/,\n  ],\n  book: [],\n};\n\nconst letterShapeMap = {\n  /* Uppercase letters */\n  Ａ: \"A\",\n  Ｂ: \"B\",\n  Ｃ: \"C\",\n  Ｄ: \"D\",\n  Ｅ: \"E\",\n  Ｆ: \"F\",\n  Ｇ: \"G\",\n  Ｈ: \"H\",\n  Ｉ: \"I\",\n  Ｊ: \"J\",\n  Ｋ: \"K\",\n  Ｌ: \"L\",\n  Ｍ: \"M\",\n  Ｎ: \"N\",\n  Ｏ: \"O\",\n  Ｐ: \"P\",\n  Ｑ: \"Q\",\n  Ｒ: \"R\",\n  Ｓ: \"S\",\n  Ｔ: \"T\",\n  Ｕ: \"U\",\n  Ｖ: \"V\",\n  Ｗ: \"W\",\n  Ｘ: \"X\",\n  Ｙ: \"Y\",\n  Ｚ: \"Z\",\n  /* Lowercase letters */\n  ａ: \"a\",\n  ｂ: \"b\",\n  ｃ: \"c\",\n  ｄ: \"d\",\n  ｅ: \"e\",\n  ｆ: \"f\",\n  ｇ: \"g\",\n  ｈ: \"h\",\n  ｉ: \"i\",\n  ｊ: \"j\",\n  ｋ: \"k\",\n  ｌ: \"l\",\n  ｍ: \"m\",\n  ｎ: \"n\",\n  ｏ: \"o\",\n  ｐ: \"p\",\n  ｑ: \"q\",\n  ｒ: \"r\",\n  ｓ: \"s\",\n  ｔ: \"t\",\n  ｕ: \"u\",\n  ｖ: \"v\",\n  ｗ: \"w\",\n  ｘ: \"x\",\n  ｙ: \"y\",\n  ｚ: \"z\",\n  /* Arabic numerals */\n  \"０\": \"0\",\n  \"１\": \"1\",\n  \"２\": \"2\",\n  \"３\": \"3\",\n  \"４\": \"4\",\n  \"５\": \"5\",\n  \"６\": \"6\",\n  \"７\": \"7\",\n  \"８\": \"8\",\n  \"９\": \"9\",\n};\n\nconst footnoteMarkers = \"*＊∗●Δ①②③④⑤⑥⑦⑧⑨➀➁➃➄➅➆➇➈\";\n\nfunction normalizeText(str: string) {\n  str = Zotero.Utilities.trimInternal(str);\n  for (const fullChar in letterShapeMap) {\n    str = str.replace(\n      new RegExp(fullChar, \"g\"),\n      letterShapeMap[fullChar as keyof typeof letterShapeMap],\n    );\n  }\n  return (\n    str\n      // eslint-disable-next-line no-control-regex\n      .replace(/[\\x00-\\x1F\\x7F\\p{Private_Use}]/gu, \"\")\n      .replace(/\\s?([\\p{Unified_Ideograph}—－])\\s?/gu, \"$1\")\n      .trim()\n  );\n}\n\nexport { getPDFTitle };\n"
  },
  {
    "path": "src/utils/prefs.ts",
    "content": "import { config } from \"../../package.json\";\n\nexport type PluginPrefsMap = _ZoteroTypes.Prefs[\"PluginPrefsMap\"];\n\nconst PREFS_PREFIX = config.prefsPrefix;\n\n/**\n * Get preference value.\n * Wrapper of `Zotero.Prefs.get`.\n * @param key\n */\nexport function getPref<K extends keyof PluginPrefsMap>(key: K) {\n  return Zotero.Prefs.get(`${PREFS_PREFIX}.${key}`, true) as PluginPrefsMap[K];\n}\n\n/**\n * Set preference value.\n * Wrapper of `Zotero.Prefs.set`.\n * @param key\n * @param value\n */\nexport function setPref<K extends keyof PluginPrefsMap>(\n  key: K,\n  value: PluginPrefsMap[K],\n) {\n  return Zotero.Prefs.set(`${PREFS_PREFIX}.${key}`, value, true);\n}\n\n/**\n * Clear preference value.\n * Wrapper of `Zotero.Prefs.clear`.\n * @param key\n */\nexport function clearPref(key: string) {\n  return Zotero.Prefs.clear(`${PREFS_PREFIX}.${key}`, true);\n}\n"
  },
  {
    "path": "src/utils/task.ts",
    "content": "import { metaSearch, metaTranslate } from \"../modules/services\";\nimport { getString } from \"./locale\";\nimport { attachmentSearch, importAttachment } from \"../modules/attachments\";\nimport { version } from \"../../package.json\";\n\n// 创建 Deferred 的工厂函数\nfunction createDeferred<T>(): DeferredResult<T> {\n  let resolve!: (value: T) => void;\n  let reject!: (reason?: any) => void;\n\n  const promise = new Promise<T>((res, rej) => {\n    resolve = res;\n    reject = rej;\n  });\n\n  return { promise, resolve, reject };\n}\n\nexport class ScraperTask implements ScraperTask {\n  public id: string;\n  public item: Zotero.Item;\n  public type: ScraperTaskType;\n  public message?: string;\n  public silent?: false;\n  public deferred?: DeferredResult;\n  public resultIndex?: 0;\n\n  private _status: TaskStatus;\n  private _searchResults: ScrapeSearchResult[] = [];\n\n  constructor(item: Zotero.Item, type: ScraperTaskType, silent?: false) {\n    this.id = Zotero.Utilities.Internal.md5(item.id.toString());\n    this.item = item;\n    this.type = type;\n    this.silent = false;\n    this._status = \"waiting\";\n  }\n\n  // 添加消息的方法（不需要通过代理）\n  addMsg(message: string) {\n    if (this.message) {\n      this.message = this.message + \"\\n\" + message;\n    } else {\n      this.message = message;\n    }\n  }\n\n  // 使用 setter 处理属性变更\n  set status(newStatus: TaskStatus) {\n    const oldStatus = this._status;\n    // if (oldStatus === newStatus) return;\n    this._status = newStatus;\n    ztoolkit.log(\n      `task ${this.id} changes \"status\" from \"${oldStatus}\" to \"${newStatus}\"`,\n    );\n    addon.data.progress.updateTaskStatus(this, newStatus);\n    if (newStatus === \"multiple_results\") {\n      this.deferred = createDeferred<number>();\n    }\n  }\n  get status(): TaskStatus {\n    return this._status;\n  }\n\n  set searchResults(results: ScrapeSearchResult[]) {\n    this._searchResults = results;\n    ztoolkit.log(\"searchResult changed\");\n    if (results && results.length > 1) {\n      addon.data.progress.updateTaskSearchResult(this, results);\n    }\n  }\n  get searchResults() {\n    return this._searchResults;\n  }\n}\n\nexport class AttachmentTask implements AttachmentTask {\n  public id: string;\n  public item: Zotero.Item;\n  public type: AttachmentTaskType;\n  public message?: string;\n  public silent?: false;\n  public deferred?: DeferredResult;\n  public resultIndex?: 0;\n\n  private _status: TaskStatus;\n  private _searchResults: AttachmentSearchResult[] = [];\n\n  constructor(item: Zotero.Item, type: AttachmentTaskType, silent?: false) {\n    this.id = Zotero.Utilities.Internal.md5(item.id.toString());\n    this.item = item;\n    this.type = type;\n    this.silent = false;\n    this._status = \"waiting\";\n  }\n\n  // 添加消息的方法（不需要通过代理）\n  addMsg(message: string) {\n    if (this.message) {\n      this.message = this.message + \"\\n\" + message;\n    } else {\n      this.message = message;\n    }\n  }\n\n  // 使用 setter 处理属性变更\n  set status(newStatus: TaskStatus) {\n    const oldStatus = this._status;\n    // if (oldStatus === newStatus) return;\n    this._status = newStatus;\n    ztoolkit.log(\n      `task ${this.id} changes \"status\" from \"${oldStatus}\" to \"${newStatus}\"`,\n    );\n    addon.data.progress.updateTaskStatus(this, newStatus);\n    if (newStatus === \"multiple_results\") {\n      this.deferred = createDeferred<number>();\n    }\n  }\n  get status(): TaskStatus {\n    return this._status;\n  }\n\n  set searchResults(results: AttachmentSearchResult[]) {\n    this._searchResults = results;\n    ztoolkit.log(\"searchResult changed\");\n    if (results && results.length > 1) {\n      addon.data.progress.updateTaskSearchResult(this, results);\n    }\n  }\n  get searchResults() {\n    return this._searchResults;\n  }\n}\n\nexport class TaskRunner {\n  public runningTask: AttachmentTask | ScraperTask | null = null;\n  public tasks: (AttachmentTask | ScraperTask)[] = [];\n  getTaskType(\n    task: AttachmentTask | ScraperTask | string,\n  ): \"metaScraper\" | \"attachmentScraper\" {\n    let taskType: string;\n    if (typeof task === \"string\") {\n      taskType = task;\n    } else {\n      taskType = task.type;\n    }\n\n    if (taskType == \"attachment\" || taskType == \"snapshot\") {\n      return \"metaScraper\";\n    } else if (taskType == \"local\" || taskType == \"remote\") {\n      return \"attachmentScraper\";\n    } else {\n      throw new Error(`Unknown task type: ${taskType}`);\n    }\n  }\n  createTask(\n    item: Zotero.Item,\n    type: ScraperTaskType | AttachmentTaskType,\n    silent?: false,\n  ): ScraperTask | AttachmentTask {\n    const taskType = this.getTaskType(type);\n    let task: ScraperTask | AttachmentTask;\n    if (taskType === \"attachmentScraper\") {\n      task = new AttachmentTask(item, type as AttachmentTaskType, silent);\n    } else if (taskType === \"metaScraper\") {\n      task = new ScraperTask(item, type as ScraperTaskType, silent);\n    } else {\n      throw new Error(`Unknown task type: ${type}`);\n    }\n    // Set the default index for silent tasks\n    // If the task is silent, set the resultIndex to 0\n    if (silent) {\n      task.resultIndex = 0;\n    }\n    return task;\n  }\n\n  async addTask(\n    task: AttachmentTask | ScraperTask,\n  ): Promise<string | undefined> {\n    if (this.getTaskById(task.id)) {\n      ztoolkit.log(`Task with ID ${task.id} already exists.`);\n      if (addon.data.progress.progressWindow) {\n        addon.data.progress.progressWindow.alert(\n          getString(\"task-already-exists\", {\n            args: { title: task.item.getField(\"title\") },\n          }),\n        );\n      }\n      return;\n    }\n    this.tasks.push(task);\n    await addon.data.progress.addTaskToProgressWindow(task);\n    ztoolkit.log(`Task with ID ${task.id} added.`);\n    await this.runTask(task);\n    return task.id;\n  }\n\n  async createAndAddTask(\n    item: Zotero.Item,\n    type: ScraperTaskType | AttachmentTaskType,\n    silent?: false,\n  ): Promise<string> {\n    const task = this.createTask(item, type, silent);\n    task.addMsg(getString(\"task-msg-header\"));\n    task.addMsg(`Zotero version: ${Zotero.version}`);\n    task.addMsg(`Addon version: ${version}`);\n    await this.addTask(task);\n    return task.id;\n  }\n  getTaskById(id: string): Task | undefined {\n    return this.tasks.find((task) => task.id === id);\n  }\n\n  async runTask(task: AttachmentTask | ScraperTask): Promise<void> {\n    this.runningTask = task;\n    if (this.getTaskType(task) === \"attachmentScraper\") {\n      this.runAttachmentTask(task as AttachmentTask);\n    } else {\n      this.runScrapeTask(task as ScraperTask);\n    }\n    this.runningTask = null;\n  }\n\n  async runScrapeTask(task: ScraperTask): Promise<void> {\n    // Implement the logic to run the scrape task\n    ztoolkit.log(`Running scrape task with ID: ${task.id}`);\n    try {\n      await metaSearch(task);\n    } catch (e) {\n      ztoolkit.log(`Error in metaSearch: ${e}`);\n      task.addMsg(`Error in metaSearch: ${e}`);\n      task.status = \"fail\";\n      return;\n    }\n    // Wait for user select result.\n    if (task.status === \"multiple_results\") {\n      task.resultIndex = await task.deferred?.promise;\n    }\n    if (task.status != \"fail\") {\n      await metaTranslate(task);\n    }\n  }\n\n  async runAttachmentTask(task: AttachmentTask): Promise<void> {\n    await attachmentSearch(task);\n    // Wait for user select result.\n    if (task.status === \"multiple_results\") {\n      task.resultIndex = await task.deferred?.promise;\n    }\n    if (task.status != \"fail\") {\n      await importAttachment(task);\n    }\n  }\n\n  resumeTask(taskID: string, resultIndex: number): void {\n    const task = this.getTaskById(taskID);\n    if (task?.deferred) {\n      task.deferred.resolve(resultIndex);\n    }\n  }\n}\n"
  },
  {
    "path": "src/utils/wait.ts",
    "content": "/**\n * Wait until the condition is `true` or timeout.\n * The callback is triggered if condition returns `true`.\n * @param condition\n * @param callback\n * @param interval\n * @param timeout\n */\nexport function waitUntil(\n  condition: () => boolean,\n  callback: () => void,\n  interval = 100,\n  timeout = 10000,\n) {\n  const start = Date.now();\n  const intervalId = ztoolkit.getGlobal(\"setInterval\")(() => {\n    if (condition()) {\n      ztoolkit.getGlobal(\"clearInterval\")(intervalId);\n      callback();\n    } else if (Date.now() - start > timeout) {\n      ztoolkit.getGlobal(\"clearInterval\")(intervalId);\n    }\n  }, interval);\n}\n\n/**\n * Wait async until the condition is `true` or timeout.\n * @param condition\n * @param interval\n * @param timeout\n */\nexport function waitUtilAsync(\n  condition: () => boolean,\n  interval = 100,\n  timeout = 10000,\n) {\n  return new Promise<void>((resolve, reject) => {\n    const start = Date.now();\n    const intervalId = ztoolkit.getGlobal(\"setInterval\")(() => {\n      if (condition()) {\n        ztoolkit.getGlobal(\"clearInterval\")(intervalId);\n        resolve();\n      } else if (Date.now() - start > timeout) {\n        ztoolkit.getGlobal(\"clearInterval\")(intervalId);\n        reject();\n      }\n    }, interval);\n  });\n}\n"
  },
  {
    "path": "src/utils/window.ts",
    "content": "import { config } from \"../../package.json\";\nimport { waitUtilAsync } from \"./wait\";\n\n/**\n * Check if the window is alive.\n * Useful to prevent opening duplicate windows.\n * @param win\n */\nexport function isWindowAlive(win?: Window) {\n  return win && !Components.utils.isDeadWrapper(win) && !win.closed;\n}\n\n/**\n * Ensures that a given promise resolves within a specified timeout.\n * If the promise does not resolve within the timeout, it rejects with an error.\n * @param promise - The promise to wait for.\n * @param timeout - The maximum time to wait in milliseconds.\n * @param message - The error message to reject with if the promise does not resolve within the timeout.\n */\nexport async function waitNoMoreThan<T>(\n  promise: Promise<T>,\n  timeout: number = 3000,\n  message: string = \"Timeout\",\n) {\n  let resolved = false;\n\n  return Promise.any([\n    promise.then((result) => {\n      resolved = true;\n      return result;\n    }),\n    // @ts-ignore - Promise delay is not typed.\n    Zotero.Promise.delay(timeout).then(() => {\n      if (resolved) return;\n      throw new Error(message);\n    }),\n  ]);\n}\n\nexport function findWindow(type: string) {\n  const enumerator = Services.wm.getEnumerator(type);\n  if (enumerator.hasMoreElements()) {\n    // In this case, getNext will always return a window\n    const win = enumerator.getNext() as Window;\n    ztoolkit.log(`found window by type: ${type}, ${win.location.href}`);\n    return win;\n  }\n  ztoolkit.log(`not found window by type: ${type}`);\n  return null;\n}\n\nexport function observeWindowLoad(\n  uri: string,\n  callback: (win: Window) => unknown,\n) {\n  // After the window opens, wait for it to load\n  const loadObserver = function (event: Event) {\n    event.originalTarget?.removeEventListener(\"load\", loadObserver, false);\n    const href = (event.target as Window)?.location.href;\n    ztoolkit.log(`window loaded: ${href}`);\n\n    if (href != uri) {\n      return;\n    }\n    const win = event.target?.ownerGlobal;\n    // Give window code time to run on load\n    win?.setTimeout(function () {\n      callback(win);\n    });\n  };\n  // Ensure that the window is opened before listening for load\n  const winObserver = {\n    observe: function (subject: Window, topic: string, data: any) {\n      if (topic != \"domwindowopened\") return;\n      subject.addEventListener(\"load\", loadObserver, false);\n    },\n  } as nsIObserver;\n\n  Services.ww.registerNotification(winObserver);\n  // Unregister notifier when addon is disabled\n  Zotero.Plugins.addObserver({\n    shutdown: ({ id }) => {\n      if (id === config.addonID)\n        Services.ww.unregisterNotification(winObserver);\n    },\n  });\n}\n\nexport async function waitElmLoaded(\n  doc: Document,\n  selector: string,\n  timeout = 10000,\n): Promise<boolean> {\n  return new Promise((resolve, reject) => {\n    waitUtilAsync(() => !!doc.querySelector(selector), 100, timeout)\n      .then(() => {\n        ztoolkit.log(`element ${selector} in ${doc.location.href} loaded`);\n        resolve(true);\n      })\n      .catch(() => {\n        ztoolkit.log(\n          `timeout waiting for element ${selector} in ${doc.location.href}`,\n        );\n        reject(false);\n      });\n  });\n}\n"
  },
  {
    "path": "src/utils/ztoolkit.ts",
    "content": "import { ZoteroToolkit } from \"zotero-plugin-toolkit\";\nimport { config } from \"../../package.json\";\n\nexport { createZToolkit };\n\nfunction createZToolkit() {\n  const _ztoolkit = new ZoteroToolkit();\n  /**\n   * Alternatively, import toolkit modules you use to minify the plugin size.\n   * You can add the modules under the `MyToolkit` class below and uncomment the following line.\n   */\n  // const _ztoolkit = new MyToolkit();\n  initZToolkit(_ztoolkit);\n  return _ztoolkit;\n}\n\nfunction initZToolkit(_ztoolkit: ReturnType<typeof createZToolkit>) {\n  const env = __env__;\n  _ztoolkit.basicOptions.log.prefix = `[${config.addonName}]`;\n  _ztoolkit.basicOptions.log.disableConsole = false;\n  _ztoolkit.UI.basicOptions.ui.enableElementJSONLog = true;\n  _ztoolkit.UI.basicOptions.ui.enableElementDOMLog = true;\n  _ztoolkit.basicOptions.debug.disableDebugBridgePassword =\n    __env__ === \"development\";\n  _ztoolkit.basicOptions.api.pluginID = config.addonID;\n  _ztoolkit.ProgressWindow.setIconURI(\n    \"default\",\n    `chrome://${config.addonRef}/content/icons/icon.png`,\n  );\n}\n\nimport { BasicTool, unregister } from \"zotero-plugin-toolkit\";\nimport { UITool } from \"zotero-plugin-toolkit\";\n\nclass MyToolkit extends BasicTool {\n  UI: UITool;\n\n  constructor() {\n    super();\n    this.UI = new UITool(this);\n  }\n\n  unregisterAll() {\n    unregister(this);\n  }\n}\n"
  },
  {
    "path": "test/CNKI_translator_test.js",
    "content": "urls = [\n  \"https://kns.cnki.net/kcms2/article/abstract?v=WStw-PbchoxXklMd97G3EDrMY35-fvcPWOGNHjK8hkbbqADLh5NGc0AmBzjI4D8ZwjzI0VHODsFQlS8sdwe2eU_tJN9s3hzTpF8GC2jjom6r22HYP5NTbScHRtT5YFMOmmBgOTPkcgh2Bsw--3eXUh2HpUIUCy4q97DM744ETBHcmNUo8g9ZvnNyVq_LgaRN&uniplatform=NZKPT&language=CHS\",\n  \"https://kns.cnki.net/kcms2/article/abstract?v=WStw-PbchowBkjKe5MjlmHuD-RasNVJ8dt-b6UD3KtSDnt1n7QxJkch8TZaKbJ8-7MQj0xUrYGr09gE16oyAIvXEr-CiiCdkjjgSqFVq36YzC43jvtJ2f8hTOpY1PGy1XXfcCGLSIRM1wzVCGndk4adUcZQZlQHK2d03J4NCfegvuPcn9U-_NhqwCcaqf5w6&uniplatform=NZKPT&language=CHS\",\n  \"https://kns.cnki.net/kcms2/article/abstract?v=WStw-Pbchowjmw6mw3uKbpWLZ2thd4Ikj9aGW2c6LgczKWCuX2sbJIEMj6bB-0Myb9sYR96tSlb8Gk0R2Z5YBIcrMYuwuydhjwJOEbFIivRXFXmcPpCYR-7eh9QpC0Giq2tNOlOdx3rz-Z-fSV0yg_0xomFu3lvR2KqJ3Zf28JSRHc4XLMIunw9eaiJQVXkZ&uniplatform=NZKPT&language=CHS\",\n  \"https://kns.cnki.net/kcms2/article/abstract?v=WStw-Pbchozhh8Fgt64WtzqgcMwtTHrBrYlh7Q4yySg7hnJ_gZrfncEA9PcKga4FKUf8K3eYz08N-kJkGU6a490pkki0kvUVkunG1MGAhnigUdsqHsG2wsWHE_qELcwKOaJxO6eY8DTnp1B33RIvWXn7MIYquE-yHHXjmoQOk2vu5RZvFrEQBYLhzKS-EGmA&uniplatform=NZKPT&language=CHS\",\n  \"https://kns.cnki.net/kcms2/article/abstract?v=WStw-Pbchoza91b55tjwPPpLVRsheZOKpFgcAdm6LDyIObh40SwtF5bPj3e9eAkU_c4S8MDmOIWVqRv5OJ6eq-KGt0sSbL9666NHWAvybhtwrlr-ULB8eGMFZS_h01YjBrmxfsdYY0cXK4SYnSghWoce8plJZitosysJn497tMNlbBu19zoyZ5-G5ms_xGKc&uniplatform=NZKPT&language=CHS\",\n  \"https://kns.cnki.net/kcms2/article/abstract?v=WStw-Pbchozs-rV1BnuxrpzoDrIrhHINoJIdxyeKIPB5uWktgKly2H6ZJQQUSjRUVoPUCHXoyZLBT2eDcH5MrDol9zfNqvscmrbJfOFiKe2bkCEET1Voc00whH0Bu4xrNDK0j5RUMqKj2JH-EELIoBDNoj1nmj6lcvxK69GmV48jw6V6GBOlfa5grOY_WfBB&uniplatform=NZKPT&language=CHS\",\n  \"https://kns.cnki.net/kcms2/article/abstract?v=WStw-PbchozAYll_0WbZyNsWw69XWM7MujYLUcTpPk_wHBzcDHl6z8xIFKgbBP9GUZ9aGf3xAFtR4ludtNumAxX85oiU2rxkqQcMorrBsa9mvJm0YqwmGSbDSJixg-Sfl3jFOFbqE9hxs1wOIhddFbgjxBRi6P-nt1qdcyak53AgrKhuQ7zmthllMkRJhI4G&uniplatform=NZKPT&language=CHS\",\n  \"https://kns.cnki.net/kcms2/article/abstract?v=WStw-PbchoxEbdsGcNHq4lrTk8lk2vE82akoFjaBTi997kL-xCmHHFG8ddNmO7SYE8ibGqs4um70WtUldjIAcPyXe_UQ9_FZ7CrfrL1LkRsBTlBS8dg1um9MAQ2PUKnlEYe7jGB4s2dVwVpuRiIYVChSfZwqfit-ESOlDTf3yUU1IGYJXEZjmjBuDiN1yYTE&uniplatform=NZKPT&language=CHS\",\n  \"https://kns.cnki.net/kcms2/article/abstract?v=WStw-PbchozMxohk4EHQgPvZYZSpp2pf1Clf4BOwXKJW-wtnhxwYhRRBeURaHD2zPZ0ddjazGcg8BfOp4LAD4h9Cpn1ayUP2VlWH_6RkdOx6hI_uuWiYNJFz-yugVFEJvifSPBEHKkHRon3KEsfqvr-R83eYznYYIsa-pzPO4Jum2xGpudDoD5HuwW0UjJn6&uniplatform=NZKPT&language=CHS\",\n  \"https://kns.cnki.net/kcms2/article/abstract?v=WStw-Pbchoy5-_CNHo0wZ1paiWwks_fDQzOTRX44Y6bF7RG-Fpwv4BP68IIvRyWLrA5ae8uTjpDS7CxdemHyUTxIOoha7kIy0vBR3XEZvBSztPhhfGdWk8RxOlqNUqh4v3n-eBRQq0c_YhcPyvuSjfz5Zga5iA4ijhKzLMwUrRsgc0Ad7kbLc0dtOpz9nz3V&uniplatform=NZKPT&language=CHS\",\n];\n\nasync function getDoc(url) {\n  const xhr = await Zotero.HTTP.request(\"GET\", url, {\n    responseType: \"document\",\n  });\n  let doc = xhr.response;\n  if (doc && !doc.location) {\n    doc = Zotero.HTTP.wrapDocument(doc, xhr.responseURL);\n  }\n  return doc;\n}\n\nasync function translate(doc) {\n  const translate = new Zotero.Translate.Web();\n  // CNKI\n  translate.setTranslator(\"5c95b67b-41c5-4f55-b71a-48d5d7183063\");\n  translate.setDocument(doc);\n  const items = await translate.translate({\n    libraryID: false, // 不保存\n    saveAttachments: false,\n  });\n  const item = items[0];\n  return item;\n}\n\nfunction delay(ms) {\n  return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nasync function test(urls) {\n  for (let i of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) {\n    console.log(\"#####\" + i);\n    for (let url of urls) {\n      await delay(3000);\n      let d = await getDoc(url);\n      let item = await translate(d);\n      console.log(item.key, item.title, item.getField(\"publicationTitle\"));\n    }\n  }\n}\n"
  },
  {
    "path": "test/expert_china.json",
    "content": "{\n  \"boolSearch\": \"true\",\n  \"QueryJson\": {\n    \"Platform\": \"\",\n    \"Resource\": \"CROSSDB\",\n    \"Classid\": \"WD0FTY92\",\n    \"Products\": \"\",\n    \"QNode\": {\n      \"QGroup\": [\n        {\n          \"Key\": \"Subject\",\n          \"Title\": \"\",\n          \"Logic\": 0,\n          \"Items\": [\n            {\n              \"Key\": \"Expert\",\n              \"Title\": \"\",\n              \"Logic\": 0,\n              \"Field\": \"EXPERT\",\n              \"Operator\": 0,\n              \"Value\": \"TI+%+'黄瓜共表达'+and+AU='林行众'\",\n              \"Value2\": \"\"\n            }\n          ],\n          \"ChildItems\": []\n        },\n        {\n          \"Key\": \"ControlGroup\",\n          \"Title\": \"\",\n          \"Logic\": 0,\n          \"Items\": [],\n          \"ChildItems\": []\n        }\n      ]\n    },\n    \"ExScope\": \"1\",\n    \"SearchType\": 4,\n    \"Rlang\": \"CHINESE\",\n    \"KuaKuCode\": \"YSTT4HG0,LSTPFY1C,JUP3MUPD,MPMFIG1A,WQ0UVIAA,BLZOG7CK,PWFIRAGL,EMRPGLPA,NLBO1Z6R,NN3FJMUV\",\n    \"SearchFrom\": 1\n  },\n  \"pageNum\": \"1\",\n  \"pageSize\": \"20\",\n  \"sortField\": \"\",\n  \"sortType\": \"\",\n  \"dstyle\": \"listmode\",\n  \"productStr\": \"YSTT4HG0,LSTPFY1C,RMJLXHZ3,JQIRZIYA,JUP3MUPD,1UR4K4HZ,BPBAFJ5S,R79MZMCB,MPMFIG1A,WQ0UVIAA,NB3BWEHK,XVLO76FD,HR1YT1Z9,BLZOG7CK,PWFIRAGL,EMRPGLPA,J708GVCE,ML4DRIDX,NLBO1Z6R,NN3FJMUV,\",\n  \"aside\": \"(TI+%+&#39;黄瓜共表达&#39;+and+AU=&#39;林行众&#39;)\",\n  \"searchFrom\": \"资源范围：总库;++中英文扩展;++时间范围：更新时间：不限;++\",\n  \"CurPage\": \"1\"\n}\n"
  },
  {
    "path": "test/expert_oversea.json",
    "content": "{\n  \"IsSearch\": \"true\",\n  \"QueryJson\": {\n    \"Platform\": \"\",\n    \"DBCode\": \"CFLS\",\n    \"KuaKuCode\": \"CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFN\",\n    \"QNode\": {\n      \"QGroup\": [\n        {\n          \"Key\": \"Subject\",\n          \"Title\": \"\",\n          \"Logic\": 4,\n          \"Items\": [\n            {\n              \"Key\": \"Expert\",\n              \"Title\": \"\",\n              \"Logic\": 0,\n              \"Name\": \"\",\n              \"Operate\": \"\",\n              \"Value\": \"TI+%+'黄瓜共表达'+and+AU='林行众'\",\n              \"ExtendType\": 12,\n              \"ExtendValue\": \"中英文对照\",\n              \"Value2\": \"\",\n              \"BlurType\": \"\"\n            }\n          ],\n          \"ChildItems\": []\n        },\n        {\n          \"Key\": \"ControlGroup\",\n          \"Title\": \"\",\n          \"Logic\": 1,\n          \"Items\": [],\n          \"ChildItems\": []\n        }\n      ]\n    },\n    \"ExScope\": 1,\n    \"CodeLang\": \"\"\n  },\n  \"PageName\": \"AdvSearch\",\n  \"DBCode\": \"CFLS\",\n  \"KuaKuCodes\": \"CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFQ,CDMD,CIPD,CCND,CYFD,CCJD,BDZK,CISD,CJFN\",\n  \"CurPage\": \"1\",\n  \"RecordsCntPerPage\": \"20\",\n  \"CurDisplayMode\": \"listmode\",\n  \"CurrSortField\": \"\",\n  \"CurrSortFieldType\": \"desc\",\n  \"IsSentenceSearch\": \"false\",\n  \"Subject\": \"\"\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"experimentalDecorators\": true,\n    \"module\": \"commonjs\",\n    \"target\": \"ES2016\",\n    \"resolveJsonModule\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true\n  },\n  \"include\": [\"src\", \"typings\", \"node_modules/zotero-types\"],\n  \"exclude\": [\"build\", \"addon\"]\n}\n"
  },
  {
    "path": "typings/attachment.d.ts",
    "content": "interface AttachmentService {\n  searchAttachments(\n    task: AttachmentTask,\n  ): Promise<AttachmentSearchResult[] | null>;\n  importAttachment(task: AttachmentTask): Promise<void>;\n}\n\ntype AttachmentSearchResult = {\n  title: string;\n  filename: string;\n  score?: number;\n  url: string;\n  source?: string;\n};\n\ninterface AttachmentTask extends Task {\n  type: AttachmentTaskType;\n  searchResults?: AttachmentSearchResult[];\n}\n"
  },
  {
    "path": "typings/global.d.ts",
    "content": "declare const _globalThis: {\n  [key: string]: any;\n  Zotero: _ZoteroTypes.Zotero;\n  ztoolkit: ZToolkit;\n  addon: typeof addon;\n};\n\ndeclare type ZToolkit = ReturnType<\n  typeof import(\"../src/utils/ztoolkit\").createZToolkit\n>;\n\ndeclare const ztoolkit: ZToolkit;\n\ndeclare const rootURI: string;\n\ndeclare const addon: import(\"../src/addon\").default;\n\ndeclare const __env__: \"production\" | \"development\";\n"
  },
  {
    "path": "typings/i10n.d.ts",
    "content": "// Generated by zotero-plugin-scaffold\n/* prettier-ignore */\n/* eslint-disable */\n// @ts-nocheck\nexport type FluentMessageId =\n  | 'CNKIcitation'\n  | 'action-after-import'\n  | 'action-after-import-desc'\n  | 'attachment-folder-desc'\n  | 'backup-label'\n  | 'bookmark'\n  | 'bookmark-add'\n  | 'bookmark-delete'\n  | 'citation'\n  | 'confirm-close'\n  | 'delete-label'\n  | 'get-Chinese-styles'\n  | 'github-link'\n  | 'help-menu-addons'\n  | 'help-menu-chinese'\n  | 'help-menu-csl'\n  | 'help-menu-translator'\n  | 'help-menu-wiki'\n  | 'how-to-update-translators'\n  | 'import-attachments-success'\n  | 'importing-attachments-is-running'\n  | 'info-best-speed-source-failed'\n  | 'info-best-speed-source-updated'\n  | 'info-translators-cn-updaing'\n  | 'item-section-example1-head-text'\n  | 'item-section-example1-sidenav-tooltip'\n  | 'item-section-example2-button-tooltip'\n  | 'item-section-example2-head-text'\n  | 'item-section-example2-sidenav-tooltip'\n  | 'label-auto-split-name'\n  | 'label-auto-update-translators'\n  | 'label-autoupdate-metadata'\n  | 'label-best-speed'\n  | 'label-choose-folder'\n  | 'label-choose-namepattern'\n  | 'label-choose-source'\n  | 'label-disableZoteroOutline'\n  | 'label-enableBookmark'\n  | 'label-install-wps-plugin-click'\n  | 'label-isMainlandChina'\n  | 'label-language'\n  | 'label-metadata-source'\n  | 'label-metadata-source-cnki'\n  | 'label-metadata-source-cvip'\n  | 'label-namepattern'\n  | 'label-namepattern-auto'\n  | 'label-namepattern-custom'\n  | 'label-namepattern-info'\n  | 'label-namepattern-t'\n  | 'label-namepattern-tg'\n  | 'label-pdf-match-folder'\n  | 'label-rename'\n  | 'label-split-en-name'\n  | 'label-tools-info-1'\n  | 'label-tools-info-2'\n  | 'label-tools-linter'\n  | 'label-translator-source'\n  | 'label-translators-detail'\n  | 'label-translators-detail-click'\n  | 'label-translators-force-update'\n  | 'label-wps'\n  | 'label-wps-help'\n  | 'label-zotero-chinese'\n  | 'menu-metadata'\n  | 'menu-tools'\n  | 'menuitem-find-attachment'\n  | 'menuitem-import-attachments'\n  | 'menuitem-mergeName'\n  | 'menuitem-retrieveMetadata'\n  | 'menuitem-retrieveMetadataForBook'\n  | 'menuitem-splitName'\n  | 'menuitem-updateCNKICite'\n  | 'namepattern-desc'\n  | 'no-attachments-found'\n  | 'no-chinese-item-for-citation'\n  | 'no-item-need-attachment'\n  | 'nothing-label'\n  | 'outline'\n  | 'outline-add'\n  | 'outline-collapse-all'\n  | 'outline-delete'\n  | 'outline-delete-confirm'\n  | 'outline-desc'\n  | 'outline-edit-placeholder'\n  | 'outline-empty-prompt'\n  | 'outline-expand-all'\n  | 'outline-save-to-pdf'\n  | 'plugin-name'\n  | 'pref-enable'\n  | 'pref-group-about'\n  | 'pref-group-attachment'\n  | 'pref-group-bookmark'\n  | 'pref-group-metadata'\n  | 'pref-group-tools'\n  | 'pref-group-translators'\n  | 'pref-group-wps'\n  | 'pref-help'\n  | 'prefs-table-detail'\n  | 'prefs-table-title'\n  | 'report-translator-bug'\n  | 'request-new-translator'\n  | 'result-score'\n  | 'result-source'\n  | 'result-title'\n  | 'search-box'\n  | 'select-download-folder'\n  | 'tabpanel-lib-tab-label'\n  | 'tabpanel-reader-tab-label'\n  | 'task-already-exists'\n  | 'task-list'\n  | 'task-msg-header'\n  | 'th-filename'\n  | 'th-label'\n  | 'th-local-update-time'\n  | 'th-remote-update-time'\n  | 'title'\n  | 'translatorSource-desc'\n  | 'translators-dashboard'\n  | 'update-failed'\n  | 'update-skipped'\n  | 'update-successfully'\n  | 'update-translators-complete'\n  | 'update-translators-start';\n"
  },
  {
    "path": "typings/myzotero.d.ts",
    "content": "declare namespace Zotero {\n  /**\n   * Cookie 对象的内部存储结构\n   */\n  interface CookieData {\n    /** Cookie 的值 */\n    value: string;\n    /** 是否为 secure cookie */\n    secure: boolean;\n    /** 是否为 host-only cookie */\n    hostOnly: boolean;\n  }\n\n  /**\n   * Cookie 存储的内部结构\n   * 格式: { \".host\": { \"/path\": { \"cookieName\": CookieData } } }\n   */\n  interface CookieStorage {\n    [host: string]: {\n      [path: string]: {\n        [name: string]: CookieData;\n      };\n    };\n  }\n\n  /**\n   * getCookiesForURI 返回的简单 cookie 对象\n   * 格式: { \"cookieName\": \"cookieValue\" }\n   */\n  interface CookieDict {\n    [name: string]: string;\n  }\n\n  /**\n   * Manage cookies in a sandboxed fashion\n   */\n  class CookieSandbox {\n    /**\n     * Internal cookie storage\n     * @internal\n     */\n    _cookies: CookieStorage;\n\n    /**\n     * User agent string to use for sandboxed requests\n     */\n    userAgent?: string;\n\n    /**\n     * Create a new CookieSandbox instance\n     *\n     * @param browser - Hidden browser object\n     * @param uri - URI of page to manage cookies for (cookies for domains that are not subdomains of this URI are ignored)\n     * @param cookieData - Cookies with which to initiate the sandbox\n     * @param userAgent - User agent to use for sandboxed requests\n     *\n     * @example\n     * ```typescript\n     * // Create an empty sandbox\n     * const sandbox = new Zotero.CookieSandbox();\n     *\n     * // Create with initial cookies\n     * const sandbox = new Zotero.CookieSandbox(\n     *   null,\n     *   \"https://example.com\",\n     *   \"sessionId=abc123; userId=456\"\n     * );\n     * ```\n     */\n    constructor(\n      browser?: any,\n      uri?: string | Components.interfaces.nsIURI,\n      cookieData?: string,\n      userAgent?: string,\n    );\n\n    /**\n     * Clone this CookieSandbox\n     *\n     * @returns A deep copy of this CookieSandbox\n     */\n    clone(): CookieSandbox;\n\n    /**\n     * Add cookies to this CookieSandbox based on a cookie header\n     *\n     * @param cookieString - Cookie header string (can contain multiple cookies separated by newlines)\n     * @param uri - URI of the header origin. Used to verify same origin. If omitted, validation is not performed\n     */\n    addCookiesFromHeader(\n      cookieString: string,\n      uri?: Components.interfaces.nsIURI,\n    ): void;\n\n    /**\n     * Attach CookieSandbox to a specific browser\n     *\n     * @param browser - Browser element to attach to\n     */\n    attachToBrowser(browser: any): void;\n\n    /**\n     * Attach CookieSandbox to a specific XMLHttpRequest\n     *\n     * @param ir - Interface requestor\n     */\n    attachToInterfaceRequestor(\n      ir: Components.interfaces.nsIInterfaceRequestor | any,\n    ): void;\n\n    /**\n     * Set a cookie for a specified host\n     *\n     * @param cookiePair - A single cookie pair in the form \"key=value\"\n     * @param host - Host to bind the cookie to\n     * @param path - Cookie path (defaults to \"/\")\n     * @param secure - Whether the cookie has the secure attribute set\n     * @param hostOnly - Whether the cookie is a host-only cookie\n     */\n    setCookie(\n      cookiePair: string,\n      host: string,\n      path?: string,\n      secure?: boolean,\n      hostOnly?: boolean,\n    ): void;\n\n    /**\n     * Returns a list of cookies that should be sent to the given URI\n     *\n     * @param uri - The URI to get cookies for (must be nsIURI object, not string)\n     * @returns Object containing cookie name-value pairs, or null if no cookies found\n     */\n    getCookiesForURI(uri: Components.interfaces.nsIURI): CookieDict | null;\n\n    /**\n     * Internal method to get cookies for a specific path\n     * @internal\n     */\n    _getCookiesForPath(\n      cookies: CookieDict,\n      cookiePaths: any,\n      pathParts: string[],\n      secure: boolean,\n      isHost: boolean,\n    ): boolean;\n  }\n\n  namespace CookieSandbox {\n    /**\n     * Initialize the CookieSandbox observer\n     */\n    function init(): void;\n\n    /**\n     * Normalize the host string: lower-case, remove leading period, some more cleanup\n     *\n     * @param host - Host string to normalize\n     * @returns Normalized host string\n     */\n    function normalizeHost(host: string): string;\n\n    /**\n     * Normalize the path string\n     *\n     * @param path - Path string to normalize\n     * @returns Normalized path string\n     */\n    function normalizePath(path: string): string;\n\n    /**\n     * Generate a semicolon-separated string of cookie values from a cookie object\n     *\n     * @param cookies - Object containing key-value cookie pairs\n     * @returns Cookie string in format \"name1=value1; name2=value2\"\n     */\n    function generateCookieString(cookies: CookieDict): string;\n\n    /**\n     * Observer for managing cookies across different contexts\n     */\n    namespace Observer {\n      /** WeakMap of browsers tracked by CookieSandbox */\n      const trackedBrowsers: WeakMap<any, CookieSandbox>;\n\n      /** WeakMap of interface requestors tracked by CookieSandbox */\n      const trackedInterfaceRequestors: WeakMap<any, CookieSandbox>;\n\n      /**\n       * Register the cookie observer\n       */\n      function register(): void;\n\n      /**\n       * Observe HTTP events to manage cookies\n       *\n       * @param channel - HTTP channel\n       * @param topic - Observer topic\n       */\n      function observe(channel: any, topic: string): void;\n    }\n  }\n}\n"
  },
  {
    "path": "typings/notifier.d.ts",
    "content": ""
  },
  {
    "path": "typings/outline.d.ts",
    "content": "type OutlineNode = {\n  level: number;\n  title: string;\n  page: number;\n  x: number;\n  y: number;\n  children?: OutlineNode[];\n  collapsed?: boolean;\n  ref?: PDFRef;\n};\n\ntype OutlineInfo = {\n  info: Record<string, string | number> & {\n    baseFontSize?: number; // Base font size for level-1, default 12\n  };\n  outline: OutlineNode[];\n};\n\n// Reference of PDF object\n// type PdfRef = {\n//   num: number;\n//   gen: number;\n//   tag?: string; // 可能是 \"Page\" 或 \"Outline\"\n// };\n\ntype PdfZoomMode = {\n  name: string; // 缩放模式名称，例如 \"Fit\", \"XYZ\", \"FitH\", \"FitV\"\n  args?: (number | null)[];\n};\n\ntype PdfDest = { dest: [PDFRef, PdfZoomMode] };\ntype PdfPosition = {\n  position: { pageIndex: number; rects: [number, number, number, number][] };\n};\n\ntype PdfOutlineNode = {\n  title: string;\n  items: PdfOutlineNode[];\n  location: PdfDest | PdfPosition; // 没有遇到 PdfDest 的情况\n};\n\n// 书签相关类型定义\ntype BookmarkNode = {\n  id: string; // 唯一标识符\n  title: string;\n  page: number;\n  x: number;\n  y: number;\n  order: number; // 用于排序，书签没有层级关系\n  createdAt: number; // 创建时间戳\n  color: string; // 书签颜色\n};\n\ntype BookmarkInfo = {\n  info: {\n    itemID: number;\n    schema: number;\n    jasminumVersion: string;\n    baseFontSize?: number; // outline 12, bookmark 13\n  };\n  bookmarks: BookmarkNode[];\n};\n"
  },
  {
    "path": "typings/pdfParser.d.ts",
    "content": "type RecognizerData = {\n  metadata: {\n    [key: string]: string;\n  };\n  totalPages: number;\n  pages: RecognizerPage[];\n};\n\ntype PdfData = {\n  metadata: {\n    [key: string]: string;\n  };\n  totalPages: number;\n  pages: PdfPage[];\n};\n\ntype RecognizerPage = {\n  // pageWidth\n  0: number;\n  // pageHeight\n  1: number;\n  2: [[[[0, 0, 0, 0, RecognizerLine[]]]]];\n};\n\ntype PdfPage = {\n  width: number;\n  height: number;\n  text: string;\n  classList: string[];\n  paragraphs: PdfParagraph[];\n};\n\ntype PdfParagraph = {\n  fontSize: string;\n  text: string;\n  classList: string[];\n  lines: PdfLine[];\n};\n\ntype RecognizerLine = [RecognizerWord[]];\n\ntype PdfLine = {\n  xMin: number;\n  yMin: number;\n  xMax: number;\n  yMax: number;\n  fontSize: string;\n  baseline: number;\n  text: string;\n  words: PdfWord[];\n};\n\ntype RecognizerWord = [\n  // 0: xMin\n  number,\n  // 1: yMin\n  number,\n  // 2: xMax\n  number,\n  // 3: yMax\n  number,\n  // 4: fontSize\n  number,\n  // 5: spaceAfter\n  0 | 1,\n  // 6: baseline\n  number,\n  // 7: rotation\n  0,\n  // 8: underlined\n  0,\n  // 9: bold\n  0 | 1,\n  // 10: italic\n  0 | 1,\n  // 11: colorIndex\n  0,\n  // 12: fontIndex\n  number,\n  // 13: text\n  string,\n];\n\ntype PdfWord = {\n  xMin: number;\n  yMin: number;\n  xMax: number;\n  yMax: number;\n  fontSize: number;\n  spaceAfter: boolean;\n  baseline: number;\n  rotation: boolean;\n  underlined: boolean;\n  bold: boolean;\n  italic: boolean;\n  colorIndex: number;\n  fontIndex: number;\n  text: string;\n};\n\ntype DocType = \"article\" | \"thesis\" | \"book\";\n"
  },
  {
    "path": "typings/prefs.d.ts",
    "content": "// Generated by zotero-plugin-scaffold\n/* prettier-ignore */\n/* eslint-disable */\n// @ts-nocheck\n\n// prettier-ignore\ndeclare namespace _ZoteroTypes {\n  interface Prefs {\n    PluginPrefsMap: {\n      \"firstRun\": boolean;\n      \"translatorsMended\": boolean;\n      \"autoSplitName\": boolean;\n      \"splitEnName\": boolean;\n      \"language\": string;\n      \"autoUpdateMetadata\": boolean;\n      \"namePattern\": string;\n      \"namePatternCustom\": string;\n      \"metadataSource\": string;\n      \"isMainlandChina\": boolean;\n      \"cnkiAttachmentCookie\": string;\n      \"similarityThresholdForMetaData\": string;\n      \"pdfMatchFolder\": string;\n      \"actionAfterAttachmentImport\": string;\n      \"similarityThreshold\": string;\n      \"topMatchCount\": number;\n      \"autoUpdateTranslators\": boolean;\n      \"translatorUpdateTime\": string;\n      \"translatorSource\": string;\n      \"enableBookmark\": boolean;\n      \"newNodeAsChild\": boolean;\n      \"disableZoteroOutline\": boolean;\n    };\n  }\n}\n"
  },
  {
    "path": "typings/scrape.d.ts",
    "content": "interface ScrapeService {\n  search(searchOption: SearchOption): Promise<ScrapeSearchResult[] | null>;\n  searchSnapshot?(task: ScrapeTask): Promise<ScrapeSearchResult[] | null>;\n  translate(\n    searchResult: ScrapeSearchResult,\n    libraryID: number,\n    saveAttachments: false,\n  ): Promise<Zotero.Item[]>;\n  translateSnapshot?(task: ScrapeTask): Promise<Zotero.Item | null | undefined>;\n}\n\ntype SearchOption = {\n  author?: string;\n  title: string;\n};\n\ntype ScrapeSearchResult = {\n  source: string;\n  title: string;\n  url: string;\n  [key: string]: string | number | null;\n};\n\ntype TaskStatus =\n  | \"waiting\"\n  | \"processing\"\n  | \"multiple_results\"\n  | \"success\"\n  | \"fail\";\ntype ScraperTaskType = \"attachment\" | \"snapshot\";\ntype AttachmentTaskType = \"local\" | \"remote\";\ninterface Task {\n  id: string;\n  type: string;\n  item: Zotero.Item;\n  resultIndex?: 0;\n  status: TaskStatus;\n  silent?: boolean;\n  message?: string;\n  addMsg: (msg: string) => void;\n  deferred?: DeferredResult;\n  searchResults?: any[];\n}\n\ninterface ScrapeTask extends Task {\n  type: ScraperTaskType;\n  searchResults?: ScrapeSearchResult[];\n}\n\n// 定义 Deferred 类型，用于等待用户输入，选择合适的结果索引\ntype DeferredResult<T = any> = {\n  promise: Promise<T>;\n  resolve: (value: T) => void;\n  reject: (reason?: any) => void;\n};\n"
  },
  {
    "path": "typings/translators.d.ts",
    "content": "type LastUpdatedMap = {\n  [filename: string]: { label: string; lastUpdated: string };\n};\n\ntype TableRow = {\n  filename: string;\n  label: string;\n  localUpdateTime: string;\n  remoteUpdateTime: string;\n};\n"
  },
  {
    "path": "zotero-plugin.config.ts",
    "content": "import { defineConfig } from \"zotero-plugin-scaffold\";\nimport pkg from \"./package.json\";\n\nexport default defineConfig({\n  source: [\"src\", \"addon\"],\n  dist: \"build\",\n  name: pkg.config.addonName,\n  id: pkg.config.addonID,\n  xpiName: `${pkg.config.addonRef}_${pkg.version}`,\n  namespace: pkg.config.addonRef,\n  updateURL: `https://github.com/{{owner}}/{{repo}}/releases/download/release/${\n    pkg.version.includes(\"-\") ? \"update-beta.json\" : \"update.json\"\n  }`,\n  xpiDownloadLink:\n    \"https://github.com/{{owner}}/{{repo}}/releases/download/v{{version}}/{{xpiName}}.xpi\",\n\n  build: {\n    assets: [\"addon/**/*.*\"],\n    define: {\n      ...pkg.config,\n      author: pkg.author,\n      description: pkg.description,\n      homepage: pkg.homepage,\n      buildVersion: pkg.version,\n      buildTime: \"{{buildTime}}\",\n    },\n    prefs: {\n      prefix: pkg.config.prefsPrefix,\n    },\n    esbuildOptions: [\n      {\n        // entryPoints: [\"src/index.ts\"],\n        entryPoints: [\n          { in: \"src/index.ts\", out: pkg.config.addonRef },\n          {\n            in: \"src/modules/workers/index.ts\",\n            out: `${pkg.config.addonRef}-worker`,\n          },\n        ],\n        outdir: \"build/addon/chrome/content/scripts\",\n        define: {\n          __env__: `\"${process.env.NODE_ENV}\"`,\n        },\n        bundle: true,\n        target: \"firefox115\",\n        // outfile: `build/addon/chrome/content/scripts/${pkg.config.addonRef}.js`,\n      },\n    ],\n  },\n\n  // If you need to see a more detailed log, uncomment the following line:\n  // logLevel: \"trace\",\n});\n"
  }
]