[
  {
    "path": ".babelrc",
    "content": "{\n    \"presets\": [\n        \"@babel/preset-env\"\n    ]\n}\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: publish release version\n\non:\n  push:\n    tags:\n      - \"v*\"\n\njobs:\n  build:\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v4\n      - uses: pnpm/action-setup@v4\n        with:\n          version: 9.14.4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 24\n          cache: \"pnpm\"\n      - run: pnpm install\n      - run: pnpm build+zip\n      - uses: actions/upload-artifact@v4\n        with:\n          name: build-artifacts\n          path: build\n  deploy-web:\n    needs: build\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/download-artifact@v4\n        with:\n          name: build-artifacts\n          path: build\n      - name: Deploy to GitHub Pages\n        uses: JamesIves/github-pages-deploy-action@v4\n        with:\n          folder: build/web\n  create-release:\n    needs: build\n    runs-on: ubuntu-24.04\n    outputs:\n      upload_url: ${{ steps.create-release.outputs.upload_url }}\n    steps:\n      - uses: actions/checkout@v4\n      - name: Extract Release Notes\n        id: extract_notes\n        shell: bash\n        run: |\n          LOG_FILE=\"CHANGELOG.md\"\n          \n          if [ -f \"$LOG_FILE\" ]; then\n            # 使用 awk 提取：从第一个 \"## \" 后面开始，直到遇到下一个 \"## \" 停止\n            # 这里的逻辑假设最新日志在文件最上方\n            NOTES=$(awk '/^## / {if (p) exit; p=1; next} p' \"$LOG_FILE\")\n          else\n            NOTES=\"Release ${{ github.ref }}\"\n          fi\n          \n          # 处理多行文本并写入 GITHUB_OUTPUT\n          {\n            echo \"body<<EOF\"\n            echo \"$NOTES\"\n            echo \"EOF\"\n          } >> \"$GITHUB_OUTPUT\"\n\n      - uses: actions/create-release@v1\n        id: create-release\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          tag_name: ${{ github.ref }}\n          release_name: Release ${{ github.ref }}\n          body: ${{ steps.extract_notes.outputs.body }}\n          draft: false\n          prerelease: false\n  upload-release:\n    needs: [build, create-release]\n    strategy:\n      matrix:\n        client: [\"chrome\", \"edge\", \"firefox\", \"userscript\", \"thunderbird\"]\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/download-artifact@v4\n        with:\n          name: build-artifacts\n          path: build\n      - uses: actions/upload-release-asset@v1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          upload_url: ${{ needs.create-release.outputs.upload_url }}\n          asset_path: ./build/${{ matrix.client }}.zip\n          asset_name: kiss-translator_${{ github.ref_name }}_${{ matrix.client }}.zip\n          asset_content_type: application/zip\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n/.obsidian\n.pnp.js\n.yarn\n\n# testing\n/coverage\n\n# production\n/build\n\n# misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npackage-lock.json\n\n*.crx\n*.pem\n*.zip\n"
  },
  {
    "path": ".pnpm-version",
    "content": "9.14.4\n"
  },
  {
    "path": ".prettierignore",
    "content": "node_modules\nbuild\npublic\npackage.json\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"arrowParens\": \"always\",\n  \"bracketSpacing\": true,\n  \"endOfLine\": \"lf\",\n  \"htmlWhitespaceSensitivity\": \"css\",\n  \"insertPragma\": false,\n  \"singleAttributePerLine\": false,\n  \"bracketSameLine\": false,\n  \"jsxSingleQuote\": false,\n  \"printWidth\": 80,\n  \"proseWrap\": \"preserve\",\n  \"quoteProps\": \"as-needed\",\n  \"requirePragma\": false,\n  \"semi\": true,\n  \"singleQuote\": false,\n  \"tabWidth\": 2,\n  \"trailingComma\": \"es5\",\n  \"useTabs\": false,\n  \"embeddedLanguageFormatting\": \"auto\",\n  \"vueIndentScriptAndStyle\": false,\n  \"experimentalTernaries\": false,\n  \"parser\": \"babel\"\n}\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "## v2.0.20\n\n- 优化本地语言识别不准确时的处理逻辑\n- 修复 MacOS 编译 Safari 插件脚本错误\n- 优化调整划词翻译逻辑\n  - 修复移动端不显示header的bug\n  - 仅对翻译框的大小和位置持久化，其他设置不持久化，仅在当前页面生效\n  - 修复翻译框有时会突然变成0,0位置，和0,0大小的bug\n- 修复移动端输入框翻译圆点按钮无效的bug\n- youtube视频右边的字幕滚动列表可以在设置中选择关闭\n- 接口设置中增加`复制接口`功能\n- 修复接口权重排序在popup无效的bug\n- 规则设置增加`扫描全部节点`的功能，并替换popup中`shadowroot`的位置\n  - `扫描全部节点`相当于忽略所有`忽略元素`，翻译全部内容，并同时扫描`shadowroot`\n- 更新内置部分规则，新安装时会尝试同步订阅规则\n- 其他一下小优化\n\n## v2.0.19\n\n- 修复油猴脚本切换翻译时的脚本错误\n- 添加自动更新版本号脚本\n\n## v2.0.18\n\n- 支持翻译closed模式的 shadowroot 中的内容\n- 保存规则时，可选子域名\n- AI聚合翻译，支持流式传输，优化翻译体验\n- 优化划词翻译窗口显示效果\n  - 划词翻译按钮增加自动隐藏逻辑（5秒 / 移动100px / 右键）\n- 增加翻译状态的图标显示，已翻译会有一个绿色小勾\n- 优化输入框翻译，解决一些兼容性问题\n  - 可设置在输入框上方显示一个圆形翻译按钮，使得移动端也可以使用输入框翻译功能\n- 优化字幕翻译，增加一些新功能\n  - 字幕翻译设置样式时，支持可视化编辑\n  - 优化字幕翻译逻辑，优先使用用户选择的字幕轨\n  - 字幕翻译右侧的字幕滚动列表可以手动点击按钮关闭\n  - 字幕翻译的菜单去掉shadowroot\n  - 增加下拉选项，可以随时切换AI断句的模型\n  - 重构字幕增强功能设置，新增“移动端禁用”选项（默认）\n- 优化移动端检测逻辑，修复触屏笔记本被误判为移动端的问题\n- 在所有场景下均给AI接口添加上下文信息，包括网页标题、描述、摘要\n- 翻译接口增加排序权重\n- 新增占位标签格式，优化google2的翻译效果\n- bing词典增加显示单词时态信息\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://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 General Public License is a free, copyleft license for\nsoftware and other kinds of works.\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,\nthe GNU General Public License is 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.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\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  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\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 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. Use with the GNU Affero General Public License.\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 Affero 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 special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe 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 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 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 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 General Public License as published by\n    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 General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\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 GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>.\n"
  },
  {
    "path": "README.en.md",
    "content": "# KISS Translator\n\n[English](README.en.md) | [中文](README.md) | [日本語](README.ja.md) | [한국어](README.ko.md)\n\nA simple, open source [bilingual translation extension & Greasemonkey script](https://github.com/fishjar/kiss-translator).\n\n[kiss-translator.webm](https://github.com/fishjar/kiss-translator/assets/1157624/f7ba8a5c-e4a8-4d5a-823a-5c5c67a0a47f)\n\n## Features\n\n- [x] Keep it simple, smart\n- [x] Open source\n- [x] Adapt to common browsers\n  - [x] Chrome/Edge\n  - [x] Firefox\n  - [x] Kiwi (Android)\n  - [x] Orion (iOS)\n  - [x] Safari\n  - [x] Thunderbird\n- [x] Supports multiple translation services\n  - [x] Google/Microsoft\n  - [x] Tencent/Volcengine\n  - [x] OpenAI/Gemini/Claude/Ollama/DeepSeek/OpenRouter\n  - [x] DeepL/DeepLX/NiuTrans\n  - [x] AzureAI / CloudflareAI\n  - [x] Chrome built-in AI translation (BuiltinAI)\n- [x] Covers common translation scenarios\n  - [x] Webpage bilingual translation\n  - [x] Input-box translation\n    - Instantly translate text in input fields into other languages via shortcut keys\n  - [x] Text selection translation\n    - [x] Open translation popup on any page, support multiple translation services for comparison\n    - [x] English dictionary lookup\n    - [x] Save vocabulary\n  - [x] Hover translation\n  - [x] YouTube subtitle translation\n    - Support translating video subtitles with any translation service and display bilingually\n    - Built-in basic subtitle merging and sentence-splitting algorithm to improve translation quality\n    - Supports AI-powered sentence segmentation for even better translation\n    - Custom subtitle style\n- [x] Supports diverse translation modes\n  - [x] Supports both automatic text recognition and manual rule modes\n    - Automatic text recognition mode allows most sites to be translated fully without writing rules\n    - Manual rule mode enables extreme optimization for specific sites\n  - [x] Custom translation styling\n  - [x] Supports rich-text translation and rendering, preserving links and other text styles where possible\n  - [x] Option to show only translation (hide original text)\n- [x] Advanced translation API features\n  - [x] With custom API support, theoretically works with any translation service\n  - [x] Batch aggregation of translation requests\n  - [x] Supports streaming for real-time translation results\n  - [x] Supports AI conversation context memory to improve translation quality\n  - [x] Custom AI terminology dictionary\n  - [x] All APIs support hooks and custom parameters for advanced usage\n- [x] Cross-client data synchronization\n  - [x] KISS-Worker（cloudflare/docker）\n  - [x] WebDAV\n- [x] Custom translation rules\n  - [x] Rule subscription/rule sharing\n  - [x] Customized terminology\n- [x] Custom shortcut keys\n  - `Alt+Q` Toggle Translation\n  - `Alt+C` Toggle Styles\n  - `Alt+K` Open Setting Popup\n  - `Alt+S` Open Translate Popup / Translate Selected Text\n  - `Alt+O` Open Options Page\n  - `Alt+I` Input Box Translation\n\n## Install\n\n> Note: For the following reasons, it is recommended to use browser extensions first\n>\n> - Browser extensions have more complete functions (local language recognition, context menu, etc.)\n> - Grease Monkey script will encounter more usage problems (cross domain issues, script conflicts, etc.)\n\n- [x] Browser extension\n  - [x] Chrome [Installation address](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=en)\n    - [x] Kiwi (Android)\n    - [x] Orion (iOS)\n  - [x] Edge [Installation address](https://microsoftedge.microsoft.com/addons/detail/%E7%AE%80%E7%BA%A6%E7%BF%BB%E8%AF%91/jemckldkclkinpjighnoilpbldbdmmlh?hl=en)\n  - [x] Firefox [Installation address](https://addons.mozilla.org/en-US/firefox/addon/kiss-translator/)\n  - [ ] Safari\n    - [ ] Safari (Mac)\n    - [ ] Safari (iOS)\n  - [x] Thunderbird [Download address](https://github.com/fishjar/kiss-translator/releases)\n- [x] GreaseMonkey Script\n  - [x] Chrome/Edge/Firefox ([Tampermonkey](https://www.tampermonkey.net/)/[Violentmonkey](https://violentmonkey.github.io/)) [Installation link](https://fishjar.github.io/kiss-translator/kiss-translator.user.js)\n    - [Greasy Fork](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator)\n  - [x] iOS Safari ([Userscripts Safari](https://github.com/quoid/userscripts)) [Installation link](https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js)\n\n## Associated Projects\n\n- Data synchronization service: [https://github.com/fishjar/kiss-worker](https://github.com/fishjar/kiss-worker)\n  - Data synchronization service available for this project.\n  - Can also be used to share personal private rule lists.\n  - Deploy by yourself, manage by yourself, data is private.\n- Community subscription rules: [https://github.com/fishjar/kiss-rules](https://github.com/fishjar/kiss-rules)\n  - Provides the latest and most complete list of subscription rules maintained by the community.\n  - Help with rules-related issues.\n\n## Frequently Asked Questions\n\n### How to Set Keyboard Shortcuts\n\nSet this in the extension management page, for example:\n\n- chrome [chrome://extensions/shortcuts](chrome://extensions/shortcuts)\n- firefox [about:addons](about:addons)\n\n### What is the priority order of rule settings?\n\nPersonal Rules > Subscription Rules > Global Rules\n\nAmong these, Global Rules have the lowest priority but are very important as they serve as the default rules.\n\n### API (Ollama, etc.) Test Failure\n\nCommon reasons for API test failures include:\n\n- Incorrect address:\n  - For example, `Ollama` has a native API address and an `Openai`-compatible address. This plugin currently supports the `Openai`-compatible address and does not support the `Ollama` native API address.\n- Some AI models do not support batch translation:\n  - In this case, you can choose to disable batch translation or use a custom API.\n  - Alternatively, you can use a custom API. For details, please refer to: [Custom API Example Documentation](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)\n- Some AI models have inconsistent parameters:\n  - For example, the parameters of the `Gemini` native API are highly inconsistent. Some model versions do not support certain parameters, leading to errors.\n  - In this case, you can modify the request body using a `Hook`, or replace it with `Gemini2` (an OpenAI-compatible address).\n- The server restricts cross-origin access, returning a 403 error:\n  - For example, `Ollama` requires adding the environment variable `OLLAMA_ORIGINS=*` when starting. See: https://github.com/fishjar/kiss-translator/issues/174\n\n### Custom API doesn't work in Tampermonkey scripts\n\nTampermonkey scripts require adding domains to the whitelist; otherwise, requests cannot be sent.\n\n### How to set up a hook function for a custom API\n\nCustom APIs are very powerful and flexible, and can theoretically connect to any translation API.\n\nExample reference: [custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)\n\n### How to directly access the Tampermonkey script settings page\n\nSettings page address: https://fishjar.github.io/kiss-translator/options.html\n\n## Future Plans \n\n This is a side project with no strict timeline. Community contributions are welcome. The following are preliminary feature directions:\n\n- [x] **Batch Text Requests**: Optimize request strategy to reduce translation API calls and improve performance.\n- [x] **Enhanced Rich Text Translation**: Support accurate translation of complex page structures and rich text content.\n- [x] **Advanced Custom/AI Interfaces**: Add support for streaming, context memory, multi-turn conversations, and other advanced AI features.\n- [x] **Fallback English Dictionary**: When translation services fail, fall back to a local dictionary lookup.\n- [x] **Improved YouTube Subtitle Support**: Enhance merging and translation experience for streaming subtitles, reducing sentence fragmentation.\n- [ ] **Upgraded Rule Collaboration System**: Introduce more flexible rule sharing, version management, and community review processes.\n\n If you're interested in any of these directions, feel free to discuss in [Issues](https://github.com/fishjar/kiss-translator/issues) or submit a PR!\n\n## Development Guidelines\n\n```sh\ngit clone https://github.com/fishjar/kiss-translator.git\ncd kiss-translator\ngit checkout dev # Submit a PR suggestion to push to the dev branch\npnpm install\npnpm build\n```\n\n### External Trigger Example\n\n```js\n// `toggle_translate`   Toggle translation\n// `toggle_styles`      Toggle styles\n// `toggle_popup`       Open/close control panel\n// `toggle_transbox`    Open/close translation popup\n// `toggle_hover_node`  Translate hovered paragraph\n// `input_translate`    Translate input box\nwindow.dispatchEvent(new CustomEvent(\"kiss_translator\", {detail: { action: \"toggle_translate\" }}));\n```\n\n## Discussion\n\n- Join [Telegram Group](https://t.me/+RRCu_4oNwrM2NmFl)\n\n## Appreciate\n\n![appreciate](https://github.com/fishjar/kiss-translator/assets/1157624/ebaecabe-2934-4172-8085-af236f5ee399)\n"
  },
  {
    "path": "README.ja.md",
    "content": "# KISS Translator シンプル翻訳\n\n[English](README.en.md) | [中文](README.md) | [日本語](README.ja.md) | [한국어](README.ko.md)\n\nシンプルでオープンソースの [バイリンガル対照翻訳拡張機能＆ユーザースクリプト](https://github.com/fishjar/kiss-translator)です。\n\n[kiss-translator.webm](https://github.com/fishjar/kiss-translator/assets/1157624/f7ba8a5c-e4a8-4d5a-823a-5c5c67a0a47f)\n\n## 特徴\n\n- [x] シンプルさを維持\n- [x] オープンソース\n- [x] 主要なブラウザに対応\n  - [x] Chrome/Edge\n  - [x] Firefox\n  - [x] Kiwi (Android)\n  - [x] Orion (iOS)\n  - [x] Safari\n  - [x] Thunderbird\n- [x] 複数の翻訳サービスをサポート\n  - [x] Google/Microsoft\n  - [x] Tencent/Volcengine\n  - [x] OpenAI/Gemini/Claude/Ollama/DeepSeek/OpenRouter\n  - [x] DeepL/DeepLX/NiuTrans\n  - [x] AzureAI/CloudflareAI\n  - [x] Chromeブラウザ内蔵AI翻訳(BuiltinAI)\n- [x] 一般的な翻訳シナリオをカバー\n  - [x] Webページのバイリンガル対照翻訳\n  - [x] 入力ボックス翻訳\n    - ショートカットキーで入力ボックス内のテキストを即座に他言語に翻訳\n  - [x] テキスト選択翻訳\n    - [x] 任意のページで翻訳ボックスを開き、複数の翻訳サービスで比較翻訳が可能\n    - [x] 英語辞書翻訳\n    - [x] 単語のブックマーク\n  - [x] マウスオーバー翻訳\n  - [x] YouTube 字幕翻訳\n    - 任意の翻訳サービスを使用してビデオ字幕を翻訳し、バイリンガル表示をサポート\n    - 基本的な字幕結合・改行アルゴリズムを内蔵し、翻訳品質を向上\n    - AIによる改行機能をサポートし、翻訳品質をさらに向上\n    - 字幕スタイルのカスタマイズ\n- [x] 多様な翻訳効果をサポート\n  - [x] テキスト自動認識と手動ルールの2つのモードをサポート\n    - テキスト自動認識モードにより、ほとんどのWebサイトでルールを記述しなくても完全な翻訳が可能\n    - 手動ルールモードで、特定のWebサイトに合わせた最適な最適化が可能\n  - [x] 翻訳テキストスタイルのカスタマイズ\n  - [x] リッチテキストの翻訳と表示をサポートし、原文のリンクやその他のテキストスタイルを可能な限り保持\n  - [x] 翻訳文のみの表示（原文を非表示）をサポート\n- [x] 翻訳APIの高度な機能\n  - [x] カスタムAPIにより、理論上あらゆる翻訳インターフェースをサポート\n  - [x] 翻訳テキストの統合バッチ送信\n  - [x] ストリーミング伝送をサポートし、翻訳結果をリアルタイムで表示\n  - [x] AIコンテキスト（会話メモリ）機能をサポートし、翻訳品質を向上\n  - [x] カスタムAI用語集\n  - [x] すべてのインターフェースがフックやカスタムパラメータなどの高度な機能をサポート\n- [x] クライアント間のデータ同期\n  - [x] KISS-Worker（cloudflare/docker）\n  - [x] WebDAV\n- [x] カスタム翻訳ルール\n  - [x] ルールの購読/ルール共有\n  - [x] カスタム専門用語\n- [x] カスタムショートカットキー\n  - `Alt+Q` 翻訳をオン\n  - `Alt+C` スタイル切り替え\n  - `Alt+K` 設定ポップアップを開く\n  - `Alt+S` 翻訳ポップアップを開く/選択テキストを翻訳\n  - `Alt+O` 設定ページを開く\n  - `Alt+I` 入力ボックス翻訳\n\n## インストール\n\n> 注：以下の理由により、ブラウザ拡張機能の使用を優先することをお勧めします\n>\n> - ブラウザ拡張機能の方が機能が完全です（ローカル言語認識、右クリックメニューなど）\n> - ユーザースクリプトはより多くの問題（クロスドメイン問題、スクリプトの競合など）に遭遇する可能性があります\n\n- [x] ブラウザ拡張機能\n  - [x] Chrome [インストール](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=ja)\n    - [x] Kiwi (Android)\n    - [x] Orion (iOS)\n  - [x] Edge [インストール](https://microsoftedge.microsoft.com/addons/detail/%E7%AE%80%E7%BA%A6%E7%BF%BB%E8%AF%91/jemckldkclkinpjighnoilpbldbdmmlh?hl=ja)\n  - [x] Firefox [インストール](https://addons.mozilla.org/ja/firefox/addon/kiss-translator/)\n  - [ ] Safari\n    - [ ] Safari (Mac)\n    - [ ] Safari (iOS) \n  - [x] Thunderbird [ダウンロード](https://github.com/fishjar/kiss-translator/releases)\n- [x] ユーザースクリプト\n  - [x] Chrome/Edge/Firefox ([Tampermonkey](https://www.tampermonkey.net/)/[Violentmonkey](https://violentmonkey.github.io/)) [インストールリンク](https://fishjar.github.io/kiss-translator/kiss-translator.user.js)\n    - [Greasy Fork](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator)\n  - [x] iOS Safari ([Userscripts Safari](https://github.com/quoid/userscripts)) [インストールリンク](https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js)\n\n## 関連プロジェクト\n\n- データ同期サービス: [https://github.com/fishjar/kiss-worker](https://github.com/fishjar/kiss-worker)\n  - 本プロジェクトのデータ同期サービスとして使用できます。\n  - 個人のプライベートなルールリストの共有にも使用できます。\n  - セルフホスト、セルフマネジメント、データはプライベート。\n- コミュニティ購読ルール: [https://github.com/fishjar/kiss-rules](https://github.com/fishjar/kiss-rules)\n  - コミュニティによってメンテナンスされた、最新かつ最も完全な購読ルールリストを提供します。\n  - ルール関連の問題についての助けを求める。\n\n## よくある質問（FAQ）\n\n### ショートカットキーの設定方法\n\n拡張機能の管理ページで設定します。例： \n\n- chrome [chrome://extensions/shortcuts](chrome://extensions/shortcuts)\n- firefox [about:addons](about:addons)\n\n### ルール設定の優先順位は？\n\n個人ルール > 購読ルール > グローバルルール\n\nグローバルルールの優先順位は最も低いですが、フォールバックルールとして非常に重要です。\n\n### API（Ollamaなど）のテストに失敗する\n\nAPIテストの失敗には、一般的に以下の原因が考えられます：\n\n- アドレスが間違っている：\n  - 例えば `Ollama` にはネイティブAPIアドレスと `Openai` 互換のアドレスがありますが、本プラグインは現在、`Openai` 互換アドレスをサポートしており、`Ollama` ネイティブAPIアドレスはサポートしていません\n- 一部のAIモデルが統合翻訳をサポートしていない：\n  - この場合、統合翻訳を無効にするか、カスタムAPIを使用して対応できます。\n  - または、カスタムAPIを使用して対応します。詳細は[カスタムAPIサンプルドキュメント](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)を参照してください\n- 一部のAIモデルでパラメータが一致しない：\n  - 例えば `Gemini` のネイティブAPIはパラメータの不一致が大きく、一部のバージョンのモデルが特定のパラメータをサポートしていないためエラーが返されることがあります。\n  - この場合、`Hook` を使用してリクエスト `body` を変更するか、`Gemini2` (`Openai` 互換アドレス) に切り替えることができます\n- サーバーのクロスドメイン制限によりアクセスが拒否され、403エラーが返される：\n  - 例えば `Ollama` を起動する際に、環境変数 `OLLAMA_ORIGINS=*` を追加する必要があります。参考：https://github.com/fishjar/kiss-translator/issues/174\n\n### 入力したAPIがユーザースクリプトで使用できない\n\nユーザースクリプトは、リクエストを送信するためにドメインのホワイトリストを追加する必要があります。\n\n### カスタムAPIのhook関数の設定方法\n\nカスタムAPI機能は非常に強力で柔軟性があり、理論的にはどんな翻訳APIにも接続できます。\n\nサンプル参照： [custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)\n\n### ユーザースクリプトの設定ページに直接アクセスする方法\n\n設定ページアドレス： https://fishjar.github.io/kiss-translator/options.html\n\n## 今後の計画 \n\n 本プロジェクトは余暇に開発しており、厳密なタイムスケジュールはありません。コミュニティの共同構築を歓迎します。以下は初期段階の機能の方向性です：\n\n- [x] **テキストの統合送信**：リクエスト戦略を最適化し、翻訳APIの呼び出し回数を減らし、パフォーマンスを向上させます。\n- [x] **リッチテキスト翻訳の強化**：より複雑なページ構造やリッチテキストコンテンツの正確な翻訳をサポートします。\n- [x] **カスタム/AI APIの強化**：ストリーミング伝送、コンテキストメモリ、複数ラウンドの対話など、高度なAI機能をサポートします。\n- [x] **英語辞書のフォールバックメカニズム**：翻訳サービスが利用できない場合、他の辞書に切り替えるか、ローカル辞書での検索にフォールバックします。\n- [x] **YouTube字幕サポートの最適化**：ストリーミング字幕の結合と翻訳体験を改善し、途切れを減らします。\n- [ ] **ルール共同構築メカニズムのアップグレード**：より柔軟なルールの共有、バージョン管理、コミュニティレビュープロセスを導入します。\n \n 特定の方向に興味がある場合は、[Issues](https://github.com/fishjar/kiss-translator/issues) で議論したり、PRを送信したりすることを歓迎します！\n\n## 開発ガイド\n\n```sh\ngit clone [https://github.com/fishjar/kiss-translator.git](https://github.com/fishjar/kiss-translator.git)\ncd kiss-translator\ngit checkout dev # PRを送信する場合はdevブランチにプッシュすることをお勧めします\npnpm install\npnpm build\n```\n\n### 外部トリガーの例\n\n```js\n// `toggle_translate`   翻訳を切り替え\n// `toggle_styles`      スタイルを切り替え\n// `toggle_popup`       コントロールパネルを開く／閉じる\n// `toggle_transbox`    翻訳ポップアップを開く／閉じる\n// `toggle_hover_node`  マウスオーバー中の段落を翻訳\n// `input_translate`    入力欄を翻訳\nwindow.dispatchEvent(new CustomEvent(\"kiss_translator\", {detail: { action: \"toggle_translate\" }}));\n```\n\n## コミュニケーション\n\n- [Telegram グループ](https://t.me/+RRCu_4oNwrM2NmFl)に参加\n\n## 寄付\n\n![appreciate](https://github.com/fishjar/kiss-translator/assets/1157624/ebaecabe-2934-4172-8085-af236f5ee399)\n"
  },
  {
    "path": "README.ko.md",
    "content": "# KISS Translator 심플 번역\n\n[English](README.en.md) | [中文](README.md) | [日本語](README.ja.md) | [한국어](README.ko.md)\n\n심플하고 오픈 소스인 [이중 언어 대조 번역 확장 프로그램 & 유저 스크립트](https://github.com/fishjar/kiss-translator)입니다.\n\n[kiss-translator.webm](https://github.com/fishjar/kiss-translator/assets/1157624/f7ba8a5c-e4a8-4d5a-823a-5c5c67a0a47f)\n\n## 특징\n\n- [x] 심플함 유지\n- [x] 오픈 소스\n- [x] 주요 브라우저 지원\n  - [x] Chrome/Edge\n  - [x] Firefox\n  - [x] Kiwi (Android)\n  - [x] Orion (iOS)\n  - [x] Safari\n  - [x] Thunderbird\n- [x] 다양한 번역 서비스 지원\n  - [x] Google/Microsoft\n  - [x] Tencent/Volcengine\n  - [x] OpenAI/Gemini/Claude/Ollama/DeepSeek/OpenRouter\n  - [x] DeepL/DeepLX/NiuTrans\n  - [x] AzureAI/CloudflareAI\n  - [x] Chrome 브라우저 내장 AI 번역(BuiltinAI)\n- [x] 일반적인 번역 시나리오 지원\n  - [x] 웹페이지 이중 언어 대조 번역\n  - [x] 입력창 번역\n    - 단축키를 통해 입력창 내 텍스트를 즉시 다른 언어로 번역\n  - [x] 텍스트 선택 번역\n    - [x] 모든 페이지에서 번역창을 열어 여러 번역 서비스로 비교 번역 가능\n    - [x] 영어 사전 번역\n    - [x] 단어 즐겨찾기\n  - [x] 마우스오버 번역\n  - [x] YouTube 자막 번역\n    - 모든 번역 서비스를 사용하여 비디오 자막을 번역하고 이중 언어로 표시 지원\n    - 기본적인 자막 병합 및 줄 바꿈 알고리즘 내장으로 번역 품질 향상\n    - AI 줄 바꿈 기능 지원으로 번역 품질 추가 향상\n    - 사용자 정의 자막 스타일\n- [x] 다양한 번역 효과 지원\n  - [x] 자동 텍스트 인식 및 수동 규칙 두 가지 모드 지원\n    - 자동 텍스트 인식 모드는 대부분의 웹사이트에서 규칙 작성 없이도 완벽한 번역 가능\n    - 수동 규칙 모드로 특정 웹사이트에 대한 최적의 최적화 가능\n  - [x] 번역문 스타일 사용자 정의\n  - [x] 리치 텍스트 번역 및 표시 지원, 원문의 링크 및 기타 텍스트 스타일 최대한 보존\n  - [x] 번역문만 표시 (원문 숨기기) 지원\n- [x] 번역 인터페이스 고급 기능\n  - [x] 사용자 정의 인터페이스를 통해 이론상 모든 번역 인터페이스 지원\n  - [x] 번역 텍스트 일괄 통합 전송\n  - [x] 스트리밍 전송 지원, 번역 결과 실시간 표시\n  - [x] AI 컨텍스트 (대화 기억) 기능 지원으로 번역 품질 향상\n  - [x] 사용자 정의 AI 용어 사전\n  - [x] 모든 인터페이스는 후크 및 사용자 정의 파라미터 등 고급 기능 지원\n- [x] 클라이언트 간 데이터 동기화\n  - [x] KISS-Worker (cloudflare/docker)\n  - [x] WebDAV\n- [x] 사용자 정의 번역 규칙\n  - [x] 규칙 구독 / 규칙 공유\n  - [x] 사용자 정의 전문 용어\n- [x] 사용자 정의 단축키\n  - `Alt+Q` 번역 켜기\n  - `Alt+C` 스타일 전환\n  - `Alt+K` 설정 팝업 열기\n  - `Alt+S` 번역 팝업 열기 / 선택한 텍스트 번역\n  - `Alt+O` 설정 페이지 열기\n  - `Alt+I` 입력창 번역\n\n## 설치\n\n> 참고: 다음과 같은 이유로 브라우저 확장 프로그램 사용을 우선적으로 권장합니다.\n>\n> - 브라우저 확장 프로그램의 기능이 더 완전합니다 (로컬 언어 인식, 우클릭 메뉴 등).\n> - 유저 스크립트는 사용상 더 많은 문제 (크로스 도메인 문제, 스크립트 충돌 등)를 겪을 수 있습니다.\n\n- [x] 브라우저 확장 프로그램\n  - [x] Chrome [설치 주소](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=ko)\n    - [x] Kiwi (Android)\n    - [x] Orion (iOS)\n  - [x] Edge [설치 주소](https://microsoftedge.microsoft.com/addons/detail/%E7%AE%80%E7%BA%A6%E7%BF%BB%E8%AF%91/jemckldkclkinpjighnoilpbldbdmmlh?hl=ko)\n  - [x] Firefox [설치 주소](https://addons.mozilla.org/ko/firefox/addon/kiss-translator/)\n  - [ ] Safari\n    - [ ] Safari (Mac)\n    - [ ] Safari (iOS) \n  - [x] Thunderbird [다운로드 주소](https://github.com/fishjar/kiss-translator/releases)\n- [x] 유저 스크립트\n  - [x] Chrome/Edge/Firefox ([Tampermonkey](https://www.tampermonkey.net/)/[Violentmonkey](https://violentmonkey.github.io/)) [설치 링크](https://fishjar.github.io/kiss-translator/kiss-translator.user.js)\n    - [Greasy Fork](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator)\n  - [x] iOS Safari ([Userscripts Safari](https://github.com/quoid/userscripts)) [설치 링크](https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js)\n\n## 관련 프로젝트\n\n- 데이터 동기화 서비스: [https://github.com/fishjar/kiss-worker](https://github.com/fishjar/kiss-worker)\n  - 본 프로젝트의 데이터 동기화 서비스로 사용할 수 있습니다.\n  - 개인의 비공개 규칙 목록을 공유하는 데에도 사용할 수 있습니다.\n  - 직접 배포, 직접 관리, 데이터 비공개.\n- 커뮤니티 구독 규칙: [https://github.com/fishjar/kiss-rules](https://github.com/fishjar/kiss-rules)\n  - 커뮤니티에서 유지 관리하는 최신의 가장 완벽한 구독 규칙 목록을 제공합니다.\n  - 규칙 관련 문제에 대한 도움 요청.\n\n## 자주 묻는 질문 (FAQ)\n\n### 단축키는 어떻게 설정하나요?\n\n플러그인 관리 페이지에서 설정합니다. 예: \n\n- chrome [chrome://extensions/shortcuts](chrome://extensions/shortcuts)\n- firefox [about:addons](about:addons)\n\n### 규칙 설정의 우선순위는 어떻게 되나요?\n\n개인 규칙 > 구독 규칙 > 전역 규칙\n\n그중 전역 규칙은 우선순위가 가장 낮지만, 예비 규칙으로서 매우 중요합니다.\n\n### 인터페이스 (Ollama 등) 테스트 실패\n\n일반적으로 인터페이스 테스트 실패는 다음과 같은 몇 가지 원인이 있습니다:\n\n- 주소를 잘못 입력한 경우:\n  - 예를 들어 `Ollama`는 네이티브 인터페이스 주소와 `Openai` 호환 주소가 있습니다. 본 플러그인은 현재 `Openai` 호환 주소를 통일되게 지원하며, `Ollama` 네이티브 인터페이스 주소는 지원하지 않습니다.\n- 일부 AI 모델이 통합 번역을 지원하지 않는 경우:\n  - 이 경우 통합 번역을 비활성화하거나 사용자 정의 인터페이스 방식을 통해 사용할 수 있습니다.\n  - 또는 사용자 정의 인터페이스 방식을 통해 사용합니다. 자세한 내용은 [사용자 정의 인터페이스 예시 문서](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)를 참조하세요.\n- 일부 AI 모델의 파라미터가 일치하지 않는 경우:\n  - 예를 들어 `Gemini` 네이티브 인터페이스 파라미터는 매우 불일치하며, 일부 버전의 모델은 특정 파라미터를 지원하지 않아 오류를 반환할 수 있습니다.\n  - 이 경우 `Hook`을 사용하여 요청 `body`를 수정하거나, `Gemini2` (`Openai` 호환 주소)로 변경할 수 있습니다.\n- 서버의 크로스 도메인 접근 제한으로 403 오류가 반환되는 경우:\n  - 예를 들어 `Ollama` 시작 시 환경 변수 `OLLAMA_ORIGINS=*`를 추가해야 합니다. 참고: https://github.com/fishjar/kiss-translator/issues/174\n\n### 입력한 인터페이스를 유저 스크립트에서 사용할 수 없습니다\n\n유저 스크립트는 도메인 화이트리스트를 추가해야 요청을 보낼 수 있습니다.\n\n### 사용자 정의 인터페이스의 hook 함수는 어떻게 설정하나요?\n\n사용자 정의 인터페이스 기능은 매우 강력하고 유연하며, 이론적으로 어떤 번역 인터페이스든 연결할 수 있습니다.\n\n예시 참고: [custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)\n\n### 유저 스크립트 설정 페이지로 바로 이동하는 방법\n\n설정 페이지 주소: https://fishjar.github.io/kiss-translator/options.html\n\n## 향후 계획 \n\n 본 프로젝트는 여가 시간에 개발되며, 엄격한 시간표는 없습니다. 커뮤니티의 공동 구축을 환영합니다. 다음은 초기 구상 중인 기능 방향입니다:\n\n- [x] **텍스트 통합 전송**: 요청 전략을 최적화하여 번역 인터페이스 호출 횟수를 줄이고 성능을 향상시킵니다.\n- [x] **리치 텍스트 번역 강화**: 더 복잡한 페이지 구조와 리치 텍스트 콘텐츠의 정확한 번역을 지원합니다.\n- [x] **사용자 정의/AI 인터페이스 강화**: 스트리밍 전송, 컨텍스트 기억, 다중 턴 대화 등 고급 AI 기능을 지원합니다.\n- [x] **영어 사전 예비 메커니즘**: 번역 서비스가 실패할 경우 다른 사전으로 전환하거나 로컬 사전 조회로 대체합니다.\n- [x] **YouTube 자막 지원 최적화**: 스트리밍 자막의 병합 및 번역 경험을 개선하고, 끊김을 줄입니다.\n- [ ] **규칙 공동 구축 메커니즘 업그레이드**: 더 유연한 규칙 공유, 버전 관리 및 커뮤니티 검토 프로세스를 도입합니다.\n \n 특정 방향에 관심이 있다면, [Issues](https://github.com/fishjar/kiss-translator/issues)에서 토론하거나 PR을 제출해 주세요!\n\n## 개발 가이드\n\n```sh\ngit clone [https://github.com/fishjar/kiss-translator.git](https://github.com/fishjar/kiss-translator.git)\ncd kiss-translator\ngit checkout dev # PR 제출 시 dev 브랜치로 푸시하는 것을 권장합니다\npnpm install\npnpm build\n```\n\n### 외부 트리거 예시\n\n```js\n// `toggle_translate`   번역 전환\n// `toggle_styles`      스타일 전환\n// `toggle_popup`       제어 패널 열기/닫기\n// `toggle_transbox`    번역 팝업 열기/닫기\n// `toggle_hover_node`  마우스를 올린 문단 번역\n// `input_translate`    입력창 번역\nwindow.dispatchEvent(new CustomEvent(\"kiss_translator\", {detail: { action: \"toggle_translate\" }}));\n```\n\n## 커뮤니티\n\n- [Telegram 그룹](https://t.me/+RRCu_4oNwrM2NmFl) 가입\n\n## 후원\n\n![appreciate](https://github.com/fishjar/kiss-translator/assets/1157624/ebaecabe-2934-4172-8085-af236f5ee399)\n\n"
  },
  {
    "path": "README.md",
    "content": "# KISS Translator 简约翻译\n\n[English](README.en.md) | [中文](README.md) | [日本語](README.ja.md) | [한국어](README.ko.md)\n\n一个简约、开源的 [双语对照翻译扩展 & 油猴脚本](https://github.com/fishjar/kiss-translator)。\n\n[kiss-translator.webm](https://github.com/fishjar/kiss-translator/assets/1157624/f7ba8a5c-e4a8-4d5a-823a-5c5c67a0a47f)\n\n## 特性\n\n- [x] 保持简约\n- [x] 开放源代码\n- [x] 适配常见浏览器\n  - [x] Chrome/Edge\n  - [x] Firefox\n  - [x] Kiwi (Android)\n  - [x] Orion (iOS)\n  - [x] Safari\n  - [x] Thunderbird\n- [x] 支持多种翻译服务\n  - [x] Google/Microsoft\n  - [x] Tencent/Volcengine\n  - [x] OpenAI/Gemini/Claude/Ollama/DeepSeek/OpenRouter\n  - [x] DeepL/DeepLX/NiuTrans\n  - [x] AzureAI/CloudflareAI\n  - [x] Chrome浏览器内置AI翻译(BuiltinAI)\n- [x] 覆盖常见翻译场景\n  - [x] 网页双语对照翻译\n  - [x] 输入框翻译\n    - 通过快捷键立即将输入框内文本翻译成其他语言\n  - [x] 划词翻译\n    - [x] 任意页面打开翻译框，可用多种翻译服务对比翻译\n    - [x] 英文词典翻译\n    - [x] 收藏词汇\n  - [x] 鼠标悬停翻译\n  - [x] YouTube 字幕翻译\n    - 支持任意翻译服务对视频字幕进行翻译并双语显示\n    - 内置基础的字幕合并与断句算法，提升翻译效果\n    - 支持AI断句功能，可进一步提升翻译质量\n    - 自定义字幕样式\n- [x] 支持多样翻译效果\n  - [x] 支持自动识别文本与手动规则两种模式\n    - 自动识别文本模式使得绝大部分网站无需编写规则也能翻译完整\n    - 手动规则模式，可以针对特定网站极致优化\n  - [x] 自定义译文样式\n  - [x] 支持富文本翻译及显示，能够尽量保留原文中的链接及其他文本样式\n  - [x] 支持仅显示译文（隐藏原文）\n- [x] 翻译接口高级功能\n  - [x] 通过自定义接口，理论上支持任何翻译接口\n  - [x] 聚合批量发送翻译文本\n  - [x] 支持流式传输，实时显示翻译结果\n  - [x] 支持AI上下文会话记忆功能，提升翻译效果\n  - [x] 自定义AI术语词典\n  - [x] 所有接口均支持Hook和自定义参数等高级功能\n- [x] 跨客户端数据同步\n  - [x] KISS-Worker（cloudflare/docker）\n  - [x] WebDAV\n- [x] 自定义翻译规则\n  - [x] 规则订阅/规则分享\n  - [x] 自定义专业术语\n- [x] 自定义快捷键\n  - `Alt+Q` 开启翻译\n  - `Alt+C` 切换样式\n  - `Alt+K` 打开设置弹窗\n  - `Alt+S` 打开翻译弹窗/翻译选中文字\n  - `Alt+O` 打开设置页面\n  - `Alt+I` 输入框翻译\n\n## 安装\n\n> 注：基于以下原因，建议优先使用浏览器扩展\n>\n> - 浏览器扩展的功能更完整（本地语言识别、右键菜单等）\n> - 油猴脚本会遇到更多使用上的问题（跨域问题、脚本冲突等）\n\n- [x] 浏览器扩展\n  - [x] Chrome [安装地址](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=zh-CN)\n    - [x] Kiwi (Android)\n    - [x] Orion (iOS)\n  - [x] Edge [安装地址](https://microsoftedge.microsoft.com/addons/detail/%E7%AE%80%E7%BA%A6%E7%BF%BB%E8%AF%91/jemckldkclkinpjighnoilpbldbdmmlh?hl=zh-CN)\n  - [x] Firefox [安装地址](https://addons.mozilla.org/zh-CN/firefox/addon/kiss-translator/)\n  - [ ] Safari\n    - [ ] Safari (Mac)\n    - [ ] Safari (iOS) \n  - [x] Thunderbird [下载地址](https://github.com/fishjar/kiss-translator/releases)\n- [x] 油猴脚本\n  - [x] Chrome/Edge/Firefox ([Tampermonkey](https://www.tampermonkey.net/)/[Violentmonkey](https://violentmonkey.github.io/)) [安装链接](https://fishjar.github.io/kiss-translator/kiss-translator.user.js)\n    - [Greasy Fork](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator)\n  - [x] iOS Safari ([Userscripts Safari](https://github.com/quoid/userscripts)) [安装链接](https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js)\n\n## 关联项目\n\n- 数据同步服务: [https://github.com/fishjar/kiss-worker](https://github.com/fishjar/kiss-worker)\n  - 可用于本项目的数据同步服务。\n  - 亦可用于分享个人的私有规则列表。\n  - 自己部署，自己管理，数据私有。\n- 社区订阅规则: [https://github.com/fishjar/kiss-rules](https://github.com/fishjar/kiss-rules)\n  - 提供社区维护的，最新最全的订阅规则列表。\n  - 求助规则相关的问题。\n\n## 常见问题\n\n### 如何设置快捷键\n\n在插件管理那里设置，例如： \n\n- chrome [chrome://extensions/shortcuts](chrome://extensions/shortcuts)\n- firefox [about:addons](about:addons)\n\n### 规则设置的优先级是如何的\n\n个人规则 > 订阅规则 > 全局规则\n\n其中全局规则优先级最低，但非常重要，相当于兜底规则。\n\n### 接口（Ollama等）测试失败\n\n一般接口测试失败常见有以下几种原因：\n\n- 地址填错了：\n  - 比如 `Ollama` 有原生接口地址和 `Openai` 兼容的地址，本插件目前统一支持 `Openai` 兼容的地址，不支持 `Ollama` 原生接口地址\n- 某些AI模型不支持聚合翻译：\n  - 此种情况可以选择禁用聚合翻译或通过自定义接口的方式来使用。\n  - 或通过自定义接口的方式来使用，详情参考： [自定义接口示例文档](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)\n- 某些AI模型的参数不一致：\n  - 比如 `Gemini` 原生接口参数非常不一致，部分版本的模型不支持某些参数会导致返回错误。\n  - 此种情况可以通过 `Hook` 修改请求 `body` ,或者更换为 `Gemini2` (`Openai` 兼容的地址)\n- 服务器跨域限制访问，返回403错误：\n  - 比如 `Ollama` 启动时须添加环境变量 `OLLAMA_ORIGINS=*`, 参考：https://github.com/fishjar/kiss-translator/issues/174\n\n### 填写的接口在油猴脚本不能使用\n\n油猴脚本需要增加域名白名单，否则不能发出请求。\n\n### 如何设置自定义接口的hook函数\n\n自定义接口功能非常强大、灵活，理论可以接入任何翻译接口。\n\n示例参考： [custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)\n\n### 如何直接进入油猴脚本设置页面\n\n设置页面地址： https://fishjar.github.io/kiss-translator/options.html\n\n## 未来规划 \n\n 本项目为业余开发，无严格时间表，欢迎社区共建。以下为初步设想的功能方向：\n\n- [x] **聚合发送文本**：优化请求策略，减少翻译接口调用次数，提升性能。\n- [x] **增强富文本翻译**：支持更复杂的页面结构和富文本内容的准确翻译。\n- [x] **强化自定义/AI 接口**：支持流式传输、上下文记忆、多轮对话等高级 AI 功能。\n- [x] **英文词典备灾机制**：当翻译服务失效时，可切换其他词典或 fallback 到本地词典查询。\n- [x] **优化 YouTube 字幕支持**：改进流式字幕的合并与翻译体验，减少断句。\n- [ ] **规则共建机制升级**：引入更灵活的规则分享、版本管理与社区评审流程。\n \n 如果你对某个方向感兴趣，欢迎在 [Issues](https://github.com/fishjar/kiss-translator/issues) 中讨论或提交 PR！\n\n## 开发指引\n\n```sh\ngit clone https://github.com/fishjar/kiss-translator.git\ncd kiss-translator\ngit checkout dev # 提交PR建议推送到dev分支\npnpm install\npnpm build\n```\n\n### 外部触发示例\n\n```js\n// `toggle_translate`   切换翻译\n// `toggle_styles`      切换样式\n// `toggle_popup`       打开/关闭控制面板\n// `toggle_transbox`    打开/关闭翻译弹窗\n// `toggle_hover_node`  翻译鼠标悬停段落\n// `input_translate`    翻译输入框\nwindow.dispatchEvent(new CustomEvent(\"kiss_translator\", {detail: { action: \"toggle_translate\" }}));\n```\n\n## 交流\n\n- 加入 [Telegram 群](https://t.me/+RRCu_4oNwrM2NmFl)\n\n## 赞赏\n\n![appreciate](https://github.com/fishjar/kiss-translator/assets/1157624/ebaecabe-2934-4172-8085-af236f5ee399)\n"
  },
  {
    "path": "VERSION_MANAGEMENT.md",
    "content": "# 版本号管理说明\n\n## 📌 背景\n\n项目的版本号分散在多个文件中：\n- `package.json`\n- `.env` (REACT_APP_VERSION)\n- `public/manifest.json`\n- `public/manifest.firefox.json`\n- `public/manifest.thunderbird.json`\n\n为了避免手动更新多个文件的麻烦，现已实现自动化版本号管理方案。\n\n## ✨ 解决方案\n\n### 单一版本源\n\n**`package.json` 是唯一的版本号来源**，其他文件的版本号会自动从 `package.json` 同步。\n\n### 自动同步机制\n\n构建时会自动同步版本号：\n\n```bash\npnpm build        # 构建前自动同步版本号\npnpm build+zip    # 打包前自动同步版本号\n```\n\n手动同步版本号：\n\n```bash\npnpm sync-version  # 手动触发版本号同步\n```\n\n## 🚀 版本号更新方法\n\n### 方法一：使用快捷命令（推荐）\n\n```bash\n# 补丁版本更新: 2.0.19 -> 2.0.20\npnpm version:patch\n\n# 次版本更新: 2.0.19 -> 2.1.0\npnpm version:minor\n\n# 主版本更新: 2.0.19 -> 3.0.0\npnpm version:major\n\n# 手动指定版本号: 设置为 2.1.0\npnpm version:set -- 2.1.0\n```\n\n这些命令会自动完成：\n1. ✅ 更新 `package.json` 中的版本号\n2. ✅ 自动同步到其他所有文件\n\n### 方法二：手动更新（不推荐）\n\n如果你手动修改了 `package.json` 的版本号，记得运行：\n\n```bash\npnpm sync-version\n```\n\n## 📝 完整的版本发布流程\n\n```bash\n# 0. 格式化\npnpm format\n\n# 1. 更新版本号（自动完成所有文件的同步）\npnpm version:patch\n\n# 2. 更新 CHANGELOG.md（手动编辑）\n# 添加新版本的更新内容\n\n# 3. 提交更改\ngit add .\ngit commit -m \"chore: bump version to 2.0.20\"\n\n# 4. 构建和打包（构建前会再次确保版本号同步）\npnpm build+zip\n\n# 5. 推送代码\ngit push origin dev\n\n# 6. 合并到 master\ngit checkout master\ngit merge dev\n\n# 7. 打 tag\ngit tag -a v2.0.20 -m \"Release version 2.0.20\"\ngit push origin master v2.0.20\n\n# 8. 切换回 dev 分支\ngit checkout dev\n```\n\n## 🛠️ 相关脚本文件\n\n- `src/scripts/sync-version.mjs` - 版本号同步脚本\n- `src/scripts/update-version.mjs` - 版本号更新脚本\n\n## ⚠️ 注意事项\n\n1. **不要手动修改** `.env`、`manifest.json` 等文件中的版本号\n2. **只需修改** `package.json` 中的版本号，或者使用 `pnpm version:*` 命令\n3. 每次构建前会自动同步版本号，确保所有文件版本一致\n4. 更新版本后记得同步更新 `CHANGELOG.md`\n"
  },
  {
    "path": "config-overrides.js",
    "content": "const paths = require(\"react-scripts/config/paths\");\nconst HtmlWebpackPlugin = require(\"html-webpack-plugin\");\nconst { WebpackManifestPlugin } = require(\"webpack-manifest-plugin\");\nconst MiniCssExtractPlugin = require(\"mini-css-extract-plugin\");\nconst TerserPlugin = require(\"terser-webpack-plugin\");\n// const webpack = require(\"webpack\");\n\nconsole.log(\"process.env.REACT_APP_CLIENT\", process.env.REACT_APP_CLIENT);\n\n// 扩展\nconst extWebpack = (config, env) => {\n  const isEnvProduction = env === \"production\";\n  const minify = isEnvProduction && {\n    removeComments: true,\n    collapseWhitespace: true,\n    removeRedundantAttributes: true,\n    useShortDoctype: true,\n    removeEmptyAttributes: true,\n    removeStyleLinkTypeAttributes: true,\n    keepClosingSlash: true,\n    minifyJS: true,\n    minifyCSS: true,\n    minifyURLs: true,\n  };\n  const names = [\n    \"HtmlWebpackPlugin\",\n    \"WebpackManifestPlugin\",\n    \"MiniCssExtractPlugin\",\n  ];\n\n  config.entry = {\n    popup: paths.appSrc + \"/popup.js\",\n    options: paths.appSrc + \"/options.js\",\n    background: paths.appSrc + \"/background.js\",\n    content: paths.appSrc + \"/content.js\",\n    \"injector-subtitle\": paths.appSrc + \"/injector-subtitle.js\",\n    \"injector-shadowroot\": paths.appSrc + \"/injector-shadowroot.js\",\n  };\n\n  config.output.filename = \"[name].js\";\n  config.output.assetModuleFilename = \"media/[name][ext]\";\n  config.optimization.splitChunks = { cacheGroups: { default: false } };\n  config.optimization.runtimeChunk = false;\n\n  config.plugins = config.plugins.filter(\n    (plugin) => !names.includes(plugin.constructor.name)\n  );\n\n  config.plugins.push(\n    new HtmlWebpackPlugin({\n      inject: true,\n      chunks: [\"options\"],\n      template: paths.appHtml,\n      filename: \"options.html\",\n      minify,\n    }),\n    new HtmlWebpackPlugin({\n      inject: true,\n      chunks: [\"popup\"],\n      template: paths.appHtml,\n      filename: \"popup.html\",\n      minify,\n    }),\n    new WebpackManifestPlugin({\n      fileName: \"asset-manifest.json\",\n    }),\n    new MiniCssExtractPlugin({\n      filename: \"css/[name].css\",\n    })\n  );\n\n  return config;\n};\n\n// 油猴\nconst userscriptWebpack = (config, env) => {\n  const banner = `// ==UserScript==\n// @name          ${process.env.REACT_APP_NAME}\n// @namespace     ${process.env.REACT_APP_HOMEPAGE}\n// @version       ${process.env.REACT_APP_VERSION}\n// @description   A simple bilingual translation extension & Greasemonkey script (一个简约的双语对照翻译扩展 & 油猴脚本)\n// @author        Gabe<yugang2002@gmail.com>\n// @homepageURL   ${process.env.REACT_APP_HOMEPAGE}\n// @license       GPL-3.0\n// @match         *://*/*\n// @icon          ${process.env.REACT_APP_LOGOURL}\n// @downloadURL   ${process.env.REACT_APP_USERSCRIPT_DOWNLOADURL}\n// @updateURL     ${process.env.REACT_APP_USERSCRIPT_DOWNLOADURL}\n// @grant         GM.xmlHttpRequest\n// @grant         GM_xmlhttpRequest\n// @grant         GM.registerMenuCommand\n// @grant         GM_registerMenuCommand\n// @grant         GM.unregisterMenuCommand\n// @grant         GM_unregisterMenuCommand\n// @grant         GM.setValue\n// @grant         GM_setValue\n// @grant         GM.getValue\n// @grant         GM_getValue\n// @grant         GM.deleteValue\n// @grant         GM_deleteValue\n// @grant         GM.info\n// @grant         GM_info\n// @grant         unsafeWindow\n// @connect       translate.googleapis.com\n// @connect       translate-pa.googleapis.com\n// @connect       generativelanguage.googleapis.com\n// @connect       api-edge.cognitive.microsofttranslator.com\n// @connect       edge.microsoft.com\n// @connect       bing.com\n// @connect       api-free.deepl.com\n// @connect       api.deepl.com\n// @connect       www2.deepl.com\n// @connect       api.openai.com\n// @connect       generativelanguage.googleapis.com\n// @connect       openai.azure.com\n// @connect       workers.dev\n// @connect       github.io\n// @connect       github.com\n// @connect       githubusercontent.com\n// @connect       kiss-translator.rayjar.com\n// @connect       ghproxy.com\n// @connect       dav.jianguoyun.com\n// @connect       fanyi.baidu.com\n// @connect       transmart.qq.com\n// @connect       niutrans.com\n// @connect       translate.volcengine.com\n// @connect       dict.youdao.com\n// @connect       api.anthropic.com\n// @connect       api.siliconflow.cn\n// @connect       api.cloudflare.com\n// @connect       openrouter.ai\n// @connect       localhost\n// @connect       127.0.0.1\n// @run-at        document-end\n// ==/UserScript==\n\n`;\n\n  const names = [\"HtmlWebpackPlugin\"];\n\n  config.entry = {\n    main: paths.appIndexJs,\n    options: paths.appSrc + \"/options.js\",\n    \"kiss-translator.user\": paths.appSrc + \"/userscript.js\",\n  };\n\n  config.output.filename = \"[name].js\";\n  config.output.publicPath = env === \"production\" ? \"./\" : \"/\";\n  config.optimization.splitChunks = { cacheGroups: { default: false } };\n  config.optimization.runtimeChunk = false;\n  config.optimization.minimize = env === \"production\";\n\n  if (config.optimization.minimize) {\n    config.optimization.minimizer = [\n      new TerserPlugin({\n        extractComments: false,\n        terserOptions: {\n          format: {\n            comments: false,\n            preamble: banner,\n          },\n        },\n      }),\n    ];\n  }\n\n  if (env === \"production\") config.devtool = false;\n\n  config.plugins = config.plugins.filter(\n    (plugin) => !names.includes(plugin.constructor.name)\n  );\n\n  config.plugins.push(\n    new HtmlWebpackPlugin({\n      inject: true,\n      chunks: [\"main\"],\n      template: paths.appHtml,\n      filename: \"index.html\",\n    }),\n    new HtmlWebpackPlugin({\n      inject: true,\n      chunks: [\"options\"],\n      template: paths.appHtml,\n      filename: \"options.html\",\n    })\n    // new webpack.BannerPlugin({\n    //   banner,\n    //   raw: true,\n    //   entryOnly: true,\n    //   include: \"kiss-translator.user\",\n    // })\n  );\n\n  return config;\n};\n\n// 开发\nconst webWebpack = (config, env) => {\n  const names = [\"HtmlWebpackPlugin\"];\n\n  config.entry = {\n    main: paths.appIndexJs,\n    options: paths.appSrc + \"/options.js\",\n    content: paths.appSrc + \"/userscript.js\",\n  };\n\n  config.output.filename = \"[name].js\";\n  config.output.publicPath = \"/\";\n\n  config.plugins = config.plugins.filter(\n    (plugin) => !names.includes(plugin.constructor.name)\n  );\n\n  config.plugins.push(\n    new HtmlWebpackPlugin({\n      inject: true,\n      chunks: [\"main\"],\n      template: paths.appHtml,\n      filename: \"index.html\",\n    }),\n    new HtmlWebpackPlugin({\n      inject: true,\n      chunks: [\"options\"],\n      template: paths.appHtml,\n      filename: \"options.html\",\n    }),\n    new HtmlWebpackPlugin({\n      inject: true,\n      chunks: [\"content\"],\n      template: paths.appPublic + \"/content.html\",\n      filename: \"content.html\",\n    })\n  );\n\n  return config;\n};\n\nlet webpackConfig;\nswitch (process.env.REACT_APP_CLIENT) {\n  case \"userscript\":\n    webpackConfig = userscriptWebpack;\n    break;\n  case \"web\":\n    webpackConfig = webWebpack;\n    break;\n  default:\n    webpackConfig = extWebpack;\n}\n\nmodule.exports = {\n  webpack: webpackConfig,\n};\n"
  },
  {
    "path": "custom-api.md",
    "content": "# 自定义接口示例（本文档已过期，新版不再适用）\n\nV2版的示例请查看这里：[custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)\n\n以下示例为网友提供，仅供学习参考。\n\n## 本地运行 Seed-X-PPO-7B 量化模型\n\n> 由网友 emptyghost6 提供，来源：https://linux.do/t/topic/828257\n\nURL\n\n```sh\nhttp://localhost:8000/v1/completions\n```\n\nRequest Hook\n\n```js\n(text, from, to, url, key) => {\n  // 模型支持的语言代码到完整名称的映射\n  const langFullNameMap = {\n    ar: 'Arabic', fr: 'French', ms: 'Malay', ru: 'Russian',\n    cs: 'Czech', hr: 'Croatian', nb: 'Norwegian Bokmal', sv: 'Swedish',\n    da: 'Danish', hu: 'Hungarian', nl: 'Dutch', th: 'Thai',\n    de: 'German', id: 'Indonesian', no: 'Norwegian', tr: 'Turkish',\n    en: 'English', it: 'Italian', pl: 'Polish', uk: 'Ukrainian',\n    es: 'Spanish', ja: 'Japanese', pt: 'Portuguese', vi: 'Vietnamese',\n    fi: 'Finnish', ko: 'Korean', ro: 'Romanian', zh: 'Chinese'\n  };\n\n  // 将 Hook 系统的语言代码转换为模型 API 支持的代码\n  const getModelLangCode = (lang) => {\n    if (lang === 'zh-CN' || lang === 'zh-TW') return 'zh';\n    return lang;\n  };\n\n  const sourceLangCode = getModelLangCode(from);\n  const targetLangCode = getModelLangCode(to);\n\n  const sourceLangName = langFullNameMap[sourceLangCode] || from;\n  const targetLangName = langFullNameMap[targetLangCode] || to;\n\n  const prompt = `Translate it to ${targetLangName}:\\n${text} <${targetLangCode}>`;\n\n  // 构建请求体对象\n  const bodyObject = {\n    model: \"./ByteDance-Seed/Seed-X-PPO-7B-AWQ-Int4\",\n    prompt: prompt,\n    max_tokens: 2048,\n    temperature: 0.0,\n  };\n\n  // 返回最终的请求配置\n  return [url, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n    },\n    // 关键改动：将 JavaScript 对象转换为 JSON 字符串\n    body: JSON.stringify(bodyObject),\n  }];\n}\n```\n\nResponse Hook\n\n```js\n(res, text, from, to) => {\n  // 检查返回是否有效\n  if (res && res.choices && res.choices.length > 0 && res.choices[0].text) {\n\n    // 提取译文并去除可能存在的前后空格\n    const translatedText = res.choices[0].text.trim();\n\n    // 比较原文与译文，相同为 true，否则为 false。\n    const areTextsIdentical = text.trim() === translatedText;\n\n    // 返回数组：[翻译后的文本, 是否与原文相同]\n    return [translatedText, areTextsIdentical];\n  }\n  // 如果响应格式不正确或没有结果，则抛出错误\n  throw new Error(\"Invalid API response format or no translation found.\");\n}\n```\n\n## 接入 openrouter\n\n> 由网友 Rick Sanchez 提供\n\nURL\n\n```sh\nhttps://openrouter.ai/api/v1/chat/completions\n```\n\nRequest Hook\n\n```js\n(text, from, to, url, key) => [url, {\n  method: \"POST\",\n  headers: {\n      \"Authorization\": `Bearer ${key}`,\n      \"Content-type\": \"application/json\",\n  },\n  body: JSON.stringify({\n    \"model\": \"deepseek/deepseek-chat-v3-0324:free\", //可自定义你的模型\n    \"messages\": [\n      {\n        \"role\": \"user\",\n        \"content\":  //可自定义你的提示词\n`You are a professional ${to} native translator. Your task is to produce a fluent, natural, and culturally appropriate translation of the following text from ${from} to ${to}, fully conveying the meaning, tone, and nuance of the original.\n\n## Translation Rules\n1. Output only the final polished translation — no explanations, intermediate drafts, or notes.\n2. Translate in a way that reads naturally to a native ${to} audience, adapting idioms, cultural references, and tone when necessary.\n3. Preserve proper nouns, technical terms, brand names, and URLs exactly as in the original text unless a widely accepted ${to} equivalent exists.\n4. Keep any formatting (Markdown, HTML tags, bullet points, numbering) intact and positioned naturally within the translation.\n5. Adapt humor, metaphors, and figurative language to culturally relevant forms in ${to} while keeping the original intent.\n6. Maintain the same level of formality or informality as the original.\n\nSource Text: ${text}\n\nTranslated Text:`\n      }\n    ]\n  })\n}]\n```\n\nResponse Hook\n\n```js\n(res, text, from, to) => [\n  res.choices?.[0]?.message?.content ?? \"\", \n  false\n]\n```\n\n## 接入 gemini-2.5-flash, 关闭思考模式, 去审查\n\n> 由网友 Rick Sanchez 提供\n\nURL\n\n```sh\nhttps://generativelanguage.googleapis.com/v1beta/models\n```\n\nRequest Hook\n\n```js\n(text, from, to, url, key) => [`${url}/gemini-2.5-flash:generateContent?key=${key}`, {\n    headers: {\n        \"Content-Type\": \"application/json\",\n    },\n    method: \"POST\",\n    body: JSON.stringify({\n        \"generationConfig\": {\n            \"temperature\": 0.8,\n            \"thinkingConfig\": {\n                \"thinkingBudget\": 0, //gemini-2.5-flash设为0关闭思考模式\n            },\n        },\n        \"safetySettings\": [\n            {\n                \"category\": \"HARM_CATEGORY_HARASSMENT\",\n                \"threshold\": \"BLOCK_NONE\",\n            },\n            {\n                \"category\": \"HARM_CATEGORY_HATE_SPEECH\",\n                \"threshold\": \"BLOCK_NONE\",\n            },\n            {\n                \"category\": \"HARM_CATEGORY_SEXUALLY_EXPLICIT\",\n                \"threshold\": \"BLOCK_NONE\",\n            },\n            {\n                \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n                \"threshold\": \"BLOCK_NONE\",\n            }\n        ],\n        \"contents\": [{\n            \"parts\": [{\n                \"text\": `自定义提示词`\n            }]\n        }],\n    }),\n}]\n```\n\nResponse Hook\n\n```js\n(res, text, from, to) => [\n  res.candidates?.[0]?.content?.parts?.[0]?.text ?? \"\",\n  false\n]\n```\n\n## 接入 Qwen-MT\n\n> 由网友 atom 提供\n\nURL\n\n```sh\nhttps://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions\n```\n\nRequest Hook\n\n```js\n(text, from, to, url, key) => {\n  const mapLanguageCode = (lang) => ({\n    'zh-CN': 'zh',\n    'zh-TW': 'zh_tw',\n  })[lang] || lang;\n\n  const targetLang = mapLanguageCode(to);\n\n  return [\n    url,\n    {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n        \"Authorization\": `Bearer ${key}`\n      },\n      body: JSON.stringify({\n        \"model\": \"qwen-mt-turbo\",\n        \"messages\": [\n          {\n            \"role\": \"user\",\n            \"content\": text\n          }\n        ],\n        \"translation_options\": {\n          \"source_lang\": \"auto\",\n          \"target_lang\": targetLang\n        }\n      })\n    }\n  ];\n}\n```\n\nResponse Hook\n\n```js\n(res, text, from, to) => [res.choices?.[0]?.message?.content ?? \"\", false]\n```\n\n\n## 接入 deepl 接口\n\n> 来源： https://github.com/fishjar/kiss-translator/issues/101#issuecomment-2123786236\n\nRequest Hook\n\n```js\n(text, from, to, url, key) => [\n  url,\n  {\n    headers: {\n      \"Content-type\": \"application/json\",\n    },\n    method: \"POST\",\n    body: JSON.stringify({\n      text,\n      target_lang: \"ZH\",\n      source_lang: \"auto\",\n    }),\n  },\n]\n```\n\nResponse Hook\n\n```js\n(res, text, from, to) => [res.data, \"ZH\" === res.source_lang]\n```\n\n## 接入智谱AI大模型\n\n> 来源： https://github.com/fishjar/kiss-translator/issues/205#issuecomment-2642422679\n\nRequest Hook\n\n```js\n(text, from, to, url, key) => [url, {\n  \"method\": \"POST\",\n  \"headers\": {\n    \"Content-type\": \"application/json\",\n    \"Authorization\": key\n  },\n  \"body\": JSON.stringify({\n  \t\"model\": \"glm-4-flash\",\n  \t\"messages\": [\n  \t\t{\n  \t\t\t\"role\":\"system\",\n  \t\t\t\"content\": \"You are a professional, authentic machine translation engine. You only return the translated text, without any explanations.\"\n  \t\t},\n  \t\t{\n  \t\t\t\"role\": \"user\",\n  \t\t\t\"content\": `Translate the following text into ${to}. If translation is unnecessary (e.g. proper nouns, codes, etc.), return the original text. NO explanations. NO notes:\\n\\n ${text} `\n  \t\t}\n  \t]\n  })\n}]\n```\n\n## 接入谷歌新接口\n\n> 由网友 Bush2021 提供，来源：https://github.com/fishjar/kiss-translator/issues/225#issuecomment-2810950717\n\nURL\n\n```sh\nhttps://translate-pa.googleapis.com/v1/translateHtml\n```\n\nKEY\n\n```sh\nAIzaSyATBXajvzQLTDHEQbcpq0Ihe0vWDHmO520\n```\n\nRequest Hook\n\n```js\n(text, from, to, url, key) => [url, {\n    method: \"POST\", \n    headers: { \n        \"Content-Type\": \"application/json+protobuf\", \n        \"X-Goog-API-Key\": key\n    }, \n    body: JSON.stringify([[[text], from || \"auto\", to], \"wt_lib\"])\n}]\n```\n\nResponse Hook\n\n```js\n(res, text, from, to) => [res?.[0]?.join(\" \") || \"Translation unavailable\", to === res?.[1]?.[0]]\n```\n\n\n"
  },
  {
    "path": "custom-api_v2.md",
    "content": "# 自定义接口说明及示例\n\n## 默认接口规范\n\n如果接口的请求数据和返回数据符合以下规范，\n则无需填写 `Request Hook` 或 `Response Hook`。\n\n\n### 非聚合翻译\n\nRequest body\n\n```json\n{\n  \"text\": \"hello\",    // 需要翻译的文本列表\n  \"from\":\"auto\",      // 原文语言\n  \"to\": \"zh-CN\"       // 目标语言\n}\n```\n\nResponse\n\n```json\n{\n  \"text\": \"你好\",    // 译文\n  \"src\": \"en\"       // 原文语言\n}\n\n// 或者\n{\n  \"text\": \"你好\",    // 译文\n  \"from\": \"en\"       // 原文语言\n}\n```\n\n\n### 聚合翻译\n\nRequest body\n\n```json\n{\n  \"texts\": [\"hello\"], // 需要翻译的文本列表\n  \"from\":\"auto\",      // 原文语言\n  \"to\": \"zh-CN\"       // 目标语言\n}\n```\n\nResponse\n\n```json\n[\n  {\n    \"text\": \"你好\",    // 译文\n    \"src\": \"en\"       // 原文语言\n  }\n]\n```\n\nv2.0.4版后亦支持以下 Response 格式\n\n```json\n{\n  \"translations\": [   // 译文列表\n    {\n      \"text\": \"你好\",  // 译文\n      \"src\": \"en\"     // 原文语言\n    }\n  ]\n}\n```\n\n## Prompt 相关\n\n`Prompt` 可替换占位符：\n\n```js\n`{{from}}`        // 原文语言名称\n`{{to}}`          // 目标语言名称\n`{{fromLang}}`    // 原文语言代码\n`{{toLang}}`      // 目标语言代码\n`{{text}}`        // 原文\n`{{tone}}`        // 风格\n`{{title}}`       // 页面标题\n`{{description}}` // 页面描述\n```\n\nHook 中 `Prompt` 类型说明：\n\n```js\n`systemPrompt`      // 聚合翻译 System Prompt\n`nobatchPrompt`     // 非聚合翻译 System Prompt\n`nobatchUserPrompt` // 非聚合翻译 User Prompt\n`subtitlePrompt`    // 字幕翻译 System Prompt\n```\n\n## 谷歌翻译接口\n\n> 此接口不支持聚合\n\nURL\n\n```\nhttps://translate.googleapis.com/translate_a/single?client=gtx&dj=1&dt=t&ie=UTF-8&q={{text}}&sl=en&tl=zh-CN\n```\n\nRequest Hook\n\n```js\nasync (args) => {\n  const url = args.url.replace(\"{{text}}\", args.texts[0]);\n  const method = \"GET\";\n  return { url, method };\n};\n```\n\nResponse Hook\n\n```js\nasync ({ res }) => {\n  return { translations: [[res?.sentences?.[0]?.trans || \"\", res?.src]] };\n};\n```\n\n\n## Ollama\n\n> 此示例为开启聚合翻译的写法\n\n* 注意 ollama 启动参数需要添加环境变量 `OLLAMA_ORIGINS=*`\n* 检查环境变量生效命令：`systemctl show ollama | grep OLLAMA_ORIGINS`\n\nURL\n\n```\nhttp://localhost:11434/v1/chat/completions\n```\n\nRequest Hook\n\n```js\nasync (args) => {\n  const url = args.url;\n  const method = \"POST\";\n  const headers = { \"Content-type\": \"application/json\" };\n  const body = {\n    model: \"gemma3\", // 或 args.model\n    messages: [\n      {\n        role: \"system\",\n        content: args.systemPrompt,\n      },\n      {\n        role: \"user\",\n        content: JSON.stringify({\n          targetLanguage: args.toLang,\n          segments: args.texts.map((text, id) => ({ id, text })),\n          title: \"\", // 可省略\n          description: \"\", // 可省略\n          glossary: {}, // 可省略\n          tone: \"\", // 可省略\n        }),\n      },\n    ],\n    temperature: 0,\n    max_tokens: 20480,\n    think: false,\n    stream: false,\n  };\n\n  return { url, body, headers, method };\n};\n```\n\nResponse Hook\n\n```js\nasync ({ res, parseAIRes }) => {\n  const translations = parseAIRes(res?.choices?.[0]?.message?.content);\n  return { translations };\n};\n```\n\n\n## 硅基流动\n\n> 此示例为禁用聚合翻译的写法\n\nURL\n\n```\nhttps://api.siliconflow.cn/v1/chat/completions\n```\n\nRequest Hook\n\n```js\nasync (args) => {\n  const url = args.url;\n  const method = \"POST\";\n  const headers = {\n    \"Content-type\": \"application/json\",\n    Authorization: `Bearer ${args.key}`,\n  };\n  const body = {\n    model: \"tencent/Hunyuan-MT-7B\", // 或 args.model,\n    messages: [\n      {\n        role: \"system\",\n        content: args.systemPrompt,\n      },\n      {\n        role: \"user\",\n        content: args.userPrompt,\n      },\n    ],\n    temperature: 0,\n    max_tokens: 20480,\n  };\n\n  return { url, body, headers, method };\n};\n```\n\nResponse Hook\n\n```js\nasync ({ res }) => {\n  return { translations: [[res?.choices?.[0]?.message?.content || \"\"]] };\n};\n```\n\n\n## 语言代码表及说明\n\nHook参数里面的语言含义说明：\n\n- `toLang`, `fromLang` 是本插件支持的标准语言代码\n- `to`, `from` 是转换后的适用于特定接口的语言代码\n\n如果你的自定义接口与下面的标准语言代码不匹配，需要自行映射转换。\n\n```\n[\"en\", \"English - English\"],\n[\"zh-CN\", \"Simplified Chinese - 简体中文\"],\n[\"zh-TW\", \"Traditional Chinese - 繁體中文\"],\n[\"ar\", \"Arabic - العربية\"],\n[\"bg\", \"Bulgarian - Български\"],\n[\"ca\", \"Catalan - Català\"],\n[\"hr\", \"Croatian - Hrvatski\"],\n[\"cs\", \"Czech - Čeština\"],\n[\"da\", \"Danish - Dansk\"],\n[\"nl\", \"Dutch - Nederlands\"],\n[\"fa\", \"Persian - فارسی\"],\n[\"fi\", \"Finnish - Suomi\"],\n[\"fr\", \"French - Français\"],\n[\"de\", \"German - Deutsch\"],\n[\"el\", \"Greek - Ελληνικά\"],\n[\"hi\", \"Hindi - हिन्दी\"],\n[\"hu\", \"Hungarian - Magyar\"],\n[\"id\", \"Indonesian - Indonesia\"],\n[\"it\", \"Italian - Italiano\"],\n[\"ja\", \"Japanese - 日本語\"],\n[\"ko\", \"Korean - 한국어\"],\n[\"ms\", \"Malay - Melayu\"],\n[\"mt\", \"Maltese - Malti\"],\n[\"nb\", \"Norwegian - Norsk Bokmål\"],\n[\"pl\", \"Polish - Polski\"],\n[\"pt\", \"Portuguese - Português\"],\n[\"ro\", \"Romanian - Română\"],\n[\"ru\", \"Russian - Русский\"],\n[\"sk\", \"Slovak - Slovenčina\"],\n[\"sl\", \"Slovenian - Slovenščina\"],\n[\"es\", \"Spanish - Español\"],\n[\"sv\", \"Swedish - Svenska\"],\n[\"ta\", \"Tamil - தமிழ்\"],\n[\"te\", \"Telugu - తెలుగు\"],\n[\"th\", \"Thai - ไทย\"],\n[\"tr\", \"Turkish - Türkçe\"],\n[\"uk\", \"Ukrainian - Українська\"],\n[\"vi\", \"Vietnamese - Tiếng Việt\"],\n```\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"kiss-translator\",\n  \"description\": \"A minimalist bilingual translation Extension & Greasemonkey Script\",\n  \"version\": \"2.0.20\",\n  \"author\": \"Gabe<yugang2002@gmail.com>\",\n  \"private\": true,\n  \"dependencies\": {\n    \"@emotion/cache\": \"^11.11.0\",\n    \"@emotion/css\": \"^11.13.5\",\n    \"@emotion/react\": \"^11.11.1\",\n    \"@emotion/styled\": \"^11.11.0\",\n    \"@mui/icons-material\": \"^5.15.15\",\n    \"@mui/lab\": \"5.0.0-alpha.170\",\n    \"@mui/material\": \"^5.15.15\",\n    \"@streamparser/json\": \"^0.0.22\",\n    \"query-string\": \"^8.1.0\",\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\",\n    \"react-markdown\": \"^8.0.7\",\n    \"react-router-dom\": \"^6.16.0\",\n    \"react-scripts\": \"5.0.1\",\n    \"sval\": \"^0.5.2\",\n    \"webdav\": \"^5.3.0\",\n    \"webextension-polyfill\": \"^0.10.0\"\n  },\n  \"scripts\": {\n    \"start\": \"cross-env REACT_APP_CLIENT=web react-app-rewired start\",\n    \"start:userscript\": \"cross-env REACT_APP_CLIENT=userscript react-app-rewired start\",\n    \"sync-version\": \"zx src/scripts/sync-version.mjs\",\n    \"version:patch\": \"zx src/scripts/update-version.mjs patch\",\n    \"version:minor\": \"zx src/scripts/update-version.mjs minor\",\n    \"version:major\": \"zx src/scripts/update-version.mjs major\",\n    \"version:set\": \"zx src/scripts/update-version.mjs set\",\n    \"build:chrome\": \"zx src/scripts/build-task.mjs --target=chrome\",\n    \"build:edge\": \"zx src/scripts/build-task.mjs --target=edge\",\n    \"build:safari-output\": \"zx src/scripts/build-task.mjs --target=safari\",\n    \"build:thunderbird\": \"zx src/scripts/build-task.mjs --target=thunderbird\",\n    \"build:firefox\": \"zx src/scripts/build-task.mjs --target=firefox\",\n    \"build:web\": \"zx src/scripts/build-task.mjs --target=web\",\n    \"build:safari\": \"node src/scripts/build-safari.mjs\",\n    \"build:userscript-ios\": \"zx src/scripts/build-ios.mjs\",\n    \"build:rules\": \"babel-node src/rules.js\",\n    \"format\": \"prettier --write \\\"**/*.{js,json,html}\\\"\",\n    \"build\": \"pnpm sync-version && pnpm format && pnpm build:chrome && pnpm build:edge && pnpm build:thunderbird && pnpm build:firefox && pnpm build:web && pnpm build:userscript-ios && pnpm build:rules\",\n    \"zip\": \"zx src/scripts/archive.mjs\",\n    \"build+zip\": \"cross-env CI=true pnpm build && pnpm zip\",\n    \"test\": \"react-app-rewired test\",\n    \"eject\": \"react-scripts eject\"\n  },\n  \"eslintConfig\": {\n    \"extends\": [\n      \"react-app\",\n      \"react-app/jest\"\n    ],\n    \"globals\": {\n      \"GM\": true,\n      \"unsafeWindow\": true,\n      \"globalThis\": true,\n      \"messenger\": true,\n      \"LanguageDetector\": true,\n      \"Translator\": true\n    }\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.2%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  },\n  \"devDependencies\": {\n    \"@babel/core\": \"^7.22.20\",\n    \"@babel/node\": \"^7.22.19\",\n    \"@babel/plugin-proposal-private-property-in-object\": \"^7.21.11\",\n    \"@babel/preset-env\": \"^7.22.20\",\n    \"bestzip\": \"^2.2.1\",\n    \"cross-env\": \"^7.0.3\",\n    \"dotenv\": \"^17.2.1\",\n    \"find-up\": \"^7.0.0\",\n    \"prettier\": \"3.6.2\",\n    \"react-app-rewired\": \"^2.2.1\",\n    \"zx\": \"^8.8.1\"\n  }\n}\n"
  },
  {
    "path": "public/.nojekyll",
    "content": ""
  },
  {
    "path": "public/_locales/de/messages.json",
    "content": "{\n  \"app_name\": {\n    \"message\": \"KISS Übersetzer\"\n  },\n  \"app_description\": {\n    \"message\": \"Eine minimalistische zweisprachige Übersetzungserweiterung, die Webseiten-, Textauswahl- und Videountertitel-Übersetzung unterstützt.\"\n  },\n  \"toggle_translate\": {\n    \"message\": \"Übersetzung umschalten\"\n  },\n  \"toggle_style\": {\n    \"message\": \"Stile umschalten\"\n  },\n  \"open_options\": {\n    \"message\": \"Einstellungen öffnen\"\n  },\n  \"open_tranbox\": {\n    \"message\": \"Popup-Fenster öffnen\"\n  },\n  \"open_separate_window\": {\n    \"message\": \"In eigenem Fenster öffnen\"\n  }\n}\n"
  },
  {
    "path": "public/_locales/en/messages.json",
    "content": "{\n  \"app_name\": {\n    \"message\": \"KISS Translator\"\n  },\n  \"app_description\": {\n    \"message\": \"A minimalist bilingual translation extension that supports webpage, text selection, and video subtitle translation.\"\n  },\n  \"toggle_translate\": {\n    \"message\": \"Toggle Translation\"\n  },\n  \"toggle_style\": {\n    \"message\": \"Toggle Styles\"\n  },\n  \"open_options\": {\n    \"message\": \"Open Setting\"\n  },\n  \"open_tranbox\": {\n    \"message\": \"Open Popup Box\"\n  },\n  \"open_separate_window\": {\n    \"message\": \"Open Independent Window\"\n  }\n}\n"
  },
  {
    "path": "public/_locales/es/messages.json",
    "content": "{\n  \"app_name\": {\n    \"message\": \"KISS Traductor\"\n  },\n  \"app_description\": {\n    \"message\": \"Una extensión de traducción bilingüe minimalista que admite la traducción de páginas web, selección de texto y subtítulos de video.\"\n  },\n  \"toggle_translate\": {\n    \"message\": \"Alternar traducción\"\n  },\n  \"toggle_style\": {\n    \"message\": \"Cambiar estilo\"\n  },\n  \"open_options\": {\n    \"message\": \"Abrir configuración\"\n  },\n  \"open_tranbox\": {\n    \"message\": \"Abrir ventana emergente\"\n  },\n  \"open_separate_window\": {\n    \"message\": \"Abrir en ventana independiente\"\n  }\n}\n"
  },
  {
    "path": "public/_locales/fr/messages.json",
    "content": "{\n  \"app_name\": {\n    \"message\": \"KISS Traducteur\"\n  },\n  \"app_description\": {\n    \"message\": \"Une extension de traduction bilingue minimaliste prenant en charge la traduction de pages web, de texte sélectionné et de sous-titres vidéo.\"\n  },\n  \"toggle_translate\": {\n    \"message\": \"Activer/désactiver la traduction\"\n  },\n  \"toggle_style\": {\n    \"message\": \"Changer de style\"\n  },\n  \"open_options\": {\n    \"message\": \"Ouvrir les paramètres\"\n  },\n  \"open_tranbox\": {\n    \"message\": \"Ouvrir la fenêtre contextuelle\"\n  },\n  \"open_separate_window\": {\n    \"message\": \"Ouvrir dans une fenêtre indépendante\"\n  }\n}\n"
  },
  {
    "path": "public/_locales/ja/messages.json",
    "content": "{\n  \"app_name\": {\n    \"message\": \"シンプル翻訳\"\n  },\n  \"app_description\": {\n    \"message\": \"Webサイト、テキスト選択、動画字幕などの翻訳に対応した、シンプルで直感的な二言語対応翻訳拡張機能。複数の翻訳サービスやAI翻訳APIをサポートし、豊富で柔軟なカスタマイズオプションを備えています。\"\n  },\n  \"toggle_translate\": {\n    \"message\": \"翻訳の切り替え\"\n  },\n  \"toggle_style\": {\n    \"message\": \"スタイル切り替え\"\n  },\n  \"open_options\": {\n    \"message\": \"設定を開く\"\n  },\n  \"open_tranbox\": {\n    \"message\": \"ポップアップを開く\"\n  },\n  \"open_separate_window\": {\n    \"message\": \"独立ウィンドウで開く\"\n  }\n}\n"
  },
  {
    "path": "public/_locales/ko/messages.json",
    "content": "{\n  \"app_name\": {\n    \"message\": \"심플 번역\"\n  },\n  \"app_description\": {\n    \"message\": \"웹페이지, 단어 선택, 동영상 자막 번역 등을 지원하는 심플한 이중 언어 대조 번역 확장 프로그램. 다양한 번역 서비스 및 AI 번역 인터페이스를 지원하며, 풍부하고 유연한 사용자 설정 옵션을 제공합니다.\"\n  },\n  \"toggle_translate\": {\n    \"message\": \"번역 켜기\"\n  },\n  \"toggle_style\": {\n    \"message\": \"스타일 전환\"\n  },\n  \"open_options\": {\n    \"message\": \"설정 열기\"\n  },\n  \"open_tranbox\": {\n    \"message\": \"팝업 열기\"\n  },\n  \"open_separate_window\": {\n    \"message\": \"독립 창으로 열기\"\n  }\n}\n"
  },
  {
    "path": "public/_locales/zh_CN/messages.json",
    "content": "{\n  \"app_name\": {\n    \"message\": \"简约翻译\"\n  },\n  \"app_description\": {\n    \"message\": \"一个简约的双语对照翻译扩展，支持网页、划词、视频字幕翻译等功能，支持多种翻译服务及AI翻译接口，拥有丰富灵活的自定义选项。\"\n  },\n  \"toggle_translate\": {\n    \"message\": \"开启翻译\"\n  },\n  \"toggle_style\": {\n    \"message\": \"切换样式\"\n  },\n  \"open_options\": {\n    \"message\": \"打开设置\"\n  },\n  \"open_tranbox\": {\n    \"message\": \"打开弹窗\"\n  },\n  \"open_separate_window\": {\n    \"message\": \"打开独立窗口\"\n  }\n}\n"
  },
  {
    "path": "public/_locales/zh_TW/messages.json",
    "content": "{\n  \"app_name\": {\n    \"message\": \"簡約翻譯\"\n  },\n  \"app_description\": {\n    \"message\": \"一個簡約的雙語對照翻譯擴充功能，支持網頁、劃詞、影片字幕翻譯等功能，支持多種翻譯服務及 AI 翻譯接口，擁有豐富靈活的自定義選項。\"\n  },\n  \"toggle_translate\": {\n    \"message\": \"開啟翻譯\"\n  },\n  \"toggle_style\": {\n    \"message\": \"切換樣式\"\n  },\n  \"open_options\": {\n    \"message\": \"開啟設定\"\n  },\n  \"open_tranbox\": {\n    \"message\": \"開啟彈出視窗\"\n  },\n  \"open_separate_window\": {\n    \"message\": \"打開獨立視窗\"\n  }\n}\n"
  },
  {
    "path": "public/content.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n    <title>%REACT_APP_NAME%</title>\n  </head>\n\n  <body>\n    <noscript>You need to enable JavaScript to run this app.</noscript>\n    <div id=\"root\">\n    </div>\n    <!--\n      This HTML file is a template.\n      If you open it directly in the browser, you will see an empty page.\n\n      You can add webfonts, meta tags, or analytics to this file.\n      The build step will place the bundled scripts into the <body> tag.\n\n      To begin the development, run `npm start` or `yarn start`.\n      To create a production bundle, use `npm run build` or `yarn build`.\n    -->\n  </body>\n</html>\n"
  },
  {
    "path": "public/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n    <title>%REACT_APP_NAME% v%REACT_APP_VERSION%</title>\n  </head>\n  <body>\n    <noscript>You need to enable JavaScript to run this app.</noscript>\n    <div id=\"root\"></div>\n    <!--\n      This HTML file is a template.\n      If you open it directly in the browser, you will see an empty page.\n\n      You can add webfonts, meta tags, or analytics to this file.\n      The build step will place the bundled scripts into the <body> tag.\n\n      To begin the development, run `npm start` or `yarn start`.\n      To create a production bundle, use `npm run build` or `yarn build`.\n    -->\n  </body>\n</html>\n"
  },
  {
    "path": "public/manifest.firefox.json",
    "content": "{\n  \"manifest_version\": 2,\n  \"name\": \"__MSG_app_name__\",\n  \"description\": \"__MSG_app_description__\",\n  \"version\": \"2.0.20\",\n  \"default_locale\": \"en\",\n  \"author\": \"Gabe<yugang2002@gmail.com>\",\n  \"homepage_url\": \"https://github.com/fishjar/kiss-translator\",\n  \"background\": {\n    \"scripts\": [\n      \"background.js\"\n    ]\n  },\n  \"content_scripts\": [\n    {\n      \"js\": [\n        \"content.js\"\n      ],\n      \"matches\": [\n        \"<all_urls>\",\n        \"file://*/*\"\n      ],\n      \"all_frames\": true\n    }\n  ],\n  \"web_accessible_resources\": [\n    \"injector-subtitle.js\",\n    \"injector-shadowroot.js\"\n  ],\n  \"commands\": {\n    \"_execute_browser_action\": {\n      \"suggested_key\": {\n        \"default\": \"Alt+K\"\n      }\n    },\n    \"toggleTranslate\": {\n      \"suggested_key\": {\n        \"default\": \"Alt+Q\"\n      },\n      \"description\": \"__MSG_toggle_translate__\"\n    },\n    \"openTranbox\": {\n      \"suggested_key\": {\n        \"default\": \"Alt+S\"\n      },\n      \"description\": \"__MSG_open_tranbox__\"\n    },\n    \"openSeparateWindow\": {\n      \"description\": \"__MSG_open_separate_window__\"\n    },\n    \"toggleStyle\": {\n      \"suggested_key\": {\n        \"default\": \"Alt+C\"\n      },\n      \"description\": \"__MSG_toggle_style__\"\n    },\n    \"openOptions\": {\n      \"description\": \"__MSG_open_options__\"\n    }\n  },\n  \"permissions\": [\n    \"<all_urls>\",\n    \"storage\",\n    \"contextMenus\",\n    \"scripting\",\n    \"declarativeNetRequest\"\n  ],\n  \"icons\": {\n    \"16\": \"images/logo16.png\",\n    \"32\": \"images/logo32.png\",\n    \"48\": \"images/logo48.png\",\n    \"128\": \"images/logo128.png\"\n  },\n  \"browser_action\": {\n    \"default_icon\": {\n      \"128\": \"images/logo128.png\"\n    },\n    \"default_title\": \"__MSG_app_name__\",\n    \"default_popup\": \"popup.html\"\n  },\n  \"options_ui\": {\n    \"page\": \"options.html\",\n    \"open_in_tab\": true\n  }\n}\n"
  },
  {
    "path": "public/manifest.json",
    "content": "{\n  \"manifest_version\": 3,\n  \"name\": \"__MSG_app_name__\",\n  \"description\": \"__MSG_app_description__\",\n  \"version\": \"2.0.20\",\n  \"default_locale\": \"en\",\n  \"author\": \"Gabe<yugang2002@gmail.com>\",\n  \"homepage_url\": \"https://github.com/fishjar/kiss-translator\",\n  \"background\": {\n    \"service_worker\": \"background.js\",\n    \"type\": \"module\"\n  },\n  \"content_scripts\": [\n    {\n      \"js\": [\n        \"content.js\"\n      ],\n      \"matches\": [\n        \"<all_urls>\",\n        \"file://*/*\"\n      ],\n      \"all_frames\": true\n    }\n  ],\n  \"web_accessible_resources\": [\n    {\n      \"resources\": [\n        \"injector-subtitle.js\"\n      ],\n      \"matches\": [\n        \"https://www.youtube.com/*\"\n      ]\n    },\n    {\n      \"resources\": [\n        \"injector-shadowroot.js\"\n      ],\n      \"matches\": [\n        \"<all_urls>\"\n      ]\n    }\n  ],\n  \"commands\": {\n    \"_execute_action\": {\n      \"suggested_key\": {\n        \"default\": \"Alt+K\"\n      }\n    },\n    \"toggleTranslate\": {\n      \"suggested_key\": {\n        \"default\": \"Alt+Q\"\n      },\n      \"description\": \"__MSG_toggle_translate__\"\n    },\n    \"openTranbox\": {\n      \"suggested_key\": {\n        \"default\": \"Alt+S\"\n      },\n      \"description\": \"__MSG_open_tranbox__\"\n    },\n    \"openSeparateWindow\": {\n      \"description\": \"__MSG_open_separate_window__\"\n    },\n    \"toggleStyle\": {\n      \"suggested_key\": {\n        \"default\": \"Alt+C\"\n      },\n      \"description\": \"__MSG_toggle_style__\"\n    },\n    \"openOptions\": {\n      \"description\": \"__MSG_open_options__\"\n    }\n  },\n  \"permissions\": [\n    \"storage\",\n    \"contextMenus\",\n    \"scripting\",\n    \"declarativeNetRequest\",\n    \"declarativeNetRequestWithHostAccess\"\n  ],\n  \"host_permissions\": [\n    \"<all_urls>\"\n  ],\n  \"icons\": {\n    \"16\": \"images/logo16.png\",\n    \"32\": \"images/logo32.png\",\n    \"48\": \"images/logo48.png\",\n    \"128\": \"images/logo128.png\"\n  },\n  \"action\": {\n    \"default_icon\": {\n      \"128\": \"images/logo128.png\"\n    },\n    \"default_title\": \"__MSG_app_name__\",\n    \"default_popup\": \"popup.html\"\n  },\n  \"options_ui\": {\n    \"page\": \"options.html\",\n    \"open_in_tab\": true\n  }\n}\n"
  },
  {
    "path": "public/manifest.thunderbird.json",
    "content": "{\n  \"manifest_version\": 2,\n  \"name\": \"__MSG_app_name__\",\n  \"description\": \"__MSG_app_description__\",\n  \"version\": \"2.0.20\",\n  \"default_locale\": \"en\",\n  \"author\": \"Gabe<yugang2002@gmail.com>\",\n  \"homepage_url\": \"https://github.com/fishjar/kiss-translator\",\n  \"browser_specific_settings\": {\n    \"gecko\": {\n      \"id\": \"yugang2002@gmail.com\",\n      \"strict_min_version\": \"78.0\"\n    }\n  },\n  \"background\": {\n    \"scripts\": [\n      \"background.js\"\n    ]\n  },\n  \"content_scripts\": [\n    {\n      \"js\": [\n        \"content.js\"\n      ],\n      \"matches\": [\n        \"<all_urls>\",\n        \"file://*/*\"\n      ],\n      \"all_frames\": true\n    }\n  ],\n  \"web_accessible_resources\": [\n    \"injector-subtitle.js\",\n    \"injector-shadowroot.js\"\n  ],\n  \"commands\": {\n    \"_execute_browser_action\": {\n      \"suggested_key\": {\n        \"default\": \"Alt+K\"\n      }\n    },\n    \"toggleTranslate\": {\n      \"suggested_key\": {\n        \"default\": \"Alt+Q\"\n      },\n      \"description\": \"__MSG_toggle_translate__\"\n    },\n    \"openTranbox\": {\n      \"suggested_key\": {\n        \"default\": \"Alt+S\"\n      },\n      \"description\": \"__MSG_open_tranbox__\"\n    },\n    \"openSeparateWindow\": {\n      \"description\": \"__MSG_open_separate_window__\"\n    },\n    \"toggleStyle\": {\n      \"suggested_key\": {\n        \"default\": \"Alt+C\"\n      },\n      \"description\": \"__MSG_toggle_style__\"\n    },\n    \"openOptions\": {\n      \"description\": \"__MSG_open_options__\"\n    }\n  },\n  \"permissions\": [\n    \"<all_urls>\",\n    \"storage\",\n    \"menus\",\n    \"messagesModify\",\n    \"scripting\",\n    \"declarativeNetRequest\"\n  ],\n  \"icons\": {\n    \"16\": \"images/logo16.png\",\n    \"32\": \"images/logo32.png\",\n    \"48\": \"images/logo48.png\",\n    \"128\": \"images/logo128.png\"\n  },\n  \"browser_action\": {\n    \"default_icon\": {\n      \"128\": \"images/logo128.png\"\n    },\n    \"default_title\": \"__MSG_app_name__\",\n    \"default_popup\": \"popup.html\"\n  },\n  \"options_ui\": {\n    \"page\": \"options.html\",\n    \"open_in_tab\": true\n  }\n}\n"
  },
  {
    "path": "src/apis/baidu.js",
    "content": "import { DEFAULT_USER_AGENT } from \"../config\";\n\nexport const genBaidu = ({ texts, from, to }) => {\n  const body = {\n    from,\n    to,\n    query: texts.join(\" \"),\n    source: \"txt\",\n  };\n\n  const url = \"https://fanyi.baidu.com/transapi\";\n  const headers = {\n    // Origin: \"https://fanyi.baidu.com\",\n    \"content-type\": \"application/x-www-form-urlencoded; charset=UTF-8\",\n    \"User-Agent\": DEFAULT_USER_AGENT,\n  };\n\n  return { url, body, headers };\n};\n"
  },
  {
    "path": "src/apis/deepl.js",
    "content": "let id = 1e4 * Math.round(1e4 * Math.random());\n\nexport const genDeeplFree = ({ texts, from, to }) => {\n  const text = texts.join(\" \");\n  const iCount = (text.match(/[i]/g) || []).length + 1;\n  let timestamp = Date.now();\n  timestamp = timestamp + (iCount - (timestamp % iCount));\n  id++;\n\n  const url = \"https://www2.deepl.com/jsonrpc\";\n\n  const body = {\n    jsonrpc: \"2.0\",\n    method: \"LMT_handle_texts\",\n    params: {\n      splitting: \"newlines\",\n      lang: {\n        target_lang: to,\n        source_lang_user_selected: from,\n      },\n      commonJobParams: {\n        wasSpoken: false,\n        transcribe_as: \"\",\n      },\n      id,\n      timestamp,\n      texts: [\n        {\n          text,\n          requestAlternatives: 3,\n        },\n      ],\n    },\n  };\n\n  const headers = {\n    \"Content-Type\": \"application/json\",\n    Accept: \"*/*\",\n    \"x-app-os-name\": \"iOS\",\n    \"x-app-os-version\": \"16.3.0\",\n    \"Accept-Language\": \"en-US,en;q=0.9\",\n    \"Accept-Encoding\": \"gzip, deflate, br\",\n    \"x-app-device\": \"iPhone13,2\",\n    \"User-Agent\": \"DeepL-iOS/2.9.1 iOS 16.3.0 (iPhone13,2)\",\n    \"x-app-build\": \"510265\",\n    \"x-app-version\": \"2.9.1\",\n  };\n\n  return { url, body, headers };\n};\n"
  },
  {
    "path": "src/apis/history.js",
    "content": "import { DEFAULT_CONTEXT_SIZE } from \"../config\";\n\nconst historyMap = new Map();\n\nconst MsgHistory = (maxSize = DEFAULT_CONTEXT_SIZE) => {\n  const messages = [];\n\n  const add = (...msgs) => {\n    messages.push(...msgs.filter(Boolean));\n    const extra = messages.length - maxSize;\n    if (extra > 0) {\n      messages.splice(0, extra);\n    }\n  };\n\n  const getAll = () => {\n    return [...messages];\n  };\n\n  const clear = () => {\n    messages.length = 0;\n  };\n\n  return {\n    add,\n    getAll,\n    clear,\n  };\n};\n\nexport const getMsgHistory = (apiSlug, maxSize) => {\n  if (historyMap.has(apiSlug)) {\n    return historyMap.get(apiSlug);\n  }\n\n  const msgHistory = MsgHistory(maxSize);\n  historyMap.set(apiSlug, msgHistory);\n  return msgHistory;\n};\n"
  },
  {
    "path": "src/apis/index.js",
    "content": "import queryString from \"query-string\";\nimport { fetchData } from \"../libs/fetch\";\nimport {\n  URL_CACHE_TRAN,\n  URL_CACHE_DELANG,\n  URL_CACHE_BINGDICT,\n  KV_SALT_SYNC,\n  OPT_LANGS_TO_SPEC,\n  OPT_LANGS_SPEC_DEFAULT,\n  API_SPE_TYPES,\n  DEFAULT_API_SETTING,\n  OPT_TRANS_MICROSOFT,\n  MSG_BUILTINAI_DETECT,\n  MSG_BUILTINAI_TRANSLATE,\n  OPT_TRANS_BUILTINAI,\n  URL_CACHE_SUBTITLE,\n  OPT_LANGS_TO_CODE,\n} from \"../config\";\nimport { sha256, withTimeout } from \"../libs/utils\";\nimport {\n  handleTranslate,\n  handleSubtitle,\n  handleMicrosoftLangdetect,\n} from \"./trans\";\nimport { getHttpCachePolyfill, putHttpCachePolyfill } from \"../libs/cache\";\nimport { getBatchQueue } from \"../libs/batchQueue\";\nimport { isBuiltinAIAvailable } from \"../libs/browser\";\nimport { chromeDetect, chromeTranslate } from \"../libs/builtinAI\";\nimport { fnPolyfill } from \"../libs/fetch\";\nimport { getFetchPool } from \"../libs/pool\";\n\n/**\n * 同步数据\n * @param {*} url\n * @param {*} key\n * @param {*} data\n * @returns\n */\nexport const apiSyncData = async (url, key, data) =>\n  fetchData(url, {\n    headers: {\n      \"Content-type\": \"application/json\",\n      Authorization: `Bearer ${await sha256(key, KV_SALT_SYNC)}`,\n    },\n    method: \"POST\",\n    body: JSON.stringify(data),\n  });\n\n/**\n * 下载数据\n * @param {*} url\n * @returns\n */\nexport const apiFetch = (url) => fetchData(url);\n\n/**\n * Microsoft token\n * @returns\n */\nexport const apiMsAuth = async () =>\n  fetchData(\"https://edge.microsoft.com/translate/auth\");\n\n/**\n * Google语言识别\n * @param {*} text\n * @returns\n */\nexport const apiGoogleLangdetect = async (text) => {\n  const params = {\n    client: \"gtx\",\n    dt: \"t\",\n    dj: 1,\n    ie: \"UTF-8\",\n    sl: \"auto\",\n    tl: \"zh-CN\",\n    q: text,\n  };\n  const input = `https://translate.googleapis.com/translate_a/single?${queryString.stringify(params)}`;\n  const init = {\n    headers: {\n      \"Content-type\": \"application/json\",\n    },\n  };\n  const res = await fetchData(input, init, { useCache: true });\n\n  if (res?.src) {\n    await putHttpCachePolyfill(input, init, res);\n    return res.src;\n  }\n\n  return \"\";\n};\n\n/**\n * Microsoft语言识别\n * @param {*} text\n * @returns\n */\nexport const apiMicrosoftLangdetect = async (text) => {\n  const cacheOpts = { text, detector: OPT_TRANS_MICROSOFT };\n  const cacheInput = `${URL_CACHE_DELANG}?${queryString.stringify(cacheOpts)}`;\n  const cache = await getHttpCachePolyfill(cacheInput);\n  if (cache) {\n    return cache;\n  }\n\n  const key = `${URL_CACHE_DELANG}_${OPT_TRANS_MICROSOFT}`;\n  const queue = getBatchQueue(key, handleMicrosoftLangdetect, {\n    batchInterval: 200,\n    batchSize: 20,\n    batchLength: 100000,\n  });\n  const lang = await queue.addTask(text);\n\n  if (lang) {\n    putHttpCachePolyfill(cacheInput, null, lang);\n    return lang;\n  }\n\n  return \"\";\n};\n\n/**\n * Microsoft词典\n * @param {*} text\n * @returns\n */\nexport const apiMicrosoftDict = async (text) => {\n  const cacheOpts = { text };\n  const cacheInput = `${URL_CACHE_BINGDICT}?${queryString.stringify(cacheOpts)}`;\n  const cache = await getHttpCachePolyfill(cacheInput);\n  if (cache) {\n    return cache;\n  }\n\n  const host = \"https://www.bing.com\";\n  const url = `${host}/dict/search?q=${text}&FORM=BDVSP6&cc=cn`;\n  const str = await fetchData(\n    url,\n    { credentials: \"include\" },\n    { useCache: false }\n  );\n  if (!str) {\n    return null;\n  }\n\n  const parser = new DOMParser();\n  const doc = parser.parseFromString(str, \"text/html\");\n\n  const word = doc.querySelector(\"#headword > h1\")?.textContent.trim();\n  if (!word) {\n    return null;\n  }\n\n  const trs = [];\n  doc.querySelectorAll(\"div.qdef > ul > li\").forEach(($li) => {\n    const pos = $li.querySelector(\".pos\")?.textContent?.trim();\n    const def = $li.querySelector(\".def\")?.textContent?.trim();\n    trs.push({ pos, def });\n  });\n\n  // 时态\n  const presents = [];\n  doc.querySelectorAll(\"div.hd_div1>.hd_if>.p1-5\").forEach(($li) => {\n    const present = $li.textContent?.trim();\n    presents.push(present);\n  });\n\n  // 英汉双解\n  const ecs = [];\n  doc.querySelectorAll(\".each_seg>.li_pos\").forEach(($li) => {\n    const pos = $li.querySelector(\".pos_lin>.pos\")?.textContent?.trim();\n    const lis = [];\n    $li.querySelectorAll(\".de_seg>.se_lis\").forEach(($l) => {\n      lis.push($l.querySelector(\".de_co\")?.textContent?.trim());\n    });\n    ecs.push({ pos, lis });\n  });\n\n  // 添加例句信息\n  const sentences = [];\n  doc.querySelectorAll(\"#sentenceSeg .se_li\").forEach(($li) => {\n    const eng = $li.querySelector(\".sen_en\")?.textContent?.trim();\n    const chs = $li.querySelector(\".sen_cn\")?.textContent?.trim();\n    if (eng && chs) {\n      sentences.push({ eng, chs });\n    }\n  });\n\n  const aus = [];\n  const $audioUK = doc.querySelector(\"#bigaud_uk\");\n  const $audioUS = doc.querySelector(\"#bigaud_us\");\n\n  // 检查 UK 音频和音标\n  if ($audioUK) {\n    const audioUK = host + $audioUK?.dataset?.mp3link;\n    const $phoneticUK = $audioUK.parentElement?.previousElementSibling;\n    const phoneticUK = $phoneticUK?.textContent\n      ?.trim()\n      ?.match(/\\[(.*?)\\]/)?.[1];\n    aus.push({ key: \"英\", audio: audioUK, phonetic: phoneticUK });\n  }\n\n  // 检查 US 音频和音标\n  if ($audioUS) {\n    const audioUS = host + $audioUS?.dataset?.mp3link;\n    const $phoneticUS = $audioUS.parentElement?.previousElementSibling;\n    const phoneticUS = $phoneticUS?.textContent\n      ?.trim()\n      ?.match(/\\[(.*?)\\]/)?.[1];\n    aus.push({ key: \"美\", audio: audioUS, phonetic: phoneticUS });\n  }\n\n  // 如果上面的方法没有获取到音标，尝试其他方式\n  if (aus.length === 0) {\n    const $pronInfo = doc.querySelector(\".hd_pr\");\n    const $pronInfoUS = doc.querySelector(\".hd_prUS\");\n\n    if ($pronInfo) {\n      const phoneticText = $pronInfo.textContent?.trim();\n      // 尝试提取音标部分\n      const phoneticMatch = phoneticText?.match(/\\[([^\\]]+)\\]/);\n      if (phoneticMatch) {\n        aus.push({ key: \"英\", phonetic: phoneticMatch[1] });\n      }\n    }\n\n    if ($pronInfoUS) {\n      const phoneticText = $pronInfoUS.textContent?.trim();\n      // 尝试提取音标部分\n      const phoneticMatch = phoneticText?.match(/\\[([^\\]]+)\\]/);\n      if (phoneticMatch) {\n        aus.push({ key: \"美\", phonetic: phoneticMatch[1] });\n      }\n    }\n  }\n\n  const res = { word, trs, aus, ecs, sentences, presents };\n  putHttpCachePolyfill(cacheInput, null, res);\n\n  return res;\n};\n\n/**\n * 百度语言识别\n * @param {*} text\n * @returns\n */\nexport const apiBaiduLangdetect = async (text) => {\n  const input = \"https://fanyi.baidu.com/langdetect\";\n  const init = {\n    headers: {\n      \"Content-type\": \"application/json\",\n    },\n    method: \"POST\",\n    body: JSON.stringify({\n      query: text,\n    }),\n  };\n  const res = await fetchData(input, init, { useCache: true });\n\n  if (res?.error === 0) {\n    await putHttpCachePolyfill(input, init, res);\n    return res.lan;\n  }\n\n  return \"\";\n};\n\n/**\n * 百度翻译建议\n * @param {*} text\n * @returns\n */\nexport const apiBaiduSuggest = async (text) => {\n  const input = \"https://fanyi.baidu.com/sug\";\n  const init = {\n    headers: {\n      \"Content-type\": \"application/json\",\n    },\n    method: \"POST\",\n    body: JSON.stringify({\n      kw: text,\n    }),\n  };\n  const res = await fetchData(input, init, { useCache: true });\n\n  if (res?.errno === 0) {\n    await putHttpCachePolyfill(input, init, res);\n    return res.data;\n  }\n\n  return [];\n};\n\n/**\n * 有道翻译建议\n * @param {*} text\n * @returns\n */\nexport const apiYoudaoSuggest = async (text) => {\n  const params = {\n    num: 5,\n    ver: 3.0,\n    doctype: \"json\",\n    cache: false,\n    le: \"en\",\n    q: text,\n  };\n  const input = `https://dict.youdao.com/suggest?${queryString.stringify(params)}`;\n  const init = {\n    headers: {\n      accept: \"application/json, text/plain, */*\",\n      \"accept-language\": \"en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,ja;q=0.6\",\n      \"content-type\": \"application/x-www-form-urlencoded\",\n    },\n    method: \"GET\",\n  };\n  const res = await fetchData(input, init, { useCache: true });\n\n  if (res?.result?.code === 200) {\n    await putHttpCachePolyfill(input, init, res);\n    return res.data.entries;\n  }\n\n  return [];\n};\n\n/**\n * 有道词典\n * @param {*} text\n * @returns\n */\nexport const apiYoudaoDict = async (text) => {\n  const params = {\n    doctype: \"json\",\n    jsonversion: 4,\n  };\n  const input = `https://dict.youdao.com/jsonapi_s?${queryString.stringify(params)}`;\n  const body = queryString.stringify({\n    q: text,\n    le: \"en\",\n    t: 3,\n    client: \"web\",\n    // sign: \"\",\n    keyfrom: \"webdict\",\n  });\n  const init = {\n    headers: {\n      accept: \"application/json, text/plain, */*\",\n      \"accept-language\": \"en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,ja;q=0.6\",\n      \"content-type\": \"application/x-www-form-urlencoded\",\n    },\n    method: \"POST\",\n    body,\n  };\n  const res = await fetchData(input, init, { useCache: true });\n\n  if (res) {\n    await putHttpCachePolyfill(input, init, res);\n    return res;\n  }\n\n  return null;\n};\n\n/**\n * 百度语音\n * @param {*} text\n * @param {*} lan\n * @param {*} spd\n * @returns\n */\nexport const apiBaiduTTS = (text, lan = \"uk\", spd = 3) => {\n  const input = `https://fanyi.baidu.com/gettts?${queryString.stringify({ lan, text, spd })}`;\n  return fetchData(input);\n};\n\n/**\n * 腾讯语言识别\n * @param {*} text\n * @returns\n */\nexport const apiTencentLangdetect = async (text) => {\n  const input = \"https://transmart.qq.com/api/imt\";\n  const body = JSON.stringify({\n    header: {\n      fn: \"text_analysis\",\n      client_key:\n        \"browser-chrome-110.0.0-Mac OS-df4bd4c5-a65d-44b2-a40f-42f34f3535f2-1677486696487\",\n    },\n    text,\n  });\n  const init = {\n    headers: {\n      \"Content-type\": \"application/json\",\n      \"user-agent\":\n        \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36\",\n      referer: \"https://transmart.qq.com/zh-CN/index\",\n    },\n    method: \"POST\",\n    body,\n  };\n  const res = await fetchData(input, init, { useCache: true });\n\n  if (res?.language) {\n    await putHttpCachePolyfill(input, init, res);\n    return res.language;\n  }\n\n  return \"\";\n};\n\n/**\n * 浏览器内置AI语言识别\n * @param {*} text\n * @returns\n */\nexport const apiBuiltinAIDetect = async (text) => {\n  if (!isBuiltinAIAvailable) {\n    return \"\";\n  }\n\n  const [lang, error] = await fnPolyfill({\n    fn: chromeDetect,\n    msg: MSG_BUILTINAI_DETECT,\n    text,\n  });\n  if (!error) {\n    return lang;\n  }\n\n  return \"\";\n};\n\n/**\n * 浏览器内置AI翻译\n * @param {*} param0\n * @returns\n */\nconst apiBuiltinAITranslate = async ({ text, from, to, apiSetting }) => {\n  if (!isBuiltinAIAvailable) {\n    return [\"\", true];\n  }\n\n  const { fetchInterval, fetchLimit, httpTimeout } = apiSetting;\n  const fetchPool = getFetchPool(fetchInterval, fetchLimit);\n  const result = await withTimeout(\n    fetchPool.push(fnPolyfill, {\n      fn: chromeTranslate,\n      msg: MSG_BUILTINAI_TRANSLATE,\n      text,\n      from,\n      to,\n    }),\n    httpTimeout\n  );\n\n  if (!result) {\n    throw new Error(\"apiBuiltinAITranslate got null reault\");\n  }\n\n  const [trText, srLang, error] = result;\n  if (error) {\n    throw new Error(\"apiBuiltinAITranslate got error\", error);\n  }\n\n  return [trText, srLang];\n};\n\n/**\n * 统一翻译接口\n * @param {*} param0\n * @returns\n */\nexport const apiTranslate = async ({\n  text,\n  fromLang = \"auto\",\n  toLang,\n  apiSetting = DEFAULT_API_SETTING,\n  glossary,\n  useCache = true,\n  usePool = true,\n}) => {\n  if (!text) {\n    throw new Error(\"The text cannot be empty.\");\n  }\n\n  const { apiType, apiSlug, useBatchFetch } = apiSetting;\n  const langMap = OPT_LANGS_TO_SPEC[apiType] || OPT_LANGS_SPEC_DEFAULT;\n  const from = langMap.get(fromLang);\n  const to = langMap.get(toLang);\n  if (!to) {\n    throw new Error(`The target lang: ${toLang} not support`);\n  }\n\n  // todo: 优化缓存失效因素\n  const [v1, v2] = process.env.REACT_APP_VERSION.split(\".\");\n  const cacheOpts = {\n    apiSlug,\n    text,\n    fromLang,\n    toLang,\n    version: [v1, v2].join(\".\"),\n  };\n  const cacheInput = `${URL_CACHE_TRAN}?${queryString.stringify(cacheOpts)}`;\n\n  // 查询缓存数据\n  if (useCache) {\n    const cache = await getHttpCachePolyfill(cacheInput);\n    if (cache?.trText) {\n      return cache;\n    }\n  }\n\n  // 请求接口数据\n  let translation = [];\n  if (apiType === OPT_TRANS_BUILTINAI) {\n    translation = await apiBuiltinAITranslate({\n      text,\n      from,\n      to,\n      apiSetting,\n    });\n  } else if (useBatchFetch && API_SPE_TYPES.batch.has(apiType)) {\n    const { apiSlug, batchInterval, batchSize, batchLength, useStream } =\n      apiSetting;\n    const enableStream = useStream && API_SPE_TYPES.stream.has(apiType);\n    const key = `${apiSlug}_${fromLang}_${toLang}_${enableStream ? \"stream\" : \"batch\"}`;\n    const queue = getBatchQueue(key, handleTranslate, {\n      batchInterval,\n      batchSize,\n      batchLength,\n    });\n\n    translation = await queue.addTask(text, {\n      from,\n      to,\n      fromLang,\n      toLang,\n      langMap,\n      glossary,\n      apiSetting,\n      usePool,\n    });\n  } else {\n    const { value } = await handleTranslate([text], {\n      from,\n      to,\n      fromLang,\n      toLang,\n      langMap,\n      glossary,\n      apiSetting,\n      usePool,\n    }).next();\n    translation = value?.result;\n  }\n\n  let trText = \"\";\n  let srLang = \"\";\n  let srCode = \"\";\n  if (Array.isArray(translation)) {\n    [trText, srLang = \"\"] = translation;\n    if (srLang) {\n      srCode = OPT_LANGS_TO_CODE[apiType].get(srLang) || \"\";\n    }\n  } else if (typeof translation === \"string\") {\n    trText = translation;\n  }\n\n  if (!trText) {\n    throw new Error(\"tanslate api got empty trtext\");\n  }\n\n  const isSame = fromLang === \"auto\" && srLang === to;\n\n  // 插入缓存\n  if (useCache) {\n    putHttpCachePolyfill(cacheInput, null, { trText, isSame, srLang, srCode });\n  }\n\n  return { trText, srLang, srCode, isSame };\n};\n\n// 字幕处理/翻译\nexport const apiSubtitle = async ({\n  videoId,\n  chunkSign,\n  fromLang = \"auto\",\n  toLang,\n  events = [],\n  apiSetting,\n}) => {\n  const cacheOpts = {\n    apiSlug: apiSetting.apiSlug,\n    videoId,\n    chunkSign,\n    fromLang,\n    toLang,\n  };\n  const cacheInput = `${URL_CACHE_SUBTITLE}?${queryString.stringify(cacheOpts)}`;\n  const cache = await getHttpCachePolyfill(cacheInput);\n  if (cache) {\n    return cache;\n  }\n\n  const subtitles = await handleSubtitle({\n    events,\n    from: fromLang,\n    to: toLang,\n    apiSetting,\n  });\n  if (subtitles?.length) {\n    putHttpCachePolyfill(cacheInput, null, subtitles);\n    return subtitles;\n  }\n\n  return [];\n};\n"
  },
  {
    "path": "src/apis/trans.js",
    "content": "import queryString from \"query-string\";\nimport {\n  OPT_TRANS_GOOGLE,\n  OPT_TRANS_GOOGLE_2,\n  OPT_TRANS_MICROSOFT,\n  OPT_TRANS_AZUREAI,\n  OPT_TRANS_DEEPL,\n  OPT_TRANS_DEEPLFREE,\n  OPT_TRANS_DEEPLX,\n  OPT_TRANS_NIUTRANS,\n  OPT_TRANS_BAIDU,\n  OPT_TRANS_TENCENT,\n  OPT_TRANS_VOLCENGINE,\n  OPT_TRANS_OPENAI,\n  OPT_TRANS_GEMINI,\n  OPT_TRANS_GEMINI_2,\n  OPT_TRANS_CLAUDE,\n  OPT_TRANS_CLOUDFLAREAI,\n  OPT_TRANS_OLLAMA,\n  OPT_TRANS_OPENROUTER,\n  OPT_TRANS_CUSTOMIZE,\n  API_SPE_TYPES,\n  INPUT_PLACE_FROM,\n  INPUT_PLACE_TO,\n  INPUT_PLACE_TEXT,\n  INPUT_PLACE_KEY,\n  INPUT_PLACE_MODEL,\n  DEFAULT_USER_AGENT,\n  defaultSystemPrompt,\n  defaultSubtitlePrompt,\n  defaultNobatchPrompt,\n  defaultNobatchUserPrompt,\n  INPUT_PLACE_TONE,\n  INPUT_PLACE_TITLE,\n  INPUT_PLACE_DESCRIPTION,\n  INPUT_PLACE_TO_LANG,\n  INPUT_PLACE_FROM_LANG,\n  defaultSystemPromptXml,\n  defaultSystemPromptLines,\n  INPUT_PLACE_SUMMARY,\n} from \"../config\";\nimport { msAuth } from \"../libs/auth\";\nimport { genDeeplFree } from \"./deepl\";\nimport { genBaidu } from \"./baidu\";\nimport { interpreter } from \"../libs/interpreter\";\nimport {\n  parseJsonObj,\n  extractJson,\n  stripMarkdownCodeBlock,\n} from \"../libs/utils\";\nimport {\n  parseStreamingSegments,\n  createStreamingJsonParser,\n  detectStreamFormat,\n  getStreamDelta,\n} from \"../libs/stream\";\nimport { kissLog } from \"../libs/log\";\nimport { fetchData, fetchStream } from \"../libs/fetch\";\nimport { getMsgHistory } from \"./history\";\nimport { parseBilingualVtt } from \"../subtitle/vtt\";\nimport { getDocInfo } from \"../libs/docInfo\";\n\nconst keyMap = new Map();\nconst urlMap = new Map();\n\n// 轮询key/url\nconst keyPick = (apiSlug, key = \"\", cacheMap) => {\n  const keys = key\n    .split(/\\n|,/)\n    .map((item) => item.trim())\n    .filter(Boolean);\n\n  if (keys.length === 0) {\n    return \"\";\n  }\n\n  const preIndex = cacheMap.get(apiSlug) ?? -1;\n  const curIndex = (preIndex + 1) % keys.length;\n  cacheMap.set(apiSlug, curIndex);\n\n  return keys[curIndex];\n};\n\nconst genSystemPrompt = ({\n  systemPrompt,\n  tone,\n  from,\n  to,\n  fromLang,\n  toLang,\n  texts,\n  docInfo: { title = \"\", description = \"\", summary = \"\" } = {},\n}) =>\n  systemPrompt\n    .replaceAll(INPUT_PLACE_TITLE, title)\n    .replaceAll(INPUT_PLACE_DESCRIPTION, description)\n    .replaceAll(INPUT_PLACE_SUMMARY, summary)\n    .replaceAll(INPUT_PLACE_TONE, tone)\n    .replaceAll(INPUT_PLACE_FROM, from)\n    .replaceAll(INPUT_PLACE_TO, to)\n    .replaceAll(INPUT_PLACE_FROM_LANG, fromLang)\n    .replaceAll(INPUT_PLACE_TO_LANG, toLang)\n    .replaceAll(INPUT_PLACE_TEXT, texts[0]);\n\nconst genUserPrompt = ({\n  nobatchUserPrompt,\n  useBatchFetch,\n  tone,\n  glossary,\n  from,\n  to,\n  fromLang,\n  toLang,\n  texts,\n  docInfo: { title = \"\", description = \"\", summary = \"\" } = {},\n}) => {\n  if (useBatchFetch) {\n    const promptObj = {\n      targetLanguage: toLang,\n      segments: texts.map((text, i) => ({ id: i, text })),\n    };\n\n    title && (promptObj.title = title);\n    description && (promptObj.description = description);\n    glossary &&\n      Object.keys(glossary).length !== 0 &&\n      (promptObj.glossary = glossary);\n    tone && (promptObj.tone = tone);\n\n    return JSON.stringify(promptObj);\n  }\n\n  return nobatchUserPrompt\n    .replaceAll(INPUT_PLACE_TITLE, title)\n    .replaceAll(INPUT_PLACE_DESCRIPTION, description)\n    .replaceAll(INPUT_PLACE_SUMMARY, summary)\n    .replaceAll(INPUT_PLACE_TONE, tone)\n    .replaceAll(INPUT_PLACE_FROM, from)\n    .replaceAll(INPUT_PLACE_TO, to)\n    .replaceAll(INPUT_PLACE_FROM_LANG, fromLang)\n    .replaceAll(INPUT_PLACE_TO_LANG, toLang)\n    .replaceAll(INPUT_PLACE_TEXT, texts[0]);\n};\n\nconst genSubtitlePrompt = ({\n  subtitlePrompt,\n  tone,\n  from,\n  to,\n  fromLang,\n  toLang,\n  docInfo: { title = \"\", description = \"\", summary = \"\" } = {},\n}) =>\n  subtitlePrompt\n    .replaceAll(INPUT_PLACE_TITLE, title)\n    .replaceAll(INPUT_PLACE_DESCRIPTION, description)\n    .replaceAll(INPUT_PLACE_SUMMARY, summary)\n    .replaceAll(INPUT_PLACE_TONE, tone)\n    .replaceAll(INPUT_PLACE_FROM, from)\n    .replaceAll(INPUT_PLACE_TO, to)\n    .replaceAll(INPUT_PLACE_FROM_LANG, fromLang)\n    .replaceAll(INPUT_PLACE_TO_LANG, toLang);\n\nconst parseAIRes = (raw, useBatchFetch = true) => {\n  if (!raw) {\n    return [];\n  }\n\n  if (!useBatchFetch) {\n    return [[raw]];\n  }\n\n  // try {\n  //   const jsonString = extractJson(raw);\n  //   if (!jsonString) return [];\n\n  //   const data = JSON.parse(jsonString);\n  //   if (Array.isArray(data.translations)) {\n  //     // todo: 考虑序号id可能会打乱\n  //     return data.translations.map((item) => [\n  //       item?.text ?? \"\",\n  //       item?.sourceLanguage ?? \"\",\n  //     ]);\n  //   }\n  // } catch (err) {\n  //   kissLog(\"parse AI Res\", err);\n  // }\n  // return [];\n\n  let content = stripMarkdownCodeBlock(raw).trim();\n\n  // JSON\n  try {\n    const start = content.search(/(\\{|\\[)/);\n    const end = content.lastIndexOf(content.includes(\"}\") ? \"}\" : \"]\");\n\n    if (start > -1 && end > -1) {\n      const jsonStr = content.substring(start, end + 1);\n      const parsed = JSON.parse(jsonStr);\n\n      const list = Array.isArray(parsed)\n        ? parsed\n        : parsed.translations || (parsed.result ? [parsed.result] : [parsed]);\n\n      if (\n        list.length > 0 &&\n        (list[0].text !== undefined || list[0].translations)\n      ) {\n        return list.map((item) => [\n          String(item.text || \"\"),\n          String(item.sourceLanguage || \"\"),\n        ]);\n      }\n    }\n  } catch (e) {\n    //\n  }\n\n  // XML\n  const xmlTagPattern = /<(t|item|seg)\\b/i;\n  if (xmlTagPattern.test(content)) {\n    try {\n      const parser = new DOMParser();\n      const doc = parser.parseFromString(content, \"text/html\");\n      const elements = doc.querySelectorAll(\"t, item, seg\");\n\n      if (elements.length > 0) {\n        return Array.from(elements).map((el) => [\n          el.innerHTML.trim(),\n          el.getAttribute(\"sourceLanguage\") || \"\",\n        ]);\n      }\n    } catch (e) {\n      //\n    }\n  }\n\n  // 纯文本换行\n  return content.split(\"\\n\").map((line) => {\n    const pipeMatch = line.match(/^\\d+\\s*\\|\\s*(.*)/);\n    if (pipeMatch) {\n      return [pipeMatch[1].trim(), \"\"];\n    }\n\n    const text = line.replace(/<br\\s*\\/?>/gi, \"\\n\").trim();\n    return [text, \"\"];\n  });\n};\n\nconst parseSTRes = (raw) => {\n  if (!raw) {\n    return [];\n  }\n\n  try {\n    // const jsonString = extractJson(raw);\n    // const data = JSON.parse(jsonString);\n    const data = parseBilingualVtt(raw);\n    if (Array.isArray(data)) {\n      return data;\n    }\n  } catch (err) {\n    kissLog(\"parse AI Res: subtitle\", err);\n  }\n\n  return [];\n};\n\nconst genGoogle = ({ texts, from, to, url, key }) => {\n  const params = queryString.stringify({\n    client: \"gtx\",\n    dt: \"t\",\n    dj: 1,\n    ie: \"UTF-8\",\n    sl: from,\n    tl: to,\n    q: texts.join(\" \"),\n  });\n  url = `${url}?${params}`;\n  const headers = {\n    \"Content-type\": \"application/json\",\n  };\n  if (key) {\n    headers.Authorization = `Bearer ${key}`;\n  }\n\n  return { url, headers, method: \"GET\" };\n};\n\nconst genGoogle2 = ({ texts, from, to, url, key }) => {\n  const body = [[texts, from, to], \"wt_lib\"];\n  const headers = {\n    \"Content-Type\": \"application/json+protobuf\",\n    \"X-Goog-API-Key\": key,\n  };\n\n  return { url, body, headers };\n};\n\nconst genMicrosoft = ({ texts, from, to, token }) => {\n  const params = queryString.stringify({\n    from,\n    to,\n    \"api-version\": \"3.0\",\n  });\n  const url = `https://api-edge.cognitive.microsofttranslator.com/translate?${params}`;\n  const headers = {\n    \"Content-type\": \"application/json\",\n    Authorization: `Bearer ${token}`,\n  };\n  const body = texts.map((text) => ({ Text: text }));\n\n  return { url, body, headers };\n};\n\nconst genAzureAI = ({ texts, from, to, url, key, region }) => {\n  const params = queryString.stringify({\n    from,\n    to,\n  });\n  url = url.endsWith(\"&\") ? `${url}${params}` : `${url}&${params}`;\n  const headers = {\n    \"Content-type\": \"application/json\",\n    \"Ocp-Apim-Subscription-Key\": key,\n    \"Ocp-Apim-Subscription-Region\": region,\n  };\n  const body = texts.map((text) => ({ Text: text }));\n\n  return { url, body, headers };\n};\n\nconst genDeepl = ({ texts, from, to, url, key }) => {\n  const body = {\n    text: texts,\n    target_lang: to,\n    source_lang: from,\n    // split_sentences: \"0\",\n  };\n  const headers = {\n    \"Content-type\": \"application/json\",\n    Authorization: `DeepL-Auth-Key ${key}`,\n  };\n\n  return { url, body, headers };\n};\n\nconst genDeeplX = ({ texts, from, to, url, key }) => {\n  const body = {\n    text: texts.join(\" \"),\n    target_lang: to,\n    source_lang: from,\n  };\n\n  const headers = {\n    \"Content-type\": \"application/json\",\n  };\n  if (key) {\n    headers.Authorization = `Bearer ${key}`;\n  }\n\n  return { url, body, headers };\n};\n\nconst genNiuTrans = ({ texts, from, to, url, key, dictNo, memoryNo }) => {\n  const body = {\n    from,\n    to,\n    apikey: key,\n    src_text: texts.join(\" \"),\n    dictNo,\n    memoryNo,\n  };\n\n  const headers = {\n    \"Content-type\": \"application/json\",\n  };\n\n  return { url, body, headers };\n};\n\nconst genTencent = ({ texts, from, to }) => {\n  const body = {\n    header: {\n      fn: \"auto_translation\",\n      client_key:\n        \"browser-chrome-110.0.0-Mac OS-df4bd4c5-a65d-44b2-a40f-42f34f3535f2-1677486696487\",\n    },\n    type: \"plain\",\n    model_category: \"normal\",\n    source: {\n      text_list: texts,\n      lang: from,\n    },\n    target: {\n      lang: to,\n    },\n  };\n\n  const url = \"https://transmart.qq.com/api/imt\";\n  const headers = {\n    \"Content-Type\": \"application/json\",\n    \"user-agent\": DEFAULT_USER_AGENT,\n    referer: \"https://transmart.qq.com/zh-CN/index\",\n  };\n\n  return { url, body, headers };\n};\n\nconst genVolcengine = ({ texts, from, to }) => {\n  const body = {\n    source_language: from,\n    target_language: to,\n    text: texts.join(\" \"),\n  };\n\n  const url = \"https://translate.volcengine.com/crx/translate/v1\";\n  const headers = {\n    \"Content-type\": \"application/json\",\n  };\n\n  return { url, body, headers };\n};\n\nconst genOpenAI = ({\n  url,\n  key,\n  systemPrompt,\n  userPrompt,\n  model,\n  temperature,\n  maxTokens,\n  hisMsgs = [],\n  useStream = false,\n}) => {\n  const userMsg = {\n    role: \"user\",\n    content: userPrompt,\n  };\n  const body = {\n    model,\n    messages: [\n      {\n        role: \"system\",\n        content: systemPrompt,\n      },\n      ...hisMsgs,\n      userMsg,\n    ],\n    temperature,\n    max_completion_tokens: maxTokens,\n    stream: useStream,\n  };\n\n  const headers = {\n    \"Content-type\": \"application/json\",\n    Authorization: `Bearer ${key}`, // OpenAI\n    // \"api-key\": key, // Azure OpenAI\n  };\n\n  return { url, body, headers, userMsg };\n};\n\nconst genGemini = ({\n  url,\n  key,\n  systemPrompt,\n  userPrompt,\n  model,\n  temperature,\n  maxTokens,\n  hisMsgs = [],\n  useStream = false,\n}) => {\n  url = url\n    .replaceAll(INPUT_PLACE_MODEL, model)\n    .replaceAll(INPUT_PLACE_KEY, key);\n\n  // 流式传输使用 streamGenerateContent 端点\n  if (useStream) {\n    url = url.replace(\":generateContent\", \":streamGenerateContent\");\n    url += (url.includes(\"?\") ? \"&\" : \"?\") + \"alt=sse\";\n  }\n\n  const userMsg = { role: \"user\", parts: [{ text: userPrompt }] };\n  const body = {\n    // system_instruction: {\n    //   parts: {\n    //     text: systemPrompt,\n    //   },\n    // },\n    contents: [\n      {\n        role: \"model\",\n        parts: [{ text: systemPrompt }],\n      },\n      ...hisMsgs,\n      userMsg,\n    ],\n    generationConfig: {\n      maxOutputTokens: maxTokens,\n      temperature,\n      // topP: 0.8,\n      // topK: 10,\n    },\n    // thinkingConfig: {\n    //   thinkingBudget: 0,\n    // },\n    safetySettings: [\n      {\n        category: \"HARM_CATEGORY_HARASSMENT\",\n        threshold: \"BLOCK_NONE\",\n      },\n      {\n        category: \"HARM_CATEGORY_HATE_SPEECH\",\n        threshold: \"BLOCK_NONE\",\n      },\n      {\n        category: \"HARM_CATEGORY_SEXUALLY_EXPLICIT\",\n        threshold: \"BLOCK_NONE\",\n      },\n      {\n        category: \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n        threshold: \"BLOCK_NONE\",\n      },\n    ],\n  };\n  const headers = {\n    \"Content-type\": \"application/json\",\n    \"x-goog-api-key\": key,\n  };\n\n  return { url, body, headers, userMsg };\n};\n\nconst genGemini2 = ({\n  url,\n  key,\n  systemPrompt,\n  userPrompt,\n  model,\n  temperature,\n  maxTokens,\n  hisMsgs = [],\n  useStream = false,\n}) => {\n  const userMsg = {\n    role: \"user\",\n    content: userPrompt,\n  };\n  const body = {\n    model,\n    messages: [\n      {\n        role: \"system\",\n        content: systemPrompt,\n      },\n      ...hisMsgs,\n      userMsg,\n    ],\n    temperature,\n    max_tokens: maxTokens,\n    stream: useStream,\n  };\n\n  const headers = {\n    \"Content-type\": \"application/json\",\n    Authorization: `Bearer ${key}`,\n  };\n\n  return { url, body, headers, userMsg };\n};\n\nconst genClaude = ({\n  url,\n  key,\n  systemPrompt,\n  userPrompt,\n  model,\n  temperature,\n  maxTokens,\n  hisMsgs = [],\n  useStream = false,\n}) => {\n  const userMsg = {\n    role: \"user\",\n    content: userPrompt,\n  };\n  const body = {\n    model,\n    system: systemPrompt,\n    messages: [...hisMsgs, userMsg],\n    temperature,\n    max_tokens: maxTokens,\n    stream: useStream,\n  };\n\n  const headers = {\n    \"Content-type\": \"application/json\",\n    \"anthropic-version\": \"2023-06-01\",\n    \"anthropic-dangerous-direct-browser-access\": \"true\",\n    \"x-api-key\": key,\n  };\n\n  return { url, body, headers, userMsg };\n};\n\nconst genOpenRouter = ({\n  url,\n  key,\n  systemPrompt,\n  userPrompt,\n  model,\n  temperature,\n  maxTokens,\n  hisMsgs = [],\n  useStream = false,\n}) => {\n  const userMsg = {\n    role: \"user\",\n    content: userPrompt,\n  };\n  const body = {\n    model,\n    messages: [\n      {\n        role: \"system\",\n        content: systemPrompt,\n      },\n      ...hisMsgs,\n      userMsg,\n    ],\n    temperature,\n    max_tokens: maxTokens,\n    stream: useStream,\n  };\n\n  const headers = {\n    \"Content-type\": \"application/json\",\n    Authorization: `Bearer ${key}`,\n  };\n\n  return { url, body, headers, userMsg };\n};\n\nconst genOllama = ({\n  // think,\n  url,\n  key,\n  systemPrompt,\n  userPrompt,\n  model,\n  temperature,\n  maxTokens,\n  hisMsgs = [],\n  useStream = false,\n}) => {\n  const userMsg = {\n    role: \"user\",\n    content: userPrompt,\n  };\n  const body = {\n    model,\n    messages: [\n      {\n        role: \"system\",\n        content: systemPrompt,\n      },\n      ...hisMsgs,\n      userMsg,\n    ],\n    temperature,\n    max_tokens: maxTokens,\n    // think,\n    stream: useStream,\n  };\n\n  const headers = {\n    \"Content-type\": \"application/json\",\n  };\n  if (key) {\n    headers.Authorization = `Bearer ${key}`;\n  }\n\n  return { url, body, headers, userMsg };\n};\n\nconst genCloudflareAI = ({ texts, from, to, url, key }) => {\n  const body = {\n    text: texts.join(\" \"),\n    source_lang: from,\n    target_lang: to,\n  };\n\n  const headers = {\n    \"Content-type\": \"application/json\",\n    Authorization: `Bearer ${key}`,\n  };\n\n  return { url, body, headers };\n};\n\nconst genCustom = ({ texts, fromLang, toLang, url, key, useBatchFetch }) => {\n  const body = useBatchFetch\n    ? { texts, from: fromLang, to: toLang }\n    : { text: texts[0], from: fromLang, to: toLang };\n  const headers = {\n    \"Content-type\": \"application/json\",\n    Authorization: `Bearer ${key}`,\n  };\n\n  return { url, body, headers };\n};\n\nconst genReqFuncs = {\n  [OPT_TRANS_GOOGLE]: genGoogle,\n  [OPT_TRANS_GOOGLE_2]: genGoogle2,\n  [OPT_TRANS_MICROSOFT]: genMicrosoft,\n  [OPT_TRANS_AZUREAI]: genAzureAI,\n  [OPT_TRANS_DEEPL]: genDeepl,\n  [OPT_TRANS_DEEPLFREE]: genDeeplFree,\n  [OPT_TRANS_DEEPLX]: genDeeplX,\n  [OPT_TRANS_NIUTRANS]: genNiuTrans,\n  [OPT_TRANS_BAIDU]: genBaidu,\n  [OPT_TRANS_TENCENT]: genTencent,\n  [OPT_TRANS_VOLCENGINE]: genVolcengine,\n  [OPT_TRANS_OPENAI]: genOpenAI,\n  [OPT_TRANS_GEMINI]: genGemini,\n  [OPT_TRANS_GEMINI_2]: genGemini2,\n  [OPT_TRANS_CLAUDE]: genClaude,\n  [OPT_TRANS_CLOUDFLAREAI]: genCloudflareAI,\n  [OPT_TRANS_OLLAMA]: genOllama,\n  [OPT_TRANS_OPENROUTER]: genOpenRouter,\n  [OPT_TRANS_CUSTOMIZE]: genCustom,\n};\n\nconst genInit = ({\n  url = \"\",\n  body = null,\n  headers = {},\n  userMsg = null,\n  method = \"POST\",\n}) => {\n  if (!url) {\n    throw new Error(\"genInit: url is empty\");\n  }\n\n  const init = {\n    method,\n    headers,\n  };\n  if (method !== \"GET\" && method !== \"HEAD\" && body) {\n    let payload = JSON.stringify(body);\n    const id = body?.params?.id;\n    if (id) {\n      payload = payload.replace(\n        'method\":\"',\n        (id + 3) % 13 === 0 || (id + 5) % 29 === 0\n          ? 'method\" : \"'\n          : 'method\": \"'\n      );\n    }\n    Object.assign(init, { body: payload });\n  }\n\n  return [url, init, userMsg];\n};\n\n/**\n * 构造翻译接口请求参数\n * @param {*}\n * @returns\n */\nexport const genTransReq = async ({ reqHook, ...args }) => {\n  const {\n    apiType,\n    apiSlug,\n    key,\n    systemPrompt,\n    subtitlePrompt,\n    // userPrompt,\n    nobatchPrompt = defaultNobatchPrompt,\n    nobatchUserPrompt = defaultNobatchUserPrompt,\n    useBatchFetch,\n    from,\n    to,\n    fromLang,\n    toLang,\n    texts,\n    glossary,\n    customHeader,\n    customBody,\n    events,\n    tone,\n  } = args;\n\n  if (API_SPE_TYPES.mulkeys.has(apiType)) {\n    args.key = keyPick(apiSlug, key, keyMap);\n  }\n\n  if (apiType === OPT_TRANS_DEEPLX) {\n    args.url = keyPick(apiSlug, args.url, urlMap);\n  }\n\n  if (API_SPE_TYPES.ai.has(apiType)) {\n    const docInfo = getDocInfo();\n\n    args.systemPrompt = events\n      ? genSubtitlePrompt({\n          subtitlePrompt,\n          from,\n          to,\n          fromLang,\n          toLang,\n          texts,\n          docInfo,\n          tone,\n        })\n      : genSystemPrompt({\n          systemPrompt: useBatchFetch ? systemPrompt : nobatchPrompt,\n          from,\n          to,\n          fromLang,\n          toLang,\n          texts,\n          docInfo,\n          tone,\n        });\n    args.userPrompt = events\n      ? JSON.stringify(events)\n      : genUserPrompt({\n          nobatchUserPrompt,\n          useBatchFetch,\n          from,\n          to,\n          fromLang,\n          toLang,\n          texts,\n          docInfo,\n          tone,\n          glossary,\n        });\n  }\n\n  const {\n    url = \"\",\n    body = null,\n    headers = {},\n    userMsg = null,\n    method = \"POST\",\n  } = genReqFuncs[apiType](args);\n\n  // 合并用户自定义headers和body\n  if (customHeader?.trim()) {\n    Object.assign(headers, parseJsonObj(customHeader));\n  }\n  if (customBody?.trim()) {\n    Object.assign(body, parseJsonObj(customBody));\n  }\n\n  // 执行 request hook\n  if (reqHook?.trim() && !events) {\n    try {\n      const req = {\n        url,\n        body,\n        headers,\n        userMsg,\n        method,\n      };\n      interpreter.run(`exports.reqHook = ${reqHook}`);\n      const hookResult = await interpreter.exports.reqHook(\n        {\n          ...args,\n          defaultSystemPrompt,\n          defaultSystemPromptXml,\n          defaultSystemPromptLines,\n          defaultSubtitlePrompt,\n          defaultNobatchPrompt,\n          defaultNobatchUserPrompt,\n          req,\n        },\n        req\n      );\n      if (hookResult && hookResult.url) {\n        return genInit(hookResult);\n      }\n    } catch (err) {\n      kissLog(\"run req hook\", err);\n      throw new Error(`Request hook error: ${err.message}`);\n    }\n  }\n\n  return genInit({ url, body, headers, userMsg, method });\n};\n\n/**\n * 解析翻译接口返回数据\n * @param {*} res\n * @param {*} param3\n * @returns\n */\nexport const parseTransRes = async (\n  res,\n  {\n    texts,\n    from,\n    to,\n    fromLang,\n    toLang,\n    langMap,\n    resHook,\n    // thinkIgnore,\n    history,\n    userMsg,\n    apiType,\n    useBatchFetch,\n  }\n) => {\n  // 执行 response hook\n  if (resHook?.trim()) {\n    try {\n      interpreter.run(`exports.resHook = ${resHook}`);\n      const hookResult = await interpreter.exports.resHook({\n        apiType,\n        userMsg,\n        res,\n        texts,\n        from,\n        to,\n        fromLang,\n        toLang,\n        langMap,\n        extractJson,\n        parseAIRes,\n      });\n      if (hookResult && Array.isArray(hookResult.translations)) {\n        if (history && userMsg && hookResult.modelMsg) {\n          history.add(userMsg, hookResult.modelMsg);\n        }\n        return hookResult.translations;\n      } else if (Array.isArray(hookResult)) {\n        return hookResult;\n      }\n    } catch (err) {\n      kissLog(\"run res hook\", err);\n      throw new Error(`Response hook error: ${err.message}`);\n    }\n  }\n\n  let modelMsg = \"\";\n\n  // todo: 根据结果抛出实际异常信息\n  switch (apiType) {\n    case OPT_TRANS_GOOGLE:\n      return [[res?.sentences?.map((item) => item.trans).join(\" \"), res?.src]];\n    case OPT_TRANS_GOOGLE_2:\n      return res?.[0]?.map((_, i) => [res?.[0]?.[i], res?.[1]?.[i]]);\n    case OPT_TRANS_MICROSOFT:\n    case OPT_TRANS_AZUREAI:\n      return res?.map((item) => [\n        item.translations.map((item) => item.text).join(\" \"),\n        item.detectedLanguage?.language,\n      ]);\n    case OPT_TRANS_DEEPL:\n      return res?.translations?.map((item) => [\n        item.text,\n        item.detected_source_language,\n      ]);\n    case OPT_TRANS_DEEPLFREE:\n      return [\n        [\n          res?.result?.texts?.map((item) => item.text).join(\" \"),\n          res?.result?.lang,\n        ],\n      ];\n    case OPT_TRANS_DEEPLX:\n      return [[res?.data, res?.source_lang]];\n    case OPT_TRANS_NIUTRANS:\n      const json = JSON.parse(res);\n      if (json.error_msg) {\n        throw new Error(json.error_msg);\n      }\n      return [[json.tgt_text, json.from]];\n    case OPT_TRANS_BAIDU:\n      if (res.type === 1) {\n        return [\n          [\n            Object.keys(JSON.parse(res.result).content[0].mean[0].cont)[0],\n            res.from,\n          ],\n        ];\n      } else if (res.type === 2) {\n        return [[res.data.map((item) => item.dst).join(\" \"), res.from]];\n      }\n      break;\n    case OPT_TRANS_TENCENT:\n      return res?.auto_translation?.map((text) => [text, res?.src_lang]);\n    case OPT_TRANS_VOLCENGINE:\n      return [[res?.translation, res?.detected_language]];\n    case OPT_TRANS_OPENAI:\n    case OPT_TRANS_GEMINI_2:\n    case OPT_TRANS_OPENROUTER:\n      modelMsg = res?.choices?.[0]?.message;\n      if (history && userMsg && modelMsg) {\n        history.add(userMsg, {\n          role: modelMsg.role,\n          content: modelMsg.content,\n        });\n      }\n      return parseAIRes(modelMsg?.content, useBatchFetch);\n    case OPT_TRANS_GEMINI:\n      modelMsg = res?.candidates?.[0]?.content;\n      if (history && userMsg && modelMsg) {\n        history.add(userMsg, modelMsg);\n      }\n      return parseAIRes(modelMsg?.parts?.[0]?.text ?? \"\", useBatchFetch);\n    case OPT_TRANS_CLAUDE:\n      modelMsg = { role: res?.role, content: res?.content?.text };\n      if (history && userMsg && modelMsg) {\n        history.add(userMsg, {\n          role: modelMsg.role,\n          content: modelMsg.content,\n        });\n      }\n      return parseAIRes(res?.content?.[0]?.text ?? \"\", useBatchFetch);\n    case OPT_TRANS_CLOUDFLAREAI:\n      return [[res?.result?.translated_text]];\n    case OPT_TRANS_OLLAMA:\n      modelMsg = res?.choices?.[0]?.message;\n\n      // const deepModels = thinkIgnore\n      //   .split(\",\")\n      //   .filter((model) => model?.trim());\n      // if (deepModels.some((model) => res?.model?.startsWith(model))) {\n      //   modelMsg?.content.replace(/<think>[\\s\\S]*<\\/think>/i, \"\");\n      // }\n\n      if (history && userMsg && modelMsg) {\n        history.add(userMsg, {\n          role: modelMsg.role,\n          content: modelMsg.content,\n        });\n      }\n      return parseAIRes(modelMsg?.content, useBatchFetch);\n    case OPT_TRANS_CUSTOMIZE:\n      if (useBatchFetch) {\n        return (res?.translations ?? res)?.map((item) => [item.text, item.src]);\n      }\n      return [[res.text, res.src || res.from]];\n    default:\n  }\n\n  throw new Error(\"parse translate result: apiType not matched\", apiType);\n};\n\n/**\n * 发送翻译请求并解析\n * 支持流式和非流式两种模式\n * @param {*} texts 待翻译文本数组\n * @param {*} options 翻译选项\n * @yields {{id: number, result: [string, string]}} 流式模式下逐个返回结果\n * @returns {Promise<Array>} 非流式模式下返回完整结果数组\n */\nexport async function* handleTranslate(\n  texts = [],\n  { from, to, fromLang, toLang, langMap, glossary, apiSetting, usePool }\n) {\n  let history = null;\n  let hisMsgs = [];\n  const {\n    apiType,\n    apiSlug,\n    contextSize,\n    useContext,\n    fetchInterval,\n    fetchLimit,\n    httpTimeout,\n    useStream,\n  } = apiSetting;\n  if (useContext && API_SPE_TYPES.context.has(apiType)) {\n    history = getMsgHistory(apiSlug, contextSize);\n    hisMsgs = history.getAll();\n  }\n\n  const enableStream = useStream && API_SPE_TYPES.stream.has(apiType);\n\n  let token = \"\";\n  if (apiType === OPT_TRANS_MICROSOFT) {\n    token = await msAuth();\n    if (!token) {\n      throw new Error(\"got msauth error\");\n    }\n  }\n\n  const [input, init, userMsg] = await genTransReq({\n    texts,\n    from,\n    to,\n    fromLang,\n    toLang,\n    langMap,\n    glossary,\n    hisMsgs,\n    token,\n    useStream: enableStream,\n    ...apiSetting,\n  });\n\n  if (enableStream) {\n    yield* handleTranslateStreamInternal(texts, input, init, {\n      apiType,\n      history,\n      userMsg,\n      usePool,\n      fetchInterval,\n      fetchLimit,\n      httpTimeout,\n    });\n  } else {\n    const response = await fetchData(input, init, {\n      useCache: false,\n      usePool,\n      fetchInterval,\n      fetchLimit,\n      httpTimeout,\n    });\n    if (!response) {\n      throw new Error(\"translate got empty response\");\n    }\n\n    const result = await parseTransRes(response, {\n      texts,\n      from,\n      to,\n      fromLang,\n      toLang,\n      langMap,\n      history,\n      userMsg,\n      ...apiSetting,\n    });\n    if (!result?.length) {\n      throw new Error(\"translate got an unexpected result\");\n    }\n\n    for (let i = 0; i < result.length; i++) {\n      yield { id: i, result: result[i] };\n    }\n  }\n}\n\n/**\n * 内部流式翻译处理\n */\nasync function* handleTranslateStreamInternal(\n  texts,\n  input,\n  init,\n  { apiType, history, userMsg, usePool, fetchInterval, fetchLimit, httpTimeout }\n) {\n  const results = new Array(texts.length).fill(null);\n  let fullContent = \"\";\n  const processedIds = new Set();\n\n  const jsonParser = createStreamingJsonParser();\n  let isJsonFormat = false;\n  let formatDetected = false;\n\n  try {\n    for await (const rawData of fetchStream(input, init, {\n      useCache: false,\n      usePool,\n      fetchInterval,\n      fetchLimit,\n      httpTimeout,\n    })) {\n      try {\n        const json = JSON.parse(rawData);\n        const delta = getStreamDelta(json, apiType);\n\n        if (delta) {\n          fullContent += delta;\n          fullContent = stripMarkdownCodeBlock(fullContent, true);\n\n          if (!formatDetected) {\n            const { isJson, detected } = detectStreamFormat(fullContent);\n            if (detected) {\n              formatDetected = true;\n              isJsonFormat = isJson;\n              // 格式检测成功后，将累积的内容写入解析器\n              if (isJsonFormat) {\n                for (const { id, translation } of jsonParser.write(\n                  fullContent\n                )) {\n                  results[id] = translation;\n                  yield { id, result: translation };\n                }\n              }\n            }\n          } else if (isJsonFormat) {\n            for (const { id, translation } of jsonParser.write(delta)) {\n              results[id] = translation;\n              yield { id, result: translation };\n            }\n          } else {\n            for (const { id, translation } of parseStreamingSegments(\n              fullContent,\n              processedIds\n            )) {\n              results[id] = translation;\n              yield { id, result: translation };\n            }\n          }\n        }\n      } catch (e) {\n        // 忽略解析错误\n      }\n    }\n\n    if (isJsonFormat) {\n      jsonParser.end();\n    }\n  } catch (error) {\n    kissLog(\"handleTranslateStream error\", error);\n    throw error;\n  }\n\n  // 最终再解析一次，捕获可能遗漏的段落\n  const hasEmpty = results.some((r) => !r);\n  if (hasEmpty) {\n    const parsed = parseAIRes(fullContent, true);\n    for (let i = 0; i < texts.length && i < parsed.length; i++) {\n      if (!results[i]) {\n        results[i] = parsed[i];\n        yield { id: i, result: results[i] };\n      }\n    }\n  }\n\n  if (history && userMsg) {\n    if (apiType === OPT_TRANS_GEMINI) {\n      history.add(userMsg, {\n        role: \"model\",\n        parts: [{ text: fullContent }],\n      });\n    } else {\n      history.add(userMsg, {\n        role: \"assistant\",\n        content: fullContent,\n      });\n    }\n  }\n}\n\n/**\n * Microsoft语言识别聚合及解析\n * @param {*} texts\n * @returns\n */\nexport const handleMicrosoftLangdetect = async (texts = []) => {\n  const token = await msAuth();\n  const input =\n    \"https://api-edge.cognitive.microsofttranslator.com/detect?api-version=3.0\";\n  const init = {\n    headers: {\n      \"Content-type\": \"application/json\",\n      Authorization: `Bearer ${token}`,\n    },\n    method: \"POST\",\n    body: JSON.stringify(texts.map((text) => ({ Text: text }))),\n  };\n\n  const res = await fetchData(input, init, {\n    useCache: false,\n  });\n\n  if (Array.isArray(res)) {\n    return res.map((r) => r.language);\n  }\n\n  return [];\n};\n\n/**\n * 字幕翻译\n * @param {*} param0\n * @returns\n */\nexport const handleSubtitle = async ({ events, from, to, apiSetting }) => {\n  const { apiType, fetchInterval, fetchLimit, httpTimeout } = apiSetting;\n\n  const [input, init] = await genTransReq({\n    ...apiSetting,\n    events,\n    from,\n    to,\n  });\n\n  const res = await fetchData(input, init, {\n    useCache: false,\n    usePool: true,\n    fetchInterval,\n    fetchLimit,\n    httpTimeout,\n  });\n  if (!res) {\n    kissLog(\"subtitle got empty response\");\n    return [];\n  }\n\n  switch (apiType) {\n    case OPT_TRANS_OPENAI:\n    case OPT_TRANS_GEMINI_2:\n    case OPT_TRANS_OPENROUTER:\n    case OPT_TRANS_OLLAMA:\n      return parseSTRes(res?.choices?.[0]?.message?.content ?? \"\");\n    case OPT_TRANS_GEMINI:\n      return parseSTRes(res?.candidates?.[0]?.content?.parts?.[0]?.text ?? \"\");\n    case OPT_TRANS_CLAUDE:\n      return parseSTRes(res?.content?.[0]?.text ?? \"\");\n    case OPT_TRANS_CUSTOMIZE:\n      return res;\n    default:\n  }\n\n  return [];\n};\n"
  },
  {
    "path": "src/background.js",
    "content": "import browser from \"webextension-polyfill\";\nimport {\n  MSG_FETCH,\n  MSG_GET_HTTPCACHE,\n  MSG_PUT_HTTPCACHE,\n  MSG_TRANS_TOGGLE,\n  MSG_OPEN_OPTIONS,\n  MSG_SAVE_RULE,\n  MSG_TRANS_TOGGLE_STYLE,\n  MSG_OPEN_TRANBOX,\n  MSG_CONTEXT_MENUS,\n  MSG_COMMAND_SHORTCUTS,\n  MSG_INJECT_JS,\n  MSG_INJECT_CSS,\n  MSG_UPDATE_CSP,\n  MSG_BUILTINAI_DETECT,\n  MSG_BUILTINAI_TRANSLATE,\n  CMD_TOGGLE_TRANSLATE,\n  CMD_TOGGLE_STYLE,\n  CMD_OPEN_OPTIONS,\n  CMD_OPEN_TRANBOX,\n  CMD_OPEN_SEPARATE_WINDOW,\n  CLIENT_THUNDERBIRD,\n  MSG_SET_LOGLEVEL,\n  MSG_CLEAR_CACHES,\n  MSG_OPEN_SEPARATE_WINDOW,\n  STOKEY_SEPARATE_WINDOW,\n  PORT_STREAM_FETCH,\n  MSG_UPDATE_ICON,\n} from \"./config\";\nimport { getSettingWithDefault, tryInitDefaultData } from \"./libs/storage\";\nimport { trySyncSettingAndRules } from \"./libs/sync\";\nimport { fetchHandle, fetchStreamNative } from \"./libs/fetch\";\nimport { tryClearCaches, getHttpCache, putHttpCache } from \"./libs/cache\";\nimport { sendTabMsg } from \"./libs/msg\";\nimport { trySyncAllSubRules } from \"./libs/subRules\";\nimport { saveRule } from \"./libs/rules\";\nimport { getCurTabId } from \"./libs/msg\";\nimport { injectInlineJsBg, injectInternalCss } from \"./libs/injector\";\nimport { kissLog, logger } from \"./libs/log\";\nimport { chromeDetect, chromeTranslate } from \"./libs/builtinAI\";\n\nglobalThis.__KISS_CONTEXT__ = \"background\";\n\nasync function updateIcon(isActive, tabId) {\n  const suffix = isActive ? \"_active\" : \"\";\n  const path = {\n    16: `images/logo16${suffix}.png`,\n    32: `images/logo32${suffix}.png`,\n    48: `images/logo48${suffix}.png`,\n    128: `images/logo128${suffix}.png`,\n    192: `images/logo192${suffix}.png`,\n  };\n  try {\n    // 兼容 v2 清单下的 Firefox\n    if (browser.action) {\n      await browser.action.setIcon({ path, tabId });\n    } else {\n      await browser.browserAction.setIcon({ path, tabId });\n    }\n  } catch (err) {\n    kissLog(\"updateIcon error\", err);\n  }\n}\n\nconst CSP_RULE_START_ID = 1;\nconst ORI_RULE_START_ID = 10000;\nconst CSP_REMOVE_HEADERS = [\n  `content-security-policy`,\n  `content-security-policy-report-only`,\n  `x-webkit-csp`,\n  `x-content-security-policy`,\n];\n\n// 独立窗口ID\nlet separateWindowId = null;\n// 记录窗口最后一次有效的位置和大小\nlet lastKnownBounds = null;\n\nconst DEFAULT_SEPARATE_WINDOW_BOUNDS = {\n  left: 100,\n  top: 100,\n  width: 400,\n  height: 400,\n};\n\n/**\n * 将独立窗口数据写入存储\n */\nasync function persistSeparateWindowBounds(bounds) {\n  if (!bounds) return;\n  try {\n    await browser.storage.local.set({ [STOKEY_SEPARATE_WINDOW]: bounds });\n    kissLog(\"Final separate window bounds saved to storage\", bounds);\n  } catch (err) {\n    kissLog(\"Save separate window bounds error\", err);\n  }\n}\n\n/**\n * 打开独立窗口\n */\nasync function openSeparateWindowWithSavedBounds() {\n  try {\n    // 如果窗口已存在，则聚焦它而不是重复创建\n    if (separateWindowId !== null) {\n      const allWindows = await browser.windows.getAll();\n      const existingWin = allWindows.find((w) => w.id === separateWindowId);\n      if (existingWin) {\n        await browser.windows.update(separateWindowId, { focused: true });\n        kissLog(\"Separate window is ready\");\n        return existingWin;\n      }\n    }\n\n    const stored = await browser.storage.local.get(STOKEY_SEPARATE_WINDOW);\n    const saved = stored && stored[STOKEY_SEPARATE_WINDOW];\n    const bounds = Object.assign(\n      {},\n      DEFAULT_SEPARATE_WINDOW_BOUNDS,\n      saved || {}\n    );\n\n    const win = await browser.windows.create({\n      url: \"popup.html#tranbox\",\n      type: \"popup\",\n      left: Math.round(bounds.left),\n      top: Math.round(bounds.top),\n      width: Math.round(bounds.width),\n      height: Math.round(bounds.height),\n      focused: true,\n    });\n\n    separateWindowId = win.id;\n    lastKnownBounds = {\n      left: win.left,\n      top: win.top,\n      width: win.width,\n      height: win.height,\n    };\n\n    return win;\n  } catch (err) {\n    kissLog(\"open separate window error\", err);\n  }\n}\n\n/**\n * 更新内存中的坐标缓存\n */\nasync function updateCacheFromActual(windowId) {\n  try {\n    const win = await browser.windows.get(windowId);\n    if (win && win.state === \"normal\") {\n      lastKnownBounds = {\n        left: Math.round(win.left),\n        top: Math.round(win.top),\n        width: Math.round(win.width),\n        height: Math.round(win.height),\n      };\n      kissLog(\"Bounds cached via fallback:\", lastKnownBounds);\n      // todo: 获取到的left和top均为0？\n      // todo: firefox 每重新打开一次，窗口愈来愈大？\n    }\n  } catch (e) {\n    // 窗口可能已关闭\n  }\n}\n\n/**\n * 监听焦点变化(兼容桌面Firefox)\n * Firefox 移动端不支持\n */\nbrowser.windows?.onFocusChanged?.addListener?.(async (windowId) => {\n  if (separateWindowId !== null) {\n    await updateCacheFromActual(separateWindowId);\n  }\n});\n\n/**\n * 监听位置变化：仅更新内存，不操作 Storage\n * Firefox 不支持 browser.windows.onBoundsChanged\n */\nbrowser.windows?.onBoundsChanged?.addListener?.((win) => {\n  if (separateWindowId !== null && win.id === separateWindowId) {\n    lastKnownBounds = {\n      left: win.left ?? lastKnownBounds.left,\n      top: win.top ?? lastKnownBounds.top,\n      width: win.width ?? lastKnownBounds.width,\n      height: win.height ?? lastKnownBounds.height,\n    };\n    // todo: 获取到的left和top均为0？\n  }\n});\n\n/**\n * 监听窗口关闭：此时执行持久化\n * Firefox 移动端不支持\n */\nbrowser.windows?.onRemoved?.addListener?.(async (windowId) => {\n  if (windowId === separateWindowId) {\n    if (lastKnownBounds) {\n      await persistSeparateWindowBounds(lastKnownBounds);\n    }\n\n    separateWindowId = null;\n    lastKnownBounds = null;\n  }\n});\n\n/**\n * 添加右键菜单\n */\nasync function addContextMenus(contextMenuType = 1) {\n  // 添加前先删除,避免重复ID的错误\n  try {\n    await browser.contextMenus.removeAll();\n  } catch (err) {\n    kissLog(\"remove contextMenus\", err);\n  }\n\n  switch (contextMenuType) {\n    case 1:\n      browser.contextMenus.create({\n        id: CMD_TOGGLE_TRANSLATE,\n        title: browser.i18n.getMessage(\"app_name\"),\n        contexts: [\"page\", \"selection\"],\n      });\n      break;\n    case 2:\n      browser.contextMenus.create({\n        id: CMD_TOGGLE_TRANSLATE,\n        title: browser.i18n.getMessage(\"toggle_translate\"),\n        contexts: [\"page\", \"selection\"],\n      });\n      browser.contextMenus.create({\n        id: CMD_TOGGLE_STYLE,\n        title: browser.i18n.getMessage(\"toggle_style\"),\n        contexts: [\"page\", \"selection\"],\n      });\n      browser.contextMenus.create({\n        id: CMD_OPEN_TRANBOX,\n        title: browser.i18n.getMessage(\"open_tranbox\"),\n        contexts: [\"page\", \"selection\"],\n      });\n      browser.contextMenus.create({\n        id: \"options_separator\",\n        type: \"separator\",\n        contexts: [\"page\", \"selection\"],\n      });\n      browser.contextMenus.create({\n        id: CMD_OPEN_OPTIONS,\n        title: browser.i18n.getMessage(\"open_options\"),\n        contexts: [\"page\", \"selection\"],\n      });\n      break;\n    default:\n  }\n}\n\n/**\n * 更新CSP策略\n * @param {*} csplist\n */\nasync function updateCspRules({ csplist, orilist }) {\n  try {\n    const oldRules = await browser.declarativeNetRequest.getDynamicRules();\n\n    const rulesToAdd = [];\n    const idsToRemove = [];\n\n    if (csplist !== undefined) {\n      let processedCspList = csplist;\n      if (typeof processedCspList === \"string\") {\n        processedCspList = processedCspList\n          .split(/\\n|,/)\n          .map((url) => url.trim())\n          .filter(Boolean);\n      }\n\n      const oldCspRuleIds = oldRules\n        .filter(\n          (rule) => rule.id >= CSP_RULE_START_ID && rule.id < ORI_RULE_START_ID\n        )\n        .map((rule) => rule.id);\n      idsToRemove.push(...oldCspRuleIds);\n\n      const newCspRules = processedCspList.map((url, index) => ({\n        id: CSP_RULE_START_ID + index,\n        action: {\n          type: \"modifyHeaders\",\n          responseHeaders: CSP_REMOVE_HEADERS.map((header) => ({\n            operation: \"remove\",\n            header,\n          })),\n        },\n        condition: {\n          urlFilter: url,\n          resourceTypes: [\"main_frame\", \"sub_frame\"],\n        },\n      }));\n      rulesToAdd.push(...newCspRules);\n    }\n\n    if (orilist !== undefined) {\n      let processedOriList = orilist;\n      if (typeof processedOriList === \"string\") {\n        processedOriList = processedOriList\n          .split(/\\n|,/)\n          .map((url) => url.trim())\n          .filter(Boolean);\n      }\n\n      const oldOriRuleIds = oldRules\n        .filter((rule) => rule.id >= ORI_RULE_START_ID)\n        .map((rule) => rule.id);\n      idsToRemove.push(...oldOriRuleIds);\n\n      const newOriRules = processedOriList.map((url, index) => ({\n        id: ORI_RULE_START_ID + index,\n        action: {\n          type: \"modifyHeaders\",\n          requestHeaders: [{ header: \"Origin\", operation: \"set\", value: url }],\n        },\n        condition: {\n          urlFilter: url,\n          resourceTypes: [\"xmlhttprequest\"],\n        },\n      }));\n      rulesToAdd.push(...newOriRules);\n    }\n\n    if (idsToRemove.length > 0 || rulesToAdd.length > 0) {\n      await browser.declarativeNetRequest.updateDynamicRules({\n        removeRuleIds: idsToRemove,\n        addRules: rulesToAdd,\n      });\n    }\n  } catch (err) {\n    kissLog(\"update csp rules\", err);\n  }\n}\n\n/**\n * 注册邮件显示脚本\n */\nasync function registerMsgDisplayScript() {\n  await messenger.messageDisplayScripts.register({\n    js: [{ file: \"/content.js\" }],\n  });\n}\n\n/**\n * 插件安装\n */\nbrowser.runtime.onInstalled.addListener(async () => {\n  await tryInitDefaultData();\n\n  //在thunderbird中注册脚本\n  if (process.env.REACT_APP_CLIENT === CLIENT_THUNDERBIRD) {\n    registerMsgDisplayScript();\n  }\n\n  const { contextMenuType, csplist, orilist, subrulesList } =\n    await getSettingWithDefault();\n\n  // 右键菜单\n  addContextMenus(contextMenuType);\n\n  // 禁用CSP\n  updateCspRules({ csplist, orilist });\n\n  // 同步订阅规则\n  trySyncAllSubRules({ subrulesList });\n});\n\n/**\n * 浏览器启动\n */\nbrowser.runtime.onStartup.addListener(async () => {\n  const {\n    clearCache,\n    contextMenuType,\n    subrulesList,\n    csplist,\n    orilist,\n    logLevel,\n  } = await getSettingWithDefault();\n\n  // 设置日志\n  logger.setLevel(logLevel);\n\n  // 清除缓存\n  if (clearCache) {\n    tryClearCaches();\n  }\n\n  //在thunderbird中注册脚本\n  if (process.env.REACT_APP_CLIENT === CLIENT_THUNDERBIRD) {\n    registerMsgDisplayScript();\n  }\n\n  // 右键菜单\n  // firefox重启后菜单会消失,故重复添加\n  addContextMenus(contextMenuType);\n\n  // 禁用CSP\n  updateCspRules({ csplist, orilist });\n\n  // 同步数据\n  trySyncSettingAndRules();\n\n  // 同步订阅规则\n  trySyncAllSubRules({ subrulesList });\n});\n\n/**\n * 向当前活动标签页注入脚本或CSS\n */\nconst injectToCurrentTab = async (func, args) => {\n  const tabId = await getCurTabId();\n  return browser.scripting.executeScript({\n    target: { tabId, allFrames: true },\n    func: func,\n    args: [args],\n    world: \"MAIN\",\n  });\n};\n\n// 动作处理器映射表\nconst messageHandlers = {\n  [MSG_FETCH]: (args) => fetchHandle(args),\n  [MSG_GET_HTTPCACHE]: (args) => getHttpCache(args),\n  [MSG_PUT_HTTPCACHE]: (args) => putHttpCache(args),\n  [MSG_OPEN_OPTIONS]: () => browser.runtime.openOptionsPage(),\n  [MSG_SAVE_RULE]: (args) => saveRule(args),\n  [MSG_INJECT_JS]: (args) => injectToCurrentTab(injectInlineJsBg, args),\n  [MSG_INJECT_CSS]: (args) => injectToCurrentTab(injectInternalCss, args),\n  [MSG_UPDATE_CSP]: (args) => updateCspRules(args),\n  [MSG_CONTEXT_MENUS]: (args) => addContextMenus(args),\n  [MSG_COMMAND_SHORTCUTS]: () => browser.commands.getAll(),\n  [MSG_BUILTINAI_DETECT]: (args) => chromeDetect(args),\n  [MSG_BUILTINAI_TRANSLATE]: (args) => chromeTranslate(args),\n  [MSG_SET_LOGLEVEL]: (args) => logger.setLevel(args),\n  [MSG_CLEAR_CACHES]: () => tryClearCaches(),\n  [MSG_OPEN_SEPARATE_WINDOW]: () => openSeparateWindowWithSavedBounds(),\n  [MSG_UPDATE_ICON]: (args, sender) => updateIcon(args, sender?.tab?.id),\n};\n\n/**\n * 监听消息\n * todo: 返回含错误的结构化信息\n */\nbrowser.runtime.onMessage.addListener(async ({ action, args }, sender) => {\n  const handler = messageHandlers[action];\n  if (!handler) {\n    throw new Error(`Message action is unavailable: ${action}`);\n  }\n\n  return handler(args, sender);\n});\n\n/**\n * 监听快捷键\n * Firefox 移动端不支持\n */\nbrowser.commands?.onCommand?.addListener?.((command) => {\n  // console.log(`Command: ${command}`);\n  switch (command) {\n    case CMD_TOGGLE_TRANSLATE:\n      sendTabMsg(MSG_TRANS_TOGGLE);\n      break;\n    case CMD_OPEN_TRANBOX:\n      sendTabMsg(MSG_OPEN_TRANBOX);\n      break;\n    case CMD_TOGGLE_STYLE:\n      sendTabMsg(MSG_TRANS_TOGGLE_STYLE);\n      break;\n    case CMD_OPEN_OPTIONS:\n      browser.runtime.openOptionsPage();\n      break;\n    case CMD_OPEN_SEPARATE_WINDOW:\n      // invoke the handler to open the independent window\n      if (messageHandlers[MSG_OPEN_SEPARATE_WINDOW]) {\n        messageHandlers[MSG_OPEN_SEPARATE_WINDOW]();\n      }\n      break;\n    default:\n  }\n});\n\n/**\n * 监听右键菜单\n * Firefox 移动端不支持\n */\nbrowser?.contextMenus?.onClicked?.addListener?.(({ menuItemId }) => {\n  switch (menuItemId) {\n    case CMD_TOGGLE_TRANSLATE:\n      sendTabMsg(MSG_TRANS_TOGGLE);\n      break;\n    case CMD_TOGGLE_STYLE:\n      sendTabMsg(MSG_TRANS_TOGGLE_STYLE);\n      break;\n    case CMD_OPEN_TRANBOX:\n      sendTabMsg(MSG_OPEN_TRANBOX);\n      break;\n    case CMD_OPEN_OPTIONS:\n      browser.runtime.openOptionsPage();\n      break;\n    default:\n  }\n});\n\n/**\n * 处理通用流式请求\n * 通过端口连接实现流式数据传输\n */\nasync function handleStreamFetch(port, args) {\n  const { input, init, opts } = args;\n\n  try {\n    for await (const chunk of fetchStreamNative(\n      input,\n      init,\n      opts.httpTimeout\n    )) {\n      port.postMessage({ type: \"delta\", data: chunk });\n    }\n    port.postMessage({ type: \"done\" });\n  } catch (error) {\n    if (error.name !== \"AbortError\") {\n      port.postMessage({ type: \"error\", error: error.message });\n    }\n  }\n}\n\n/**\n * 监听端口连接（用于流式请求）\n */\nbrowser.runtime.onConnect.addListener((port) => {\n  if (port.name === PORT_STREAM_FETCH) {\n    port.onMessage.addListener((message) => {\n      if (message.action === \"start\") {\n        handleStreamFetch(port, message.args);\n      }\n    });\n  }\n});\n"
  },
  {
    "path": "src/common.js",
    "content": "import { OPT_HIGHLIGHT_WORDS_DISABLE } from \"./config\";\nimport {\n  getFabWithDefault,\n  getSettingWithDefault,\n  getWordsWithDefault,\n} from \"./libs/storage\";\nimport { isIframe } from \"./libs/iframe\";\nimport { genEventName } from \"./libs/utils\";\nimport { handlePing, injectScript } from \"./libs/gm\";\nimport { matchRule } from \"./libs/rules\";\nimport { trySyncAllSubRules } from \"./libs/subRules\";\nimport { isInBlacklist } from \"./libs/blacklist\";\nimport { runSubtitle } from \"./subtitle/subtitle\";\nimport { logger } from \"./libs/log\";\nimport { injectInlineJs } from \"./libs/injector\";\nimport TranslatorManager from \"./libs/translatorManager\";\n\n/**\n * 油猴脚本设置页面\n */\nfunction runSettingPage() {\n  if (GM.info?.script?.grant?.includes(\"unsafeWindow\")) {\n    unsafeWindow.GM = GM;\n    unsafeWindow.APP_INFO = {\n      name: process.env.REACT_APP_NAME,\n      version: process.env.REACT_APP_VERSION,\n    };\n  } else {\n    const ping = genEventName();\n    window.addEventListener(ping, handlePing);\n    // window.eval(`(${injectScript})(\"${ping}\")`); // eslint-disable-line\n    injectInlineJs(\n      `(${injectScript})(\"${ping}\")`,\n      \"kiss-translator-options-injector\"\n    );\n  }\n}\n\n/**\n * 显示错误信息到页面顶部\n * @param {*} message\n */\nfunction showErr(message) {\n  const bannerId = \"KISS-Translator-Message\";\n  const existingBanner = document.getElementById(bannerId);\n  if (existingBanner) {\n    existingBanner.remove();\n  }\n\n  const banner = document.createElement(\"div\");\n  banner.id = bannerId;\n\n  Object.assign(banner.style, {\n    position: \"fixed\",\n    top: \"0\",\n    left: \"0\",\n    width: \"100%\",\n    backgroundColor: \"#f44336\",\n    color: \"white\",\n    textAlign: \"center\",\n    padding: \"8px 16px\",\n    zIndex: \"1001\",\n    boxSizing: \"border-box\",\n    fontSize: \"16px\",\n    boxShadow: \"0 2px 5px rgba(0,0,0,0.2)\",\n  });\n\n  const closeButton = document.createElement(\"span\");\n  closeButton.textContent = \"×\";\n\n  Object.assign(closeButton.style, {\n    position: \"absolute\",\n    top: \"50%\",\n    right: \"20px\",\n    transform: \"translateY(-50%)\",\n    cursor: \"pointer\",\n    fontSize: \"22px\",\n    fontWeight: \"bold\",\n  });\n\n  const messageText = document.createTextNode(`KISS-Translator: ${message}`);\n  banner.appendChild(messageText);\n  banner.appendChild(closeButton);\n\n  document.body.appendChild(banner);\n\n  const removeBanner = () => {\n    banner.style.transition = \"opacity 0.5s ease\";\n    banner.style.opacity = \"0\";\n    setTimeout(() => {\n      if (banner && banner.parentNode) {\n        banner.parentNode.removeChild(banner);\n      }\n    }, 500);\n  };\n\n  closeButton.onclick = removeBanner;\n  setTimeout(removeBanner, 10000);\n}\n\nasync function getFavWords(rule) {\n  if (\n    rule.highlightWords &&\n    rule.highlightWords !== OPT_HIGHLIGHT_WORDS_DISABLE\n  ) {\n    try {\n      return Object.keys(await getWordsWithDefault());\n    } catch (err) {\n      logger.info(\"get fav words\", err);\n    }\n  }\n\n  return [];\n}\n\n/**\n * 入口函数\n */\nexport async function run(isUserscript = false) {\n  try {\n    // 读取设置信息\n    const setting = await getSettingWithDefault();\n\n    // 日志\n    logger.setLevel(setting.logLevel);\n\n    // if (document?.documentElement?.tagName?.toUpperCase() !== \"HTML\") {\n    //   return;\n    // }\n    const contentType = document?.contentType?.toLowerCase() || \"\";\n    if (!contentType.includes(\"text\") && !contentType.includes(\"html\")) {\n      logger.info(\"Skip running in document content type: \", contentType);\n      return;\n    }\n\n    const href = document?.location?.href || \"\";\n\n    // 油猴脚本\n    if (isUserscript) {\n      if (!globalThis.GM) {\n        globalThis.GM = {\n          xmlHttpRequest: globalThis.GM_xmlhttpRequest,\n          registerMenuCommand: globalThis.GM_registerMenuCommand,\n          unregisterMenuCommand: globalThis.GM_unregisterMenuCommand,\n          setValue: globalThis.GM_setValue,\n          getValue: globalThis.GM_getValue,\n          deleteValue: globalThis.GM_deleteValue,\n          info: globalThis.GM_info,\n        };\n      }\n\n      // 设置页面\n      if (\n        href.includes(process.env.REACT_APP_OPTIONSPAGE_DEV) ||\n        href.includes(process.env.REACT_APP_OPTIONSPAGE)\n      ) {\n        runSettingPage();\n        return;\n      }\n    }\n\n    // 黑名单\n    if (isInBlacklist(href, setting)) {\n      return;\n    }\n\n    // 翻译网页\n    const rule = await matchRule(href, setting);\n    const favWords = await getFavWords(rule);\n    const fabConfig = await getFabWithDefault();\n    const translatorManager = new TranslatorManager({\n      setting,\n      rule,\n      fabConfig,\n      favWords,\n      isIframe,\n      isUserscript,\n    });\n    translatorManager.start();\n\n    if (isIframe) {\n      return;\n    }\n\n    // 字幕翻译\n    runSubtitle({ href, setting, rule, isUserscript });\n\n    if (isUserscript) {\n      trySyncAllSubRules(setting);\n    }\n  } catch (err) {\n    console.error(\"[KISS-Translator]\", err);\n    showErr(err.message);\n  }\n}\n"
  },
  {
    "path": "src/components/Logo/icon.base64.js",
    "content": "export const FAVICON_BASE64 =\n  \"data:image/x-icon;base64,AAABAAMAMDAAAAEAIACoJQAANgAAACAgAAABACAAqBAAAN4lAAAQEAAAAQAgAGgEAACGNgAAKAAAADAAAABgAAAAAQAgAAAAAAAAJAAAAAAAAAAAAAAAAAAAAAAAAAAAAADunB8Q7pwgd+6cINTunCD67pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD67pwg1O6cIHfunR8QAAAAAO6cHxDunCCW7pwg+e6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIPnunCCW7pwfEO6cIHfunCD47pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD47pwgd+6cINPunCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg0+6cIPnunCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg+e6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6bHv/umx3/7pse/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unB//7psd/+6bHf/umx7/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pse/+6bHf/umx7/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7p0i//GsRP/ytlv/8a9K/+6eJf/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cH//vnyj/8bFP//K2Wv/wqj7/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCH/8Kk9//KzVf/xrUb/7p4k/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx7/8rVY//3y4v/++/f//fbq//S+bP/umx7/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/2yIT//vjw//779//87db/8axF/+6bHv/unCD/7pwg/+6cIP/unCD/7pwg/+6bHv/xsEz//O7Z//769P/99en/9MBx/+6bHv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9smG//////////////////jVn//unB//7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6fJv/64r7//////////////v3/9L9v/+6aHP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHP/1xHn/////////////////+Nmq/+6dIf/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suK//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/++fKP/65MH///////////////7/9MF0/+6aHP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dut/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/++fJ//65MH//////////////v7/9MFz/+6aHP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwf/++gKf/75cX//////////////v7/9MFz/+6aHP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pod//GuSP/98+T///////////////7/9MF0/+6aHP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx7/8a1G//rkw////////////////////vz/9L5t/+6aHP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwf/+6cH//ytlz//O3W///////////////////////98eH/8axE/+6bHv/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/umx//7p4k//TCdv/99ej///////////////////////3x4P/zu2b/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jWpP/unCD/7pwg/+6bHv/voSz/98+S//769P//////////////////////++jK//KxUP/umx7/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpf/unB//7psd//CnOf/5267///37///////////////////9+//53K//8Kg7/+6bHf/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////vmx//wpjj/8rFQ//vmx////////////////////////vrz//fOkf/voSz/7pse/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ///////////////////9+v/87df//fXo///////////////////////99Ob/9MFz/+6dI//umx//7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////////////////////////////////////zr0v/ytVj/7psf/+6cH//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ////////////////////////////////////////////++fJ//GrQv/umx3/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ////////////////////////////////////////////9cN3/+6aG//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ////////////////////////////////////////////+Nai/+6eJf/unB//7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ/////////////////////////////////////////////vv2//bKiP/uniT/7pwf/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ///////////////////+/f/+9+3///z5//////////////////769f/2yYX/7p0j/+6cH//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////zr0v/xsE7/9siE//748P/////////////////++vT/9siD/+6dI//unB//7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jYp//umx7/7pwh//XFfP/++fH//////////////////vrz//XHgP/unSL/7pwf/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jWpP/unCD/7pwf/+6dIv/1xn3//vny//////////////////758v/1xn7/7p0i/+6cH//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cH//unSL/9cZ///758//////////////////++fH/9cV7/+6dIv/unB//7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unB//7p0j//XHgf/++vP//////////////////vjw//XDef/unSH/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwf/+6dI//1yIP//vr0//////////////////747//0wXP/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwg/+6cH//uniP/9smF//769f/////////////////98d//8KpB/+6bHv/unCD/7pwg/+6cIP/unCD/7pwg/+6aHP/1xX7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unB//7p4k//fOkf///vz//////////////fz/9L1q/+6aHP/unB//7psf/+6bH//umx//7pse/+6aG//2yof/////////////////+d60/+6dIv/umx7/7psf/+6bH//umx//7pwf/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pse//CnOP/879r//////////////v7/9MFz/+6aHP/vnyf/8KY2//CmN//wpjf/8KY3//KxT//76Mv//////////////////fPk//O6ZP/wpjj/8KY3//CmN//wpjb/76Er/+6cH//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwf/++gKP/65MP//////////////v7/9MBy/++kMf/5267//PDe//3x3//88d///fHf//737v////7///////////////////////768//98uH//PHf//3x3//88d//+uTB//GuSP/umx7/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suK//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/++fKP/65MH///////////////7/9MBw//O4X////fr///////////////////////////////////////////////////////////////////////////////////////bJhv/umx3/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9smF//////////////////jUnv/unB//7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6fJv/64r3//////////////v3/9L1q//O6Y////fv///////////////////////////////////////////////////////////////////////////////////////bLif/umx3/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx7/8rNU//zv3P/++vP//fTl//O7Z//umx7/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cH//1xn7//fbr//768//76tD/8KpA//CoO//75sj//vjw//748P/++PD//vjw//748P/++PD//vjw//748P/++PD//vjw//748P/++PD//vjw//748P/++PD//O7a//KzVf/umx7/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwh//CpPv/ys1P/8axD/+6dI//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unib/8a5I//KyUv/wpzn/7pwg/+6cIP/wpTT/8a9M//GwTf/xsE3/8bBN//GwTf/xsE3/8bBN//GwTf/xsE3/8bBN//GwTf/xsE3/8bBN//GwTf/xsE3/8Kg7/+6cIf/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6bHv/umx3/7pse/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pse/+6bHf/umx7/7pwg/+6cIP/umx//7psd/+6bHf/umx3/7psd/+6bHf/umx3/7psd/+6bHf/umx3/7psd/+6bHf/umx3/7psd/+6bHf/umx3/7pse/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIPnunCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg+e6cINTunCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg1O6cIHfunCD47pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD47pwgd+6cHxDunCCW7pwg+e6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIPnunCCW7pwfEAAAAADunB8Q7pwgd+6cINTunCD67pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD67pwg1O6cIHfunB8QAAAAAOAAAAAABwAAgAAAAAABAACAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAQAAgAAAAAABAADgAAAAAAcAACgAAAAgAAAAQAAAAAEAIAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAADunB8S7pwgie6cIOvunCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIOvunCCJ7pwgEu6cIInunCD57pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIPnunCCJ7pwg6u6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIOrunCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cH//uniX/7p8m/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cH//uniT/758n/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6dIf/vnyf/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx7/8KpA//netf/64bz/8a5J/+6bHv/unCD/7pwg/+6cIP/umx//76Qz//jZqf/65MP/87Zc/+6bHv/unCD/7pwg/+6cIP/uniX/9syL//vlxP/0wnb/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHP/zvGn///78///////1xHr/7poc/+6cIP/unCD/7pwg/+6bHf/yslP//vr0///////30pr/7pse/+6cIP/unCD/7psf/++kM//87tr///////rjwP/unyb/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7poc//S+bP///v3///////XFff/umhz/7pwg/+6cIP/unCD/7psd//KzVP/++vX///////jUnf/umx7/7pwg/+6cIP/umx//8KU1//zv3P//////+uTC/++fJ//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umhz/9L1s///+/f//////9cV9/+6aHP/unCD/7pwg/+6cIP/umhv/87de//78+P//////+NSd/+6bHv/unCD/7pwg/+6bH//wpTX//O/c///////65ML/758n/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHP/0vWz///79///////1xX3/7poc/+6cIP/unCD/7psd//CmN//637j////////////30pn/7pse/+6cIP/unCD/7psf//ClNf/879z///////rkwv/vnyf/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7poc//S9bP///v3///////XFff/umhz/7pwg/+6bHv/xr0r/++bG/////////////vft//O5Yf/umx3/7pwg/+6cIP/umx//8KU1//zv3P//////+uTC/++fJ//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umhz/9L1s///+/f//////9cV8/+6aHP/unCD/87lh//zv3P////////////3x3//zvGf/7p0h/+6cIP/unCD/7pwg/+6bH//wpTX//O/c///////65ML/758n/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHP/0vWz///79///////2yIP/7p0i//XFfP/+9uz////////////758n/8bBN/+6bHv/unCD/7pwg/+6cIP/unCD/7psf//ClNf/879z///////rkwv/vnyf/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7poc//S9bP///v3///////zt1v/53rT//vv1/////////fv/+dut//CnOf/umx3/7pwg/+6cIP/unCD/7pwg/+6cIP/umx//8KU1//zv3P//////+uTC/++fJ//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umhz/9L1s///+/f///////////////////////vr1//bOkP/voSv/7pse/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6bH//wpTX//O/c///////65ML/758n/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHP/0vWz///79///////////////////////526//7p0i/+6bH//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7psf//ClNf/879z///////rkwv/vnyf/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7poc//S9bP///v3///////////////////////voy//wpzj/7pse/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx//8KU1//zv3P//////+uTC/++fJ//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umhz/9L1s///+/f///////vv3//768/////////////rguv/wpjb/7pse/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6bH//wpTX//O/c///////65ML/758n/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHP/0vWz///79///////41J7/87hf//zv2/////////////rft//wpTX/7pse/+6cIP/unCD/7pwg/+6cIP/unCD/7psf//ClNf/879z///////rkwv/vnyf/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7poc//S9bP///v3///////XFfP/tmRn/8rNV//zw3P////////////netf/wpTT/7pse/+6cIP/unCD/7pwg/+6cIP/umx//8KU1//zv3P//////+uTC/++fJ//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umhz/9L1s///+/f//////9cV9/+6aHP/umx7/8rRW//zw3v/////////+//nds//vpDP/7pse/+6cIP/unCD/7pwg/+6bH//wpTX//O/c///////65ML/758n/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHP/0vWz///79///////1xX3/7poc/+6cIP/umx7/8rVY//3x3//////////+//ncsP/vozD/7pwf/+6cIP/unCD/7psf//ClNf/879z///////rkwv/vnyf/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7poc//S9bP///v3///////XFff/umhz/7pwg/+6cIP/umx7/8rVZ//3y4v////////79//XEev/umx3/7psf/+6bHv/umx3/76Qz//zw3P//////+uTC/+6eJf/umx7/7pse/+6cH//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umhz/9L1s///+/f//////9cV9/+6aHP/unCD/7pwg/+6cIP/umx3/9cV7///+/f//////99Ob/+6cIf/wpTT/8Kc4//CmN//zu2b//vjv///////88N7/8rJT//CmN//wpzj/76Mx/+6cIf/unCD/7pwg/+6cIP/unCD/7pwg/+6aHP/0vmz///79///////1xX3/7poc/+6cIP/unCD/7pwg/+6bHf/ytFX//vv1///////30pv/87tn//zv2//98uH//fLh//758f///////////////v/+9+3//fHh//3y4f/76tD/8bBM/+6bHv/unCD/7pwg/+6cIP/unCD/7poc//O8af///vz///////XEef/umhz/7pwg/+6cIP/unCD/7psd//KyUv/++vP///////fRlv/3zpH////////////////////////////////////////////////////////+/P/0vWv/7poc/+6cIP/unCD/7pwg/+6cIP/umx7/8Kk+//ncsP/537b/8a1H/+6bHv/unCD/7pwg/+6cIP/unB//76Qx//jWo//64r7/8rRW//KyUv/537f/+uO///riv//64r//+uK///riv//64r//+uK///riv//647//+dqs//CpPv/umx7/7pwg/+6cIP/unCD/7pwg/+6cIP/unB//7p4k/+6eJP/unB//7pwg/+6cIP/unCD/7pwg/+6cIP/unB//7p0j/+6eJf/unB//7pwf/+6eJP/unib/7p4l/+6eJf/uniX/7p4l/+6eJf/uniX/7p4l/+6eJv/unSP/7pwf/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg6u6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIOrunCCJ7pwg+e6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD57pwgie6cIBLunCCJ7pwg6+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg6+6cIInunB8SgAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAEoAAAAEAAAACAAAAABACAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAA7pwgnu6cIPjunCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD47pwgnu6cIPfunB//7psd/+6cH//unCD/7pwg/+6bHv/umx3/7pwg/+6cIP/umx7/7psd/+6cIP/unCD/7pwg/+6cIPfunB//76Mv//S9a//vpDH/7pwf/+6cH//xsE7/87dc/+6cIf/umx//8axD//O5Yf/unSP/7pwg/+6cIP/unCD/7psd//KxT//99en/8rRX/+6bHf/umx7/+NWh//vmxv/vnyf/7poc//bLiv/87NX/76Iu/+6cH//unCD/7pwg/+6bHf/yslH//fbr//K1Wf/umhr/76Iu//rkw//75sb/7p8n/+6aHP/2zIz//O3X/++jL//unB//7pwg/+6cIP/umx3/8rJR//326//ytFX/8Kc4//netv/+9+3/9cN4/+6cIP/umh3/9syM//zt1//voy//7pwf/+6cIP/unCD/7psd//KyUf/++O//99Oc//voy//98eD/87tl/+6cIf/unCD/7psd//bMjP/87df/76Mv/+6cH//unCD/7pwg/+6bHf/ysVD//vrz///////98uL/8bBO/+6bHf/unCD/7pwg/+6aHf/2zIz//O3X/++jL//unB//7pwg/+6cIP/umx3/8rFQ//768//++/b//fXo//O3Xf/umx7/7pwg/+6cIP/umh3/9syM//zt1//voy//7pwf/+6cIP/unCD/7psd//KyUf/+9+3/9cV9//rguf/98+P/8rZb/+6bHv/unCD/7pod//bMjP/87df/76Mv/+6cH//unCD/7pwg/+6bHf/yslH//fbr//K0Vv/vpTT/+uK+//3y4f/ytFf/7pse/+6aG//2y4v//O3X/++iLf/umx7/7pwg/+6cIP/umx3/8rJR//326//ytVn/7pka//CnOf/869L/+uG7/++kM//wpjj/+NWh//3y4f/xr0z/8KU1/+6dI//unCD/7psd//KxT//99en/8rRX/+6bHf/umx3/+Nai//vnyv/52ar//fTm//779f///v3//fXp//zs0//xrET/7psd/+6cH//voi7/87xo/++jMf/unB//7pwf//GvTP/ytlv/8rZZ//XEev/1w3j/9cN3//XEef/0v3D/76Iu/+6cH//unCD37pwf/+6bHf/unB//7pwg/+6cIP/umx7/7psd/+6bHf/umhz/7poc/+6aHP/umhz/7poc/+6cH//unCD37pwgnu6cIPjunCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD47pwgngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\";\nexport default FAVICON_BASE64;\n"
  },
  {
    "path": "src/components/Logo/index.js",
    "content": "import React from \"react\";\nimport { FAVICON_BASE64 } from \"./icon.base64.js\";\n\nconst Logo = ({ size = 16, className = \"\", style = {}, onClick }) => {\n  return (\n    <img\n      src={FAVICON_BASE64}\n      alt=\"Logo\"\n      className={className}\n      onClick={onClick}\n      style={{\n        width: `${size}px`,\n        height: `${size}px`,\n        objectFit: \"contain\",\n        display: \"block\",\n        ...style,\n      }}\n    />\n  );\n};\n\nexport { FAVICON_BASE64 };\nexport default Logo;\n"
  },
  {
    "path": "src/config/api.js",
    "content": "export const DEFAULT_HTTP_TIMEOUT = 10000; // 调用超时时间\nexport const DEFAULT_FETCH_LIMIT = 10; // 默认最大任务数量\nexport const DEFAULT_FETCH_INTERVAL = 100; // 默认任务间隔时间\nexport const DEFAULT_BATCH_INTERVAL = 400; // 批处理请求间隔时间\nexport const DEFAULT_BATCH_SIZE = 10; // 每次最多发送段落数量\nexport const DEFAULT_BATCH_LENGTH = 10000; // 每次发送最大文字数量\nexport const DEFAULT_CONTEXT_SIZE = 3; // 上下文会话数量\n\nexport const INPUT_PLACE_URL = \"{{url}}\"; // 占位符\nexport const INPUT_PLACE_FROM = \"{{from}}\"; // 占位符\nexport const INPUT_PLACE_TO = \"{{to}}\"; // 占位符\nexport const INPUT_PLACE_FROM_LANG = \"{{fromLang}}\"; // 占位符\nexport const INPUT_PLACE_TO_LANG = \"{{toLang}}\"; // 占位符\nexport const INPUT_PLACE_TEXT = \"{{text}}\"; // 占位符\nexport const INPUT_PLACE_TONE = \"{{tone}}\"; // 占位符\nexport const INPUT_PLACE_TITLE = \"{{title}}\"; // 标题\nexport const INPUT_PLACE_DESCRIPTION = \"{{description}}\"; // 描述\nexport const INPUT_PLACE_SUMMARY = \"{{summary}}\"; // 摘要\nexport const INPUT_PLACE_KEY = \"{{key}}\"; // 占位符\nexport const INPUT_PLACE_MODEL = \"{{model}}\"; // 占位符\n\n// export const OPT_DICT_BAIDU = \"Baidu\";\nexport const OPT_DICT_BING = \"Bing\";\nexport const OPT_DICT_YOUDAO = \"Youdao\";\nexport const OPT_DICT_ALL = [OPT_DICT_BING, OPT_DICT_YOUDAO];\nexport const OPT_DICT_MAP = new Set(OPT_DICT_ALL);\n\nexport const OPT_SUG_BAIDU = \"Baidu\";\nexport const OPT_SUG_YOUDAO = \"Youdao\";\nexport const OPT_SUG_ALL = [OPT_SUG_BAIDU, OPT_SUG_YOUDAO];\nexport const OPT_SUG_MAP = new Set(OPT_SUG_ALL);\n\nexport const OPT_TRANS_BUILTINAI = \"BuiltinAI\";\nexport const OPT_TRANS_GOOGLE = \"Google\";\nexport const OPT_TRANS_GOOGLE_2 = \"Google2\";\nexport const OPT_TRANS_MICROSOFT = \"Microsoft\";\nexport const OPT_TRANS_AZUREAI = \"AzureAI\";\nexport const OPT_TRANS_DEEPL = \"DeepL\";\nexport const OPT_TRANS_DEEPLX = \"DeepLX\";\nexport const OPT_TRANS_DEEPLFREE = \"DeepLFree\";\nexport const OPT_TRANS_NIUTRANS = \"NiuTrans\";\nexport const OPT_TRANS_BAIDU = \"Baidu\";\nexport const OPT_TRANS_TENCENT = \"Tencent\";\nexport const OPT_TRANS_VOLCENGINE = \"Volcengine\";\nexport const OPT_TRANS_OPENAI = \"OpenAI\";\nexport const OPT_TRANS_GEMINI = \"Gemini\";\nexport const OPT_TRANS_GEMINI_2 = \"Gemini2\";\nexport const OPT_TRANS_CLAUDE = \"Claude\";\nexport const OPT_TRANS_CLOUDFLAREAI = \"CloudflareAI\";\nexport const OPT_TRANS_OLLAMA = \"Ollama\";\nexport const OPT_TRANS_OPENROUTER = \"OpenRouter\";\nexport const OPT_TRANS_CUSTOMIZE = \"Custom\";\n\n// 内置支持的翻译引擎\nexport const OPT_ALL_TRANS_TYPES = [\n  OPT_TRANS_BUILTINAI,\n  OPT_TRANS_GOOGLE,\n  OPT_TRANS_GOOGLE_2,\n  OPT_TRANS_MICROSOFT,\n  OPT_TRANS_AZUREAI,\n  // OPT_TRANS_BAIDU,\n  OPT_TRANS_TENCENT,\n  OPT_TRANS_VOLCENGINE,\n  OPT_TRANS_DEEPL,\n  OPT_TRANS_DEEPLFREE,\n  OPT_TRANS_DEEPLX,\n  OPT_TRANS_NIUTRANS,\n  OPT_TRANS_OPENAI,\n  OPT_TRANS_GEMINI,\n  OPT_TRANS_GEMINI_2,\n  OPT_TRANS_CLAUDE,\n  OPT_TRANS_CLOUDFLAREAI,\n  OPT_TRANS_OLLAMA,\n  OPT_TRANS_OPENROUTER,\n  OPT_TRANS_CUSTOMIZE,\n];\n\nexport const OPT_LANGDETECTOR_ALL = [\n  OPT_TRANS_BUILTINAI,\n  OPT_TRANS_GOOGLE,\n  OPT_TRANS_MICROSOFT,\n  OPT_TRANS_BAIDU,\n  OPT_TRANS_TENCENT,\n];\n\nexport const OPT_LANGDETECTOR_MAP = new Set(OPT_LANGDETECTOR_ALL);\n\n// 翻译引擎特殊集合\nexport const API_SPE_TYPES = {\n  // 内置翻译\n  builtin: new Set(OPT_ALL_TRANS_TYPES),\n  // 机器翻译\n  machine: new Set([\n    OPT_TRANS_MICROSOFT,\n    OPT_TRANS_DEEPLFREE,\n    OPT_TRANS_BAIDU,\n    OPT_TRANS_TENCENT,\n    OPT_TRANS_VOLCENGINE,\n  ]),\n  // AI翻译\n  ai: new Set([\n    OPT_TRANS_OPENAI,\n    OPT_TRANS_GEMINI,\n    OPT_TRANS_GEMINI_2,\n    OPT_TRANS_CLAUDE,\n    OPT_TRANS_OLLAMA,\n    OPT_TRANS_OPENROUTER,\n    OPT_TRANS_CUSTOMIZE,\n  ]),\n  // 支持多key\n  mulkeys: new Set([\n    OPT_TRANS_AZUREAI,\n    OPT_TRANS_DEEPL,\n    OPT_TRANS_OPENAI,\n    OPT_TRANS_GEMINI,\n    OPT_TRANS_GEMINI_2,\n    OPT_TRANS_CLAUDE,\n    OPT_TRANS_CLOUDFLAREAI,\n    OPT_TRANS_OLLAMA,\n    OPT_TRANS_OPENROUTER,\n    OPT_TRANS_NIUTRANS,\n    OPT_TRANS_CUSTOMIZE,\n  ]),\n  // 支持批处理\n  batch: new Set([\n    OPT_TRANS_AZUREAI,\n    OPT_TRANS_GOOGLE_2,\n    OPT_TRANS_MICROSOFT,\n    OPT_TRANS_TENCENT,\n    OPT_TRANS_DEEPL,\n    OPT_TRANS_OPENAI,\n    OPT_TRANS_GEMINI,\n    OPT_TRANS_GEMINI_2,\n    OPT_TRANS_CLAUDE,\n    OPT_TRANS_OLLAMA,\n    OPT_TRANS_OPENROUTER,\n    OPT_TRANS_CUSTOMIZE,\n  ]),\n  // 支持上下文\n  context: new Set([\n    OPT_TRANS_OPENAI,\n    OPT_TRANS_GEMINI,\n    OPT_TRANS_GEMINI_2,\n    OPT_TRANS_CLAUDE,\n    OPT_TRANS_OLLAMA,\n    OPT_TRANS_OPENROUTER,\n    OPT_TRANS_CUSTOMIZE,\n  ]),\n  // 支持流式传输\n  stream: new Set([\n    OPT_TRANS_OPENAI,\n    OPT_TRANS_GEMINI,\n    OPT_TRANS_GEMINI_2,\n    OPT_TRANS_CLAUDE,\n    OPT_TRANS_OLLAMA,\n    OPT_TRANS_OPENROUTER,\n  ]),\n};\n\nexport const BUILTIN_STONES = [\n  \"formal\", // 正式风格\n  \"casual\", // 口语风格\n  \"neutral\", // 中性风格\n  \"technical\", // 技术风格\n  \"marketing\", // 营销风格\n  \"Literary\", // 文学风格\n  \"academic\", // 学术风格\n  \"legal\", // 法律风格\n  \"literal\", // 直译风格\n  \"idiomatic\", // 意译风格\n  \"transcreation\", // 创译风格\n  \"machine-like\", // 机器风格\n  \"concise\", // 简明风格\n];\nexport const BUILTIN_PLACEHOLDERS = [\"{ }\", \"{{ }}\", \"[ ]\", \"[[ ]]\"];\nexport const BUILTIN_PLACETAGS = [\"i\", \"a\", \"b\", \"x\", \"span\"];\nexport const PLACETAG_FORMATS = [\"compact\", \"attribute\"]; // 占位符格式：简洁格式、属性格式\n\nexport const OPT_LANGS_TO = [\n  [\"en\", \"English - English\"],\n  [\"zh-CN\", \"Simplified Chinese - 简体中文\"],\n  [\"zh-TW\", \"Traditional Chinese - 繁體中文\"],\n  [\"ar\", \"Arabic - العربية\"],\n  [\"bg\", \"Bulgarian - Български\"],\n  [\"ca\", \"Catalan - Català\"],\n  [\"hr\", \"Croatian - Hrvatski\"],\n  [\"cs\", \"Czech - Čeština\"],\n  [\"da\", \"Danish - Dansk\"],\n  [\"nl\", \"Dutch - Nederlands\"],\n  [\"fa\", \"Persian - فارسی\"],\n  [\"fi\", \"Finnish - Suomi\"],\n  [\"fr\", \"French - Français\"],\n  [\"de\", \"German - Deutsch\"],\n  [\"el\", \"Greek - Ελληνικά\"],\n  [\"hi\", \"Hindi - हिन्दी\"],\n  [\"hu\", \"Hungarian - Magyar\"],\n  [\"id\", \"Indonesian - Indonesia\"],\n  [\"it\", \"Italian - Italiano\"],\n  [\"ja\", \"Japanese - 日本語\"],\n  [\"ko\", \"Korean - 한국어\"],\n  [\"ms\", \"Malay - Melayu\"],\n  [\"mt\", \"Maltese - Malti\"],\n  [\"nb\", \"Norwegian - Norsk Bokmål\"],\n  [\"pl\", \"Polish - Polski\"],\n  [\"pt\", \"Portuguese - Português\"],\n  [\"ro\", \"Romanian - Română\"],\n  [\"ru\", \"Russian - Русский\"],\n  [\"sk\", \"Slovak - Slovenčina\"],\n  [\"sl\", \"Slovenian - Slovenščina\"],\n  [\"es\", \"Spanish - Español\"],\n  [\"sv\", \"Swedish - Svenska\"],\n  [\"ta\", \"Tamil - தமிழ்\"],\n  [\"te\", \"Telugu - తెలుగు\"],\n  [\"th\", \"Thai - ไทย\"],\n  [\"tr\", \"Turkish - Türkçe\"],\n  [\"uk\", \"Ukrainian - Українська\"],\n  [\"vi\", \"Vietnamese - Tiếng Việt\"],\n];\nexport const OPT_LANGS_LIST = OPT_LANGS_TO.map(([lang]) => lang);\nexport const OPT_LANGS_FROM = [[\"auto\", \"Auto-detect\"], ...OPT_LANGS_TO];\nexport const OPT_LANGS_MAP = new Map(OPT_LANGS_TO);\n\n// CODE->名称\nexport const OPT_LANGS_SPEC_NAME = new Map(\n  OPT_LANGS_FROM.map(([key, val]) => [key, val.split(\" - \")[0]])\n);\nexport const OPT_LANGS_SPEC_DEFAULT = new Map(\n  OPT_LANGS_FROM.map(([key]) => [key, key])\n);\nexport const OPT_LANGS_SPEC_DEFAULT_UC = new Map(\n  OPT_LANGS_FROM.map(([key]) => [key, key.toUpperCase()])\n);\nexport const OPT_LANGS_TO_SPEC = {\n  [OPT_TRANS_BUILTINAI]: new Map([\n    ...OPT_LANGS_SPEC_DEFAULT,\n    [\"zh-CN\", \"zh\"],\n    [\"zh-TW\", \"zh\"],\n  ]),\n  [OPT_TRANS_GOOGLE]: OPT_LANGS_SPEC_DEFAULT,\n  [OPT_TRANS_GOOGLE_2]: OPT_LANGS_SPEC_DEFAULT,\n  [OPT_TRANS_MICROSOFT]: new Map([\n    ...OPT_LANGS_SPEC_DEFAULT,\n    [\"auto\", \"\"],\n    [\"zh-CN\", \"zh-Hans\"],\n    [\"zh-TW\", \"zh-Hant\"],\n  ]),\n  [OPT_TRANS_AZUREAI]: new Map([\n    ...OPT_LANGS_SPEC_DEFAULT,\n    [\"auto\", \"\"],\n    [\"zh-CN\", \"zh-Hans\"],\n    [\"zh-TW\", \"zh-Hant\"],\n  ]),\n  [OPT_TRANS_DEEPL]: new Map([\n    ...OPT_LANGS_SPEC_DEFAULT_UC,\n    [\"auto\", \"\"],\n    [\"zh-CN\", \"ZH\"],\n    [\"zh-TW\", \"ZH\"],\n  ]),\n  [OPT_TRANS_DEEPLFREE]: new Map([\n    ...OPT_LANGS_SPEC_DEFAULT_UC,\n    [\"auto\", \"auto\"],\n    [\"zh-CN\", \"ZH\"],\n    [\"zh-TW\", \"ZH\"],\n  ]),\n  [OPT_TRANS_DEEPLX]: new Map([\n    ...OPT_LANGS_SPEC_DEFAULT_UC,\n    [\"auto\", \"auto\"],\n    [\"zh-CN\", \"ZH\"],\n    [\"zh-TW\", \"ZH\"],\n  ]),\n  [OPT_TRANS_NIUTRANS]: new Map([\n    ...OPT_LANGS_SPEC_DEFAULT,\n    [\"auto\", \"auto\"],\n    [\"zh-CN\", \"zh\"],\n    [\"zh-TW\", \"cht\"],\n  ]),\n  [OPT_TRANS_VOLCENGINE]: new Map([\n    ...OPT_LANGS_SPEC_DEFAULT,\n    [\"auto\", \"auto\"],\n    [\"zh-CN\", \"zh\"],\n    [\"zh-TW\", \"zh-Hant\"],\n  ]),\n  [OPT_TRANS_BAIDU]: new Map([\n    ...OPT_LANGS_SPEC_DEFAULT,\n    [\"zh-CN\", \"zh\"],\n    [\"zh-TW\", \"cht\"],\n    [\"ar\", \"ara\"],\n    [\"bg\", \"bul\"],\n    [\"ca\", \"cat\"],\n    [\"hr\", \"hrv\"],\n    [\"da\", \"dan\"],\n    [\"fi\", \"fin\"],\n    [\"fr\", \"fra\"],\n    [\"hi\", \"mai\"],\n    [\"ja\", \"jp\"],\n    [\"ko\", \"kor\"],\n    [\"ms\", \"may\"],\n    [\"mt\", \"mlt\"],\n    [\"nb\", \"nor\"],\n    [\"ro\", \"rom\"],\n    [\"ru\", \"ru\"],\n    [\"sl\", \"slo\"],\n    [\"es\", \"spa\"],\n    [\"sv\", \"swe\"],\n    [\"ta\", \"tam\"],\n    [\"te\", \"tel\"],\n    [\"uk\", \"ukr\"],\n    [\"vi\", \"vie\"],\n  ]),\n  [OPT_TRANS_TENCENT]: new Map([\n    [\"auto\", \"auto\"],\n    [\"zh-CN\", \"zh\"],\n    [\"zh-TW\", \"zh\"],\n    [\"en\", \"en\"],\n    [\"ar\", \"ar\"],\n    [\"de\", \"de\"],\n    [\"ru\", \"ru\"],\n    [\"fr\", \"fr\"],\n    [\"fi\", \"fil\"],\n    [\"ko\", \"ko\"],\n    [\"ms\", \"ms\"],\n    [\"pt\", \"pt\"],\n    [\"ja\", \"ja\"],\n    [\"th\", \"th\"],\n    [\"tr\", \"tr\"],\n    [\"es\", \"es\"],\n    [\"it\", \"it\"],\n    [\"hi\", \"hi\"],\n    [\"id\", \"id\"],\n    [\"vi\", \"vi\"],\n  ]),\n  [OPT_TRANS_OPENAI]: OPT_LANGS_SPEC_NAME,\n  [OPT_TRANS_GEMINI]: OPT_LANGS_SPEC_NAME,\n  [OPT_TRANS_GEMINI_2]: OPT_LANGS_SPEC_NAME,\n  [OPT_TRANS_CLAUDE]: OPT_LANGS_SPEC_NAME,\n  [OPT_TRANS_OLLAMA]: OPT_LANGS_SPEC_NAME,\n  [OPT_TRANS_OPENROUTER]: OPT_LANGS_SPEC_NAME,\n  [OPT_TRANS_CLOUDFLAREAI]: new Map([\n    ...OPT_LANGS_SPEC_DEFAULT,\n    [\"auto\", \"en\"],\n    [\"zh-CN\", \"zh\"],\n    [\"zh-TW\", \"zh\"],\n  ]),\n  [OPT_TRANS_CUSTOMIZE]: OPT_LANGS_SPEC_NAME,\n};\n\nconst specToCode = (m) =>\n  new Map(\n    Array.from(m.entries()).map(([k, v]) => {\n      if (v === \"\") {\n        return [\"auto\", \"auto\"];\n      }\n      if (v === \"zh\" || v === \"ZH\") {\n        return [v, \"zh-CN\"];\n      }\n      return [v, k];\n    })\n  );\n\n// 名称->CODE\nexport const OPT_LANGS_TO_CODE = {};\nObject.entries(OPT_LANGS_TO_SPEC).forEach(([t, m]) => {\n  OPT_LANGS_TO_CODE[t] = specToCode(m);\n});\n\nexport const defaultNobatchPrompt = `You are a professional, authentic machine translation engine.`;\nexport const defaultNobatchUserPrompt = `# Context\nTitle: ${INPUT_PLACE_TITLE}\nDescription: ${INPUT_PLACE_DESCRIPTION}\nSummary: ${INPUT_PLACE_SUMMARY}\nTone: ${INPUT_PLACE_TONE}\n\n# Task\nTranslate the Source Text below to ${INPUT_PLACE_TO}.\n1. Use the Context to ensure accuracy.\n2. Adapt the wording to match the specified Tone.\n3. Output ONLY the translated text. No markdown, no explanations.\n\nSource Text: ${INPUT_PLACE_TEXT}\n\nTranslated Text:`;\n\nexport const defaultSystemPrompt = `Act as a translation API. Output a single raw JSON object only. No extra text or fences.\n\nInput:\n{\"targetLanguage\":\"<lang>\",\"title\":\"<context>\",\"description\":\"<context>\",\"summary\":\"<context>\",\"segments\":[{\"id\":1,\"text\":\"...\"}],\"glossary\":{\"sourceTerm\":\"targetTerm\"},\"tone\":\"<formal|casual>\"}\n\nOutput:\n{\"translations\":[{\"id\":1,\"text\":\"...\",\"sourceLanguage\":\"<detected>\"}]}\n\nRules:\n1.  Use title/description for context only; do not output them.\n2.  Keep id, order, and count of segments.\n3.  Preserve whitespace, HTML entities, and all HTML-like tags (e.g., <i1>, <a1>). Translate inner text only.\n4.  Highest priority: Follow 'glossary'. Use value for translation; if value is \"\", keep the key.\n5.  Do not translate: content in <code>, <pre>, text enclosed in backticks, or placeholders like {1}, {{1}}, [1], [[1]].\n6.  Apply the specified tone to the translation.\n7.  Detect sourceLanguage for each segment.\n8.  Return empty or unchanged inputs as is.\n\nExample:\nInput: {\"targetLanguage\":\"zh-CN\",\"segments\":[{\"id\":1,\"text\":\"A <b>React</b> component.\"}],\"glossary\":{\"component\":\"组件\",\"React\":\"\"}}\nOutput: {\"translations\":[{\"id\":1,\"text\":\"一个<b>React</b>组件\",\"sourceLanguage\":\"en\"}]}\n\nFail-safe: On any error, return {\"translations\":[]}.`;\n\nexport const defaultSystemPromptXml = `Act as a translation API. Output raw XML-like format only. No Markdown fences (xml). No conversational filler.\n\nInput:\n{\"targetLanguage\":\"<lang>\",\"title\":\"<context>\",\"description\":\"<context>\",\"summary\":\"<context>\",\"segments\":[{\"id\":1,\"text\":\"...\"}],\"glossary\":{\"sourceTerm\":\"targetTerm\"},\"tone\":\"<formal|casual>\"}\n\nOutput Format:\n<root>\n    <t id=\"0\" sourceLanguage=\"<detected_source_lang>\">Translated text content...</t>\n    <t id=\"1\" sourceLanguage=\"<detected_source_lang>\">Translated text content...</t>\n</root>\n\nRules:\n1.  **Strict Format**: Output ONLY the <root> element and its children. Do not include \"xml\" version declarations or markdown code blocks.\n2.  **Structure**: Maintain the exact \"id\" from the input in the \"id\" attribute. Detect the source language for the \"sourceLanguage\" attribute.\n3.  **HTML & Whitespace**: Preserve all HTML tags (e.g., <b>, <span>, <br>) and whitespace exactly as they appear in the structure. Only translate the text content inside them.\n4.  **Glossary**: Highest priority. Use the glossary value for translation. If the value is \"\", keep the source term as is.\n5.  **Do Not Translate**: Content inside <code>, <pre>, text in backticks (\"code\"), and placeholders like {1}, {{1}}, [1], [[1]].\n6.  **Context**: Use the \"title\" and \"description\" fields to understand the context for better translation accuracy, but do not output them.\n7.  **Tone**: Apply the specified \"tone\" (formal/casual).\n\nExample:\nInput:\n{\"targetLanguage\":\"zh-CN\",\"segments\":[{\"id\":0,\"text\":\"Hello <b>World</b>!\"}],\"glossary\":{\"World\":\"世界\"},\"tone\":\"formal\"}\n\nOutput:\n<root>\n    <t id=\"0\" sourceLanguage=\"en\">你好 <b>世界</b>！</t>\n</root>`;\n\nexport const defaultSystemPromptLines = `Act as a translation API. Output raw text lines in \"ID | Text\" format. No Markdown. No conversational filler.\n\nInput:\n{\"targetLanguage\":\"<lang>\",\"title\":\"<context>\",\"description\":\"<context>\",\"summary\":\"<context>\",\"segments\":[{\"id\":1,\"text\":\"...\"}],\"glossary\":{\"sourceTerm\":\"targetTerm\"},\"tone\":\"<formal|casual>\"}\n\nOutput Format:\n<id> | <Translation for Segment>\n<id> | <Translation for Segment>\n...\n\nRules:\n1.  **Strict Format**: Output exactly one line per segment using the format: \"{id} | {translated_text}\".\n2.  **ID Mapping**: You MUST copy the exact \"id\" from the input segment to the output line.\n3.  **Newline Handling**: If the translated text contains a newline, replace it with the HTML tag \"<br>\" to ensure it stays on a single line.\n4.  **Separator**: Use the pipe symbol \" | \" strictly to separate the ID and the text.\n5.  **Context**: Use title/description for context only; do not output them.\n6.  **HTML/Tags**: Preserve whitespace, HTML entities, and all HTML-like tags (e.g., <i1>, <b>). Translate inner text only.\n7.  **Glossary**: Highest priority. Follow 'glossary'. Use value for translation; if value is \"\", keep the key.\n8.  **Do Not Translate**: content in <code>, <pre>, text enclosed in backticks, or placeholders like {1}, {{1}}, [1].\n9.  **Tone**: Apply the specified tone.\n\nExample:\nInput: {\"targetLanguage\":\"zh-CN\",\"segments\":[{\"id\":0,\"text\":\"Hello.\"},{\"id\":1,\"text\":\"Line 1\\nLine 2\"}],\"glossary\":{}}\nOutput:\n0 | 你好。\n1 | 第一行<br>第二行\n\nFail-safe: On error, return \"{id} | {original_text}\" line by line.`;\n\n// const defaultSubtitlePrompt = `Goal: Convert raw subtitle event JSON into a clean, sentence-based JSON array.\n\n// Output (valid JSON array, output ONLY this array):\n// [{\n//   \"text\": \"string\",        // Full sentence with correct punctuation\n//   \"translation\": \"string\", // Translation in ${INPUT_PLACE_TO}\n//   \"start\": int,            // Start time (ms)\n//   \"end\": int,              // End time (ms)\n// }]\n\n// Guidelines:\n// 1. **Segmentation**: Merge sequential 'utf8' strings from 'segs' into full sentences, merging groups logically.\n// 2. **Punctuation**: Ensure proper sentence-final punctuation (., ?, !); add if missing.\n// 3. **Translation**: Translate 'text' into ${INPUT_PLACE_TO}, place result in 'translation'.\n// 4. **Special Cases**: '[Music]' (and similar cues) are standalone entries. Translate appropriately (e.g., '[音乐]', '[Musique]').\n// `;\n\nexport const defaultSubtitlePrompt = `# Context\nTitle: ${INPUT_PLACE_TITLE}\nDescription: ${INPUT_PLACE_DESCRIPTION}\nSummary: ${INPUT_PLACE_SUMMARY}\nTone: ${INPUT_PLACE_TONE}\n\n# Task\nConvert the input word-level timestamp JSON into a bilingual VTT file. Target Language: ${INPUT_PLACE_TO}.\n\n# Rules\n1. Merge words into complete sentences first.\n2. Split long sentences into readable cues (max 42 chars/line, natural pauses).\n3. Translate using the provided Context and Tone. Keep non-speech sounds (e.g., [Music]) as is.\n4. Convert timestamps to standard VTT format (MM:SS.mmm).\n5. Output ONLY the raw VTT content. No markdown, no notes.\n\n# VTT Format Example\nWEBVTT\n\n1000 --> 3500\nHello world!\n你好，世界！\n\n4000 --> 6000\nGood morning.\n早上好。`;\n\nconst defaultRequestHook = `async (args, { url, body, headers, userMsg, method } = {}) => {\n  console.log(\"request hook args:\", { args, url, body, headers, userMsg, method });\n  // return { url, body, headers, userMsg, method };\n};`;\n\nconst defaultResponseHook = `async ({ res, ...args }) => {\n  console.log(\"reaponse hook args:\", { res, args });\n  // const translations = [[\"你好\", \"zh\"]];\n  // const modelMsg = \"\";\n  // return { translations, modelMsg };\n};`;\n\n// 翻译接口默认参数\nconst defaultApi = {\n  apiSlug: \"\", // 唯一标识\n  apiName: \"\", // 接口名称\n  apiType: \"\", // 接口类型\n  url: \"\",\n  key: \"\",\n  model: \"\", // 模型名称\n  systemPrompt: defaultSystemPromptXml,\n  subtitlePrompt: defaultSubtitlePrompt,\n  nobatchPrompt: defaultNobatchPrompt,\n  nobatchUserPrompt: defaultNobatchUserPrompt,\n  userPrompt: \"\",\n  tone: BUILTIN_STONES[0], // 翻译风格\n  placeholder: BUILTIN_PLACEHOLDERS[0], // 占位符\n  placetag: BUILTIN_PLACETAGS[0], // 占位标签\n  // aiTerms: false, // AI智能专业术语 （todo: 备用）\n  customHeader: \"\",\n  customBody: \"\",\n  reqHook: \"\", // request 钩子函数\n  resHook: \"\", // response 钩子函数\n  fetchLimit: DEFAULT_FETCH_LIMIT, // 最大请求数量\n  fetchInterval: DEFAULT_FETCH_INTERVAL, // 请求间隔时间\n  httpTimeout: DEFAULT_HTTP_TIMEOUT * 3, // 请求超时时间\n  batchInterval: DEFAULT_BATCH_INTERVAL, // 批处理请求间隔时间\n  batchSize: DEFAULT_BATCH_SIZE, // 每次最多发送段落数量\n  batchLength: DEFAULT_BATCH_LENGTH, // 每次发送最大文字数量\n  useBatchFetch: false, // 是否启用聚合发送请求\n  useStream: false, // 是否启用流式传输\n  useContext: false, // 是否启用智能上下文\n  contextSize: DEFAULT_CONTEXT_SIZE, // 智能上下文保留会话数\n  temperature: 0.0,\n  maxTokens: 20480,\n  // think: false, // (OpenAI 兼容接口未支持，暂时移除)\n  // thinkIgnore: \"qwen3,deepseek-r1\", // (OpenAI 兼容接口未支持，暂时移除)\n  isDisabled: false, // 是否不显示,\n  region: \"\", // Azure 专用\n  sortOrder: 0, // 排序权重，数值越小越靠前\n  placetagFormat: \"compact\", // 占位符格式：compact(<a1>) 或 attribute(<a i=1>)\n};\n\nconst defaultApiOpts = {\n  [OPT_TRANS_BUILTINAI]: defaultApi,\n  [OPT_TRANS_GOOGLE]: {\n    ...defaultApi,\n    url: \"https://translate.googleapis.com/translate_a/single\",\n  },\n  [OPT_TRANS_GOOGLE_2]: {\n    ...defaultApi,\n    url: \"https://translate-pa.googleapis.com/v1/translateHtml\",\n    key: \"AIzaSyATBXajvzQLTDHEQbcpq0Ihe0vWDHmO520\",\n    useBatchFetch: true,\n    placetag: \"a\",\n    placetagFormat: \"attribute\",\n  },\n  [OPT_TRANS_MICROSOFT]: {\n    ...defaultApi,\n    useBatchFetch: true,\n  },\n  [OPT_TRANS_AZUREAI]: {\n    ...defaultApi,\n    url: \"https://api.cognitive.microsofttranslator.com/translate?api-version=3.0\",\n    useBatchFetch: true,\n  },\n  [OPT_TRANS_BAIDU]: {\n    ...defaultApi,\n  },\n  [OPT_TRANS_TENCENT]: {\n    ...defaultApi,\n    useBatchFetch: true,\n  },\n  [OPT_TRANS_VOLCENGINE]: {\n    ...defaultApi,\n  },\n  [OPT_TRANS_DEEPL]: {\n    ...defaultApi,\n    url: \"https://api-free.deepl.com/v2/translate\",\n    useBatchFetch: true,\n  },\n  [OPT_TRANS_DEEPLFREE]: {\n    ...defaultApi,\n    fetchLimit: 1,\n  },\n  [OPT_TRANS_DEEPLX]: {\n    ...defaultApi,\n    url: \"http://localhost:1188/translate\",\n  },\n  [OPT_TRANS_NIUTRANS]: {\n    ...defaultApi,\n    url: \"https://api.niutrans.com/NiuTransServer/translation\",\n    dictNo: \"\",\n    memoryNo: \"\",\n  },\n  [OPT_TRANS_OPENAI]: {\n    ...defaultApi,\n    url: \"https://api.openai.com/v1/chat/completions\",\n    model: \"gpt-4\",\n    useBatchFetch: true,\n  },\n  [OPT_TRANS_GEMINI]: {\n    ...defaultApi,\n    url: `https://generativelanguage.googleapis.com/v1/models/${INPUT_PLACE_MODEL}:generateContent`,\n    model: \"gemini-2.5-flash\",\n    useBatchFetch: true,\n  },\n  [OPT_TRANS_GEMINI_2]: {\n    ...defaultApi,\n    url: `https://generativelanguage.googleapis.com/v1beta/openai/chat/completions`,\n    model: \"gemini-2.0-flash\",\n    useBatchFetch: true,\n  },\n  [OPT_TRANS_CLAUDE]: {\n    ...defaultApi,\n    url: \"https://api.anthropic.com/v1/messages\",\n    model: \"claude-3-haiku-20240307\",\n    useBatchFetch: true,\n  },\n  [OPT_TRANS_CLOUDFLAREAI]: {\n    ...defaultApi,\n    url: \"https://api.cloudflare.com/client/v4/accounts/{{ACCOUNT_ID}}/ai/run/@cf/meta/m2m100-1.2b\",\n  },\n  [OPT_TRANS_OLLAMA]: {\n    ...defaultApi,\n    url: \"http://localhost:11434/v1/chat/completions\",\n    model: \"llama3.1\",\n    useBatchFetch: true,\n  },\n  [OPT_TRANS_OPENROUTER]: {\n    ...defaultApi,\n    url: \"https://openrouter.ai/api/v1/chat/completions\",\n    model: \"openai/gpt-4o\",\n    useBatchFetch: true,\n  },\n  [OPT_TRANS_CUSTOMIZE]: {\n    ...defaultApi,\n    reqHook: defaultRequestHook,\n    resHook: defaultResponseHook,\n  },\n};\n\n// 内置翻译接口列表（带参数）\nexport const DEFAULT_API_LIST = OPT_ALL_TRANS_TYPES.map((apiType) => ({\n  ...defaultApiOpts[apiType],\n  apiSlug: apiType,\n  apiName: apiType,\n  apiType,\n}));\n\nexport const DEFAULT_API_TYPE = OPT_TRANS_MICROSOFT;\nexport const DEFAULT_API_SETTING = DEFAULT_API_LIST.find(\n  (a) => a.apiType === DEFAULT_API_TYPE\n);\n"
  },
  {
    "path": "src/config/app.js",
    "content": "export const APP_NAME = process.env.REACT_APP_NAME.trim()\n  .split(/\\s+/)\n  .join(\"-\");\nexport const APP_LCNAME = APP_NAME.toLowerCase();\nexport const APP_UPNAME = APP_NAME.toUpperCase();\nexport const APP_CONSTS = {\n  fabID: `${APP_LCNAME}-fab`,\n  boxID: `${APP_LCNAME}-box`,\n  popupID: `${APP_LCNAME}-popup`,\n};\n\nexport const APP_VERSION = process.env.REACT_APP_VERSION.split(\".\");\n\nexport const THEME_LIGHT = \"light\";\nexport const THEME_DARK = \"dark\";\n"
  },
  {
    "path": "src/config/client.js",
    "content": "export const CLIENT_WEB = \"web\";\nexport const CLIENT_CHROME = \"chrome\";\nexport const CLIENT_EDGE = \"edge\";\nexport const CLIENT_FIREFOX = \"firefox\";\nexport const CLIENT_USERSCRIPT = \"userscript\";\nexport const CLIENT_THUNDERBIRD = \"thunderbird\";\nexport const CLIENT_EXTS = [\n  CLIENT_CHROME,\n  CLIENT_EDGE,\n  CLIENT_FIREFOX,\n  CLIENT_THUNDERBIRD,\n];\n\nexport const DEFAULT_USER_AGENT =\n  \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36\";\n"
  },
  {
    "path": "src/config/i18n.js",
    "content": "export const UI_LANGS = [\n  [\"en\", \"English\"],\n  [\"zh\", \"简体中文\"],\n  [\"zh_TW\", \"繁體中文\"],\n  [\"ja\", \"日本語\"],\n  [\"ko\", \"한국어\"],\n];\n\nconst customApiLangs = `[\"en\", \"English - English\"],\n[\"zh-CN\", \"Simplified Chinese - 简体中文\"],\n[\"zh-TW\", \"Traditional Chinese - 繁體中文\"],\n[\"ar\", \"Arabic - العربية\"],\n[\"bg\", \"Bulgarian - Български\"],\n[\"ca\", \"Catalan - Català\"],\n[\"hr\", \"Croatian - Hrvatski\"],\n[\"cs\", \"Czech - Čeština\"],\n[\"da\", \"Danish - Dansk\"],\n[\"nl\", \"Dutch - Nederlands\"],\n[\"fi\", \"Finnish - Suomi\"],\n[\"fr\", \"French - Français\"],\n[\"de\", \"German - Deutsch\"],\n[\"el\", \"Greek - Ελληνικά\"],\n[\"hi\", \"Hindi - हिन्दी\"],\n[\"hu\", \"Hungarian - Magyar\"],\n[\"id\", \"Indonesian - Indonesia\"],\n[\"it\", \"Italian - Italiano\"],\n[\"ja\", \"Japanese - 日本語\"],\n[\"ko\", \"Korean - 한국어\"],\n[\"ms\", \"Malay - Melayu\"],\n[\"mt\", \"Maltese - Malti\"],\n[\"nb\", \"Norwegian - Norsk Bokmål\"],\n[\"pl\", \"Polish - Polski\"],\n[\"pt\", \"Portuguese - Português\"],\n[\"ro\", \"Romanian - Română\"],\n[\"ru\", \"Russian - Русский\"],\n[\"sk\", \"Slovak - Slovenčina\"],\n[\"sl\", \"Slovenian - Slovenščina\"],\n[\"es\", \"Spanish - Español\"],\n[\"sv\", \"Swedish - Svenska\"],\n[\"ta\", \"Tamil - தமிழ்\"],\n[\"te\", \"Telugu - తెలుగు\"],\n[\"th\", \"Thai - ไทย\"],\n[\"tr\", \"Turkish - Türkçe\"],\n[\"uk\", \"Ukrainian - Українська\"],\n[\"vi\", \"Vietnamese - Tiếng Việt\"],\n`;\n\nconst customApiHelpZH = `// 请求数据默认格式\n{\n  \"url\": \"{{url}}\",\n  \"method\": \"POST\",\n  \"headers\": {\n    \"Content-type\": \"application/json\",\n    \"Authorization\": \"Bearer {{key}}\"\n  },\n  \"body\": {\n    \"text\": \"{{text}}\", // 待翻译文字\n    \"from\": \"{{from}}\", // 文字的语言（可能为空）\n    \"to\": \"{{to}}\",     // 目标语言\n  },\n}\n\n\n// 返回数据默认格式\n{\n  text: \"\", // 翻译后的文字\n  from: \"\", // 识别的源语言\n  to: \"\",   // 目标语言（可选）\n}\n\n\n// Hook 范例\n// URL\nhttps://translate.googleapis.com/translate_a/single?client=gtx&dj=1&dt=t&ie=UTF-8&q={{text}}&sl=en&tl=zh-CN\n\n// Request Hook\n(text, from, to, url, key) => [url, {\n  headers: {\n      \"Content-type\": \"application/json\",\n  },\n  method: \"GET\",\n  body: null,\n}]\n\n// Response Hook\n// 其中返回数组第一个值表示译文字符串，第二个值为布尔值，表示原文语言与目标语言是否相同\n(res, text, from, to) => [res.sentences.map((item) => item.trans).join(\" \"), to === res.src]\n\n\n// 支持的语言代码如下\n${customApiLangs}\n`;\n\nconst customApiHelpEN = `// Default request\n{\n  \"url\": \"{{url}}\",\n  \"method\": \"POST\",\n  \"headers\": {\n    \"Content-type\": \"application/json\",\n    \"Authorization\": \"Bearer {{key}}\"\n  },\n  \"body\": {\n    \"text\": \"{{text}}\", // Text to be translated\n    \"from\": \"{{from}}\", // The language of the text (may be empty)\n    \"to\": \"{{to}}\",     // Target language\n  },\n}\n\n\n// Default response\n{\n  text: \"\", // translated text\n  from: \"\", // Recognized source language\n  to: \"\",   // Target language (optional)\n}\n\n\n/// Hook Example\n// URL\nhttps://translate.googleapis.com/translate_a/single?client=gtx&dj=1&dt=t&ie=UTF-8&q={{text}}&sl=en&tl=zh-CN\n\n// Request Hook\n(text, from, to, url, key) => [url, {\n  headers: {\n      \"Content-type\": \"application/json\",\n  },\n  method: \"GET\",\n  body: null,\n}]\n\n// Response Hook\n// In the returned array, the first value is the translated string, while the second value is a boolean\n// that indicates whether the source language is the same as the target language.\n(res, text, from, to) => [res.sentences.map((item) => item.trans).join(\" \"), to === res.src]\n\n\n// The supported language codes are as follows\n${customApiLangs}\n`;\n\nconst requestHookHelperZH = `1、第一个参数包含如下字段：'texts', 'from', 'to', 'url', 'key', 'model', 'systemPrompt', ...\n2、返回值必须是包含以下字段的对象： 'url', 'body', 'headers', 'method'\n3、如返回空值，则hook函数不会产生任何效果。\n\n// 示例\nasync (args, { url, body, headers, userMsg, method } = {}) => {\n  return { url, body, headers, userMsg, method };\n}`;\n\nconst requestHookHelperEN = `1. The first parameter contains the following fields: 'texts', 'from', 'to', 'url', 'key', 'model', 'systemPrompt', ...\n2. The return value must be an object containing the following fields: 'url', 'body', 'headers', 'method'\n3. If a null value is returned, the hook function will have no effect.\n\n// Example\nasync (args, { url, body, headers, userMsg, method } = {}) => {\n  return { url, body, headers, userMsg, method };\n}`;\n\nconst responsetHookHelperZH = `1、第一个参数包含如下字段：'res', ...\n2、返回值必须是包含以下字段的对象： 'translations'\n  （'translations' 应为一个二维数组：[[译文, 原文语言]]）\n3、如返回空值，则hook函数不会产生任何效果。\n\n// 示例\nasync ({ res, ...args }) => {\n  const translations = [[\"你好\", \"en\"]];\n  const modelMsg = {}; // 用于AI上下文\n  return { translations, modelMsg };\n}`;\n\nconst responsetHookHelperEN = `1. The first parameter contains the following fields: 'res', ...\n2. The return value must be an object containing the following fields: 'translations'\n  ('translations' should be a two-dimensional array: [[translation, source language]]).\n3. If a null value is returned, the hook function will have no effect.\n\n// Example\nasync ({ res, ...args }) => {\n  const translations = [[\"你好\", \"en\"]];\n  const modelMsg = {}; // For AI context\n  return { translations, modelMsg };\n}`;\n\nexport const I18N = {\n  app_name: {\n    zh: `简约翻译`,\n    en: `KISS Translator`,\n    zh_TW: `簡約翻譯`,\n    ja: `KISS Translator`,\n    ko: `KISS Translator`,\n  },\n  translate: {\n    zh: `翻译`,\n    en: `Translate`,\n    zh_TW: `翻譯`,\n    ja: `翻訳`,\n    ko: `번역`,\n  },\n  custom_api_help: {\n    zh: customApiHelpZH,\n    en: customApiHelpEN,\n    zh_TW: customApiHelpZH,\n    ja: customApiHelpEN,\n    ko: customApiHelpEN,\n  },\n  request_hook_helper: {\n    zh: requestHookHelperZH,\n    en: requestHookHelperEN,\n    zh_TW: requestHookHelperZH,\n    ja: requestHookHelperEN,\n    ko: requestHookHelperEN,\n  },\n  response_hook_helper: {\n    zh: responsetHookHelperZH,\n    en: responsetHookHelperEN,\n    zh_TW: responsetHookHelperZH,\n    ja: responsetHookHelperEN,\n    ko: responsetHookHelperEN,\n  },\n  translate_alt: {\n    zh: `翻译`,\n    en: `Translate`,\n    zh_TW: `翻譯`,\n    ja: `翻訳`,\n    ko: `번역`,\n  },\n  basic_setting: {\n    zh: `基本设置`,\n    en: `Basic Setting`,\n    zh_TW: `基本設定`,\n    ja: `基本設定`,\n    ko: `기본 설정`,\n  },\n  rules_setting: {\n    zh: `规则设置`,\n    en: `Rules Setting`,\n    zh_TW: `規則設定`,\n    ja: `ルール設定`,\n    ko: `규칙 설정`,\n  },\n  apis_setting: {\n    zh: `接口设置`,\n    en: `Apis Setting`,\n    zh_TW: `API設定`,\n    ja: `API設定`,\n    ko: `API 설정`,\n  },\n  sync_setting: {\n    zh: `同步设置`,\n    en: `Sync Setting`,\n    zh_TW: `同步設定`,\n    ja: `同期設定`,\n    ko: `동기화 설정`,\n  },\n  patch_setting: {\n    zh: `补丁设置`,\n    en: `Patch Setting`,\n    zh_TW: `修補設定`,\n    ja: `パッチ設定`,\n    ko: `패치 설정`,\n  },\n  patch_setting_help: {\n    zh: `针对一些特殊网站的修正脚本，以便翻译软件得到更好的展示效果。`,\n    en: `Corrected scripts for some special websites so that the translation software can get better display results.`,\n    zh_TW: `針對某些特殊網站的修正腳本，讓翻譯軟體有更好的顯示效果。`,\n    ja: `一部の特殊なウェブサイト用の修正スクリプトで、翻訳ソフトウェアの表示効果を向上させます。`,\n    ko: `일부 특수 웹사이트를 위한 수정 스크립트로, 번역 소프트웨어의 표시 효과를 개선합니다.`,\n  },\n  inject_webfix: {\n    zh: `注入修复补丁`,\n    en: `Inject Webfix`,\n    zh_TW: `注入修正補丁`,\n    ja: `Webfixを注入`,\n    ko: `웹 수정 패치 주입`,\n  },\n  about: {\n    zh: `关于`,\n    en: `About`,\n    zh_TW: `關於`,\n    ja: `概要`,\n    ko: `정보`,\n  },\n  about_md: {\n    zh: `README.md`,\n    en: `README.en.md`,\n    zh_TW: `README.md`,\n    ja: `README.ja.md`, // 假设的文件名\n    ko: `README.ko.md`, // 假设的文件名\n  },\n  about_md_local: {\n    zh: `请 [点击这里](${process.env.REACT_APP_HOMEPAGE}) 查看详情。`,\n    en: `Please [click here](${process.env.REACT_APP_HOMEPAGE}) for details.`,\n    zh_TW: `請 [點這裡](${process.env.REACT_APP_HOMEPAGE}) 查看詳細內容。`,\n    ja: `詳細は [こちら](${process.env.REACT_APP_HOMEPAGE}) をクリックしてください。`,\n    ko: `자세한 내용은 [여기](${process.env.REACT_APP_HOMEPAGE})를 클릭하세요.`,\n  },\n  ui_lang: {\n    zh: `界面语言`,\n    en: `Interface Language`,\n    zh_TW: `介面語言`,\n    ja: `インターフェース言語`,\n    ko: `인터페이스 언어`,\n  },\n  fetch_limit: {\n    zh: `最大并发请求数量 (1-100)`,\n    en: `Maximum Number Of Concurrent Requests (1-100)`,\n    zh_TW: `最大同時請求數量 (1-100)`,\n    ja: `最大同時リクエスト数 (1-100)`,\n    ko: `최대 동시 요청 수 (1-100)`,\n  },\n  if_think: {\n    zh: `启用或禁用模型的深度思考能力`,\n    en: `Enable or disable the model’s thinking behavior `,\n    zh_TW: `啟用或停用模型的深度思考能力`,\n    ja: `モデルの思考行動を有効または無効にする`,\n    ko: `모델의 사고 행동 활성화 또는 비활성화`,\n  },\n  think: {\n    zh: `启用深度思考`,\n    en: `enable thinking`,\n    zh_TW: `啟用深度思考`,\n    ja: `思考を有効にする`,\n    ko: `사고 활성화`,\n  },\n  nothink: {\n    zh: `禁用深度思考`,\n    en: `disable thinking`,\n    zh_TW: `停用深度思考`,\n    ja: `思考を無効にする`,\n    ko: `사고 비활성화`,\n  },\n  think_ignore: {\n    zh: `忽略以下模型的<think>输出,逗号(,)分割,当模型支持思考但ollama不支持时需要填写本参数`,\n    en: `Ignore the <think> block for the following models, comma (,) separated`,\n    zh_TW: `忽略以下模型的 <think> 輸出，以逗號 (,) 分隔；當模型支援思考但 ollama 不支援時需要填寫此參數`,\n    ja: `以下のモデルの<think>出力を無視する (コンマ(,)区切り)。モデルが思考をサポートしているが、ollamaがサポートしていない場合に記入が必要です`,\n    ko: `다음 모델의 <think> 블록 무시 (쉼표(,)로 구분), 모델이 사고를 지원하지만 ollama가 지원하지 않는 경우 이 매개변수를 입력해야 합니다`,\n  },\n  fetch_interval: {\n    zh: `每次请求间隔时间 (0-5000ms)`,\n    en: `Time Between Requests (0-5000ms)`,\n    zh_TW: `每次請求間隔時間 (0-5000ms)`,\n    ja: `リクエスト間隔 (0-5000ms)`,\n    ko: `요청 간 시간 (0-5000ms)`,\n  },\n  translate_interval: {\n    zh: `翻译间隔时间 (1-2000ms)`,\n    en: `Translation Interval (1-2000ms)`,\n    zh_TW: `翻譯間隔時間 (1-2000ms)`,\n    ja: `翻訳間隔 (1-2000ms)`,\n    ko: `번역 간격 (1-2000ms)`,\n  },\n  http_timeout: {\n    zh: `请求超时时间 (100-6000000ms)`,\n    en: `Request Timeout Time (100-6000000ms)`,\n    zh_TW: `請求逾時時間 (100-60000ms)`,\n    ja: `リクエストタイムアウト (100-6000000ms)`,\n    ko: `요청 시간 초과 (100-6000000ms)`,\n  },\n  custom_header: {\n    zh: `自定义Header参数`,\n    en: `Custom Header Params`,\n    zh_TW: `自訂 Header 參數`,\n    ja: `カスタムヘッダー`,\n    ko: `사용자 지정 헤더`,\n  },\n  custom_header_help: {\n    zh: `使用JSON格式，例如 \"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0\"`,\n    en: `Use JSON format, for example \"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0\"`,\n    zh_TW: `使用JSON格式，例如 \"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0\"`,\n    ja: `JSON形式を使用してください。例: \"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0\"`,\n    ko: `JSON 형식을 사용하세요. 예: \"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0\"`,\n  },\n  custom_body: {\n    zh: `自定义Body参数`,\n    en: `Custom Body Params`,\n    zh_TW: `自訂 Body 參數`,\n    ja: `カスタムボディ`,\n    ko: `사용자 지정 바디`,\n  },\n  custom_body_help: {\n    zh: `使用JSON格式，例如 \"top_p\": 0.7`,\n    en: `Use JSON format, for example \"top_p\": 0.7`,\n    zh_TW: `使用JSON格式，例如 \"top_p\": 0.7`,\n    ja: `JSON形式を使用してください。例: \"top_p\": 0.7`,\n    ko: `JSON 형식을 사용하세요. 예: \"top_p\": 0.7`,\n  },\n  min_translate_length: {\n    zh: `最小翻译字符数 (1-100)`,\n    en: `Minimum number Of Translated Characters (1-100)`,\n    zh_TW: `最小翻譯字元數 (1-100)`,\n    ja: `最小翻訳文字数 (1-100)`,\n    ko: `최소 번역 문자 수 (1-100)`,\n  },\n  max_translate_length: {\n    zh: `最大翻译字符数 (100-100000)`,\n    en: `Maximum number Of Translated Characters (100-100000)`,\n    zh_TW: `最大翻譯字元數 (100-100000)`,\n    ja: `最大翻訳文字数 (100-100000)`,\n    ko: `최대 번역 문자 수 (100-100000)`,\n  },\n  num_of_newline_characters: {\n    zh: `换行字符数 (1-1000)`,\n    en: `Number of Newline Characters (1-1000)`,\n    zh_TW: `換行字元數 (1-1000)`,\n    ja: `改行文字数 (1-1000)`,\n    ko: `줄바꿈 문자 수 (1-1000)`,\n  },\n  translate_service: {\n    zh: `翻译服务`,\n    en: `Translate Service`,\n    zh_TW: `翻譯服務`,\n    ja: `翻訳サービス`,\n    ko: `번역 서비스`,\n  },\n  translate_service_multiple: {\n    zh: `翻译服务 (支持多选)`,\n    en: `Translation service (multiple supported)`,\n    zh_TW: `翻譯服務 (支援多選)`,\n    ja: `翻訳サービス (複数選択可)`,\n    ko: `번역 서비스 (다중 선택 지원)`,\n  },\n  translate_timing: {\n    zh: `翻译时机`,\n    en: `Translate Timing`,\n    zh_TW: `翻譯時機`,\n    ja: `翻訳タイミング`,\n    ko: `번역 시점`,\n  },\n  mk_pagescroll: {\n    zh: `滚动加载翻译（推荐）`,\n    en: `Rolling Loading (Suggested)`,\n    zh_TW: `滾動載入翻譯（建議）`,\n    ja: `スクロール翻訳 (推奨)`,\n    ko: `스크롤 번역 (권장)`,\n  },\n  mk_pageopen: {\n    zh: `立即全部翻译`,\n    en: `Translate all now`,\n    zh_TW: `立即全部翻譯`,\n    ja: `すぐにすべて翻訳`,\n    ko: `즉시 모두 번역`,\n  },\n  mk_mouseover: {\n    zh: `鼠标悬停翻译`,\n    en: `Mouseover`,\n    zh_TW: `滑鼠懸停翻譯`,\n    ja: `マウスオーバー翻訳`,\n    ko: `마우스오버 번역`,\n  },\n  mk_ctrlKey: {\n    zh: `Control + 鼠标悬停`,\n    en: `Control + Mouseover`,\n    zh_TW: `Control + 滑鼠懸停`,\n    ja: `Control + マウスオーバー`,\n    ko: `Control + 마우스오버`,\n  },\n  mk_shiftKey: {\n    zh: `Shift + 鼠标悬停`,\n    en: `Shift + Mouseover`,\n    zh_TW: `Shift + 滑鼠懸停`,\n    ja: `Shift + マウスオーバー`,\n    ko: `Shift + 마우스오버`,\n  },\n  mk_altKey: {\n    zh: `Alt + 鼠标悬停`,\n    en: `Alt + Mouseover`,\n    zh_TW: `Alt + 滑鼠懸停`,\n    ja: `Alt + マウスオーバー`,\n    ko: `Alt + 마우스오버`,\n  },\n  from_lang: {\n    zh: `原文语言`,\n    en: `Source Language`,\n    zh_TW: `原文語言`,\n    ja: `原文の言語`,\n    ko: `원본 언어`,\n  },\n  to_lang: {\n    zh: `目标语言`,\n    en: `Target Language`,\n    zh_TW: `目標語言`,\n    ja: `翻訳先の言語`,\n    ko: `대상 언어`,\n  },\n  to_lang2: {\n    zh: `第二目标语言`,\n    en: `Target Language 2`,\n    zh_TW: `第二目標語言`,\n    ja: `第二翻訳先の言語`,\n    ko: `두 번째 대상 언어`,\n  },\n  to_lang2_helper: {\n    zh: `设定后，与目标语言产生互译效果，但依赖远程语言识别。`,\n    en: `After setting, it will produce mutual translation effect with the target language, but it relies on remote language recognition.`,\n    zh_TW: `設定後會與目標語言互譯，但依賴遠端語言識別。`,\n    ja: `設定後、ターゲット言語との相互翻訳が可能になりますが、リモート言語認識に依存します。`,\n    ko: `설정 후, 대상 언어와 상호 번역 효과가 발생하지만, 원격 언어 인식에 의존합니다.`,\n  },\n  text_style: {\n    zh: `译文样式`,\n    en: `Text Style`,\n    zh_TW: `譯文樣式`,\n    ja: `翻訳テキストスタイル`,\n    ko: `번역 텍스트 스타일`,\n  },\n  text_style_alt: {\n    zh: `译文样式`,\n    en: `Text Style`,\n    zh_TW: `譯文樣式`,\n    ja: `翻訳テキストスタイル`,\n    ko: `번역 텍스트 스타일`,\n  },\n  bg_color: {\n    zh: `样式颜色`,\n    en: `Style Color`,\n    zh_TW: `樣式顏色`,\n    ja: `スタイルカラー`,\n    ko: `스타일 색상`,\n  },\n  remain_unchanged: {\n    zh: `保留不变`,\n    en: `Remain Unchanged`,\n    zh_TW: `保留不變`,\n    ja: `変更しない`,\n    ko: `변경하지 않음`,\n  },\n  google_api: {\n    zh: `谷歌翻译接口`,\n    en: `Google Translate API`,\n    zh_TW: `Google 翻譯介面`,\n    ja: `Google 翻訳 API`,\n    ko: `Google 번역 API`,\n  },\n  default_selector: {\n    zh: `默认选择器`,\n    en: `Default selector`,\n    zh_TW: `預設選擇器`,\n    ja: `デフォルトセレクタ`,\n    ko: `기본 선택자`,\n  },\n  selector_rules: {\n    zh: `选择器规则`,\n    en: `Selector Rules`,\n    zh_TW: `選擇器規則`,\n    ja: `セレクタールール`,\n    ko: `선택자 규칙`,\n  },\n  save: {\n    zh: `保存`,\n    en: `Save`,\n    zh_TW: `儲存`,\n    ja: `保存`,\n    ko: `저장`,\n  },\n  edit: {\n    zh: `编辑`,\n    en: `Edit`,\n    zh_TW: `編輯`,\n    ja: `編集`,\n    ko: `수정`,\n  },\n  cancel: {\n    zh: `取消`,\n    en: `Cancel`,\n    zh_TW: `取消`,\n    ja: `キャンセル`,\n    ko: `취소`,\n  },\n  delete: {\n    zh: `删除`,\n    en: `Delete`,\n    zh_TW: `刪除`,\n    ja: `削除`,\n    ko: `삭제`,\n  },\n  reset: {\n    zh: `重置`,\n    en: `Reset`,\n    zh_TW: `重設`,\n    ja: `リセット`,\n    ko: `초기화`,\n  },\n  add: {\n    zh: `添加`,\n    en: `Add`,\n    zh_TW: `新增`,\n    ja: `追加`,\n    ko: `추가`,\n  },\n  copy_api: {\n    zh: `复制接口`,\n    en: `Copy Interface`,\n    zh_TW: `複製介面`,\n    ja: `インターフェースをコピー`,\n    ko: `인터페이스 복사`,\n  },\n  inject_rules: {\n    zh: `注入订阅规则`,\n    en: `Inject Subscribe Rules`,\n    zh_TW: `注入訂閱規則`,\n    ja: `購読ルールを注入`,\n    ko: `구독 규칙 주입`,\n  },\n  personal_rules: {\n    zh: `个人规则`,\n    en: `Rules`,\n    zh_TW: `個人規則`,\n    ja: `個人ルール`,\n    ko: `개인 규칙`,\n  },\n  subscribe_rules: {\n    zh: `订阅规则`,\n    en: `Subscribe`,\n    zh_TW: `訂閱規則`,\n    ja: `購読ルール`,\n    ko: `구독 규칙`,\n  },\n  overwrite_subscribe_rules: {\n    zh: `覆写订阅规则`,\n    en: `Overwrite`,\n    zh_TW: `覆寫訂閱規則`,\n    ja: `購読ルールを上書き`,\n    ko: `구독 규칙 덮어쓰기`,\n  },\n  subscribe_url: {\n    zh: `订阅地址`,\n    en: `Subscribe URL`,\n    zh_TW: `訂閱網址`,\n    ja: `購読URL`,\n    ko: `구독 URL`,\n  },\n  rules_warn_1: {\n    zh: `1、规则生效的优先级依次为：个人规则 > 订阅规则 > 全局规则。\"全局规则\"相当于兜底规则。`,\n    en: `1. The priority of rules is: personal rules > subscription rules > global rules. \"Global rules\" are like a fallback rule.`,\n    zh_TW: `1.規則生效的優先順序依序為：個人規則 > 訂閱規則 > 全域規則。 \"全域規則\"相當於兜底規則。`,\n    ja: `1. ルールの優先順位: 個人ルール > 購読ルール > グローバルルール。「グローバルルール」はフォールバックルールのようなものです。`,\n    ko: `1. 규칙 우선순위: 개인 규칙 > 구독 규칙 > 전역 규칙. \"전역 규칙\"은 일종의 폴백(fallback) 규칙입니다.`,\n  },\n  rules_warn_2: {\n    zh: `2、“订阅规则”选择注入后才会生效。`,\n    en: `2. \"Subscription rules\" will take effect only after injection is selected.`,\n    zh_TW: `2、「訂閱規則」選擇注入後才會生效。`,\n    ja: `2. 「購読ルール」は注入を選択した後にのみ有効になります。`,\n    ko: `2. \"구독 규칙\"은 주입을 선택한 후에만 적용됩니다.`,\n  },\n  rules_warn_3: {\n    zh: `3、关于规则填写：输入框留空或下拉框选“*”表示采用全局规则。CSS选择器支持 + 号前缀表示在全局规则基础上追加，- 号表示剔除。`,\n    en: `3. Regarding filling in the rules: Leave the input box blank or select \"*\" in the drop-down box to use global rule. CSS selectors support prefixes: \"+\" means add to the global rules, \"-\" means exclude.`,\n    zh_TW: `3. 規則填寫說明：輸入框留空或下拉選擇「*」表示使用全域規則。CSS 選擇器支援使用前綴：「+」表示在全域規則基礎上追加，「-」表示剔除。`,\n    ja: `3. ルールの記入について: 入力ボックスを空白にするか、ドロップダウンで「*」を選択すると、グローバルルールが使用されます。CSS セレクターはプレフィックスに対応しています。「+」はグローバルルールへの追加、「-」は除外を意味します。`,\n    ko: `3. 규칙 작성 관련: 입력란을 비워두거나 드롭다운에서 \"*\"를 선택하면 전역 규칙이 사용됩니다. CSS 선택자는 접두사를 지원합니다. \"+\"는 전역 규칙에 추가, \"-\"는 제외를 의미합니다.`,\n  },\n  sync_warn: {\n    zh: `涉及隐私数据的同步请谨慎选择第三方同步服务，建议自行搭建 kiss-worker 或 WebDAV 服务。`,\n    en: `When synchronizing data that involves privacy, please be cautious about choosing third-party sync services. It is recommended to set up your own sync service using kiss-worker or WebDAV.`,\n    zh_TW: `同步涉及隱私資料時，請謹慎選擇第三方同步服務；建議自建 kiss-worker 或 WebDAV 服務。`,\n    ja: `プライバシーに関わるデータを同期する場合、サードパーティの同期サービスは慎重に選択してください。kiss-worker や WebDAV サービスを自己ホスティングすることをお勧めします。`,\n    ko: `개인정보가 포함된 데이터를 동기화할 경우, 타사 동기화 서비스 선택에 신중을 기하십시오. 자체 kiss-worker 또는 WebDAV 서비스를 구축하는 것을 권장합니다.`,\n  },\n  sync_warn_2: {\n    zh: `如果服务器存在其他客户端同步的数据，第一次同步将直接覆盖本地配置，后面则根据修改时间，新的覆盖旧的。`,\n    en: `If the server has data synchronized by other clients, the first synchronization will directly overwrite the local configuration, and later, according to the modification time, the new one will overwrite the old one.`,\n    zh_TW: `若伺服器上存在其他用戶端同步的資料，第一次同步會直接覆蓋本機設定；之後則依修改時間，由新的覆蓋舊的。`,\n    ja: `サーバーに他のクライアントによって同期されたデータがある場合、最初の同期はローカル設定を直接上書きし、その後は変更時間に応じて新しいものが古いものを上書きします。`,\n    ko: `서버에 다른 클라이언트가 동기화한 데이터가 있는 경우, 첫 번째 동기화는 로컬 구성을 직접 덮어쓰며, 이후에는 수정 시간에 따라 새 항목이 기존 항목을 덮어씁니다.`,\n  },\n  about_sync_api: {\n    zh: `自建kiss-wroker数据同步服务`,\n    en: `Self-hosting a Kiss-worker data sync service`,\n    zh_TW: `自建 kiss-wroker 資料同步服務`,\n    ja: `Kiss-worker データ同期サービスをセルフホストする`,\n    ko: `Kiss-worker 데이터 동기화 서비스 자체 호스팅`,\n  },\n  about_api: {\n    zh: `1、其中 BuiltinAI 为浏览器内置AI翻译，目前仅 Chrome 138 及以上版本得到支持。`,\n    en: `1. BuiltinAI is the browser's built-in AI translation, which is currently only supported by Chrome 138 and above.`,\n    zh_TW: `1.其中 BuiltinAI 為瀏覽器內建AI翻譯，目前僅 Chrome 138 以上版本支援。`,\n    ja: `1. BuiltinAI はブラウザ内蔵のAI翻訳で、現在 Chrome 138 以降のバージョンでのみサポートされています。`,\n    ko: `1. BuiltinAI는 브라우저 내장 AI 번역으로, 현재 Chrome 138 이상 버전에서만 지원됩니다.`,\n  },\n  about_api_2: {\n    zh: `2、大部分AI接口都与OpenAI兼容，因此选择OpenAI类型即可。“是否聚合发送翻译请求”所对应的 Prompt 并不相同，并且不是所有接口都支持聚合翻译。`,\n    en: `2. Most AI interfaces are compatible with OpenAI, so you can simply select the OpenAI type. The prompts corresponding to “Whether to aggregate translation requests” are different, and not all interfaces support aggregated translation.`,\n    zh_TW: `2. 大部分的 AI 介面都與 OpenAI 相容，因此選擇 OpenAI 類型即可。「是否聚合發送翻譯請求」所對應的 Prompt 並不相同，並且不是所有介面都支援聚合翻譯。`,\n    ja: `2. ほとんどの AI インターフェースは OpenAI と互換性があるため、OpenAI タイプを選択すれば問題ありません。「翻訳リクエストをまとめて送信するかどうか」に対応するプロンプトは異なり、すべてのインターフェースが集約翻訳をサポートしているわけではありません。`,\n    ko: `2. 대부분의 AI 인터페이스는 OpenAI와 호환되므로 OpenAI 유형을 선택하면 됩니다. “번역 요청을 집합적으로 보낼지 여부”에 대응하는 프롬프트는 서로 다르며, 모든 인터페이스가 집합 번역을 지원하는 것은 아닙니다.`,\n  },\n  about_api_3: {\n    zh: `3、理论上，所有翻译接口，都可以通过自定义接口 (Custom) 的形式使用。`,\n    en: `3. In theory, all translation interfaces can be used by configuring them as a custom interface.`,\n    zh_TW: `3. 理論上，所有翻譯介面都可以透過自訂介面（Custom）的方式來使用。`,\n    ja: `3. 理論的には、すべての翻訳インターフェースはカスタム（Custom）インターフェースとして設定することで利用できます。`,\n    ko: `3. 이론적으로 모든 번역 인터페이스는 커스텀(Custom) 인터페이스로 설정하여 사용할 수 있습니다.`,\n  },\n  about_api_proxy: {\n    zh: `查看自建一个翻译接口代理`,\n    en: `Check out the self-built translation interface proxy`,\n    zh_TW: `查看如何自建翻譯介面 Proxy`,\n    ja: `自作の翻訳インターフェースプロキシをチェックする`,\n    ko: `자체 구축 번역 인터페이스 프록시 확인하기`,\n  },\n  setting_helper: {\n    zh: `新旧配置并不兼容，导出的旧版配置，勿再次导入。`,\n    en: `The old and new configurations are not compatible. Do not import the exported old configuration again.`,\n    zh_TW: `新舊配置並不相容，匯出的舊版配置，勿再次匯入。`,\n    ja: `新旧の設定に互換性はありません。エクスポートした古い設定を再度インポートしないでください。`,\n    ko: `이전 구성과 새 구성은 호환되지 않습니다. 내보낸 이전 구성을 다시 가져오지 마십시오.`,\n  },\n  style_none: {\n    zh: `无`,\n    en: `None`,\n    zh_TW: `無`,\n    ja: `なし`,\n    ko: `없음`,\n  },\n  under_line: {\n    zh: `下划直线`,\n    en: `Underline`,\n    zh_TW: `下劃直線`,\n    ja: `下線`,\n    ko: `밑줄`,\n  },\n  dot_line: {\n    zh: `下划点状线`,\n    en: `Dotted Underline`,\n    zh_TW: `下劃點狀線`,\n    ja: `点線の下線`,\n    ko: `점선 밑줄`,\n  },\n  dash_line: {\n    zh: `下划虚线`,\n    en: `Dashed Underline`,\n    zh_TW: `下劃虛線`,\n    ja: `破線の下線`,\n    ko: `파선 밑줄`,\n  },\n  dash_box: {\n    zh: `虚线框`,\n    en: `Dashed Box`,\n    zh_TW: `虛線框`,\n    ja: `破線ボックス`,\n    ko: `파선 상자`,\n  },\n  dash_line_bold: {\n    zh: `下划虚线加粗`,\n    en: `Dashed Underline Bold`,\n    zh_TW: `下劃虛線`,\n    ja: `破線の下線 (太字)`,\n    ko: `굵은 파선 밑줄`,\n  },\n  dash_box_bold: {\n    zh: `虚线框加粗`,\n    en: `Dashed Box Bold`,\n    zh_TW: `虛線框加粗`,\n    ja: `破線ボックス (太字)`,\n    ko: `굵은 파선 상자`,\n  },\n  marker: {\n    zh: `马克笔`,\n    en: `Marker`,\n    zh_TW: `馬克筆`,\n    ja: `マーカー`,\n    ko: `마커`,\n  },\n  gradient_marker: {\n    zh: `渐变马克笔`,\n    en: `Gradient Marker`,\n    zh_TW: `漸層馬克筆`,\n    ja: `グラデーションマーカー`,\n    ko: `그라데이션 마커`,\n  },\n  wavy_line: {\n    zh: `下划波浪线`,\n    en: `Wavy Underline`,\n    zh_TW: `下劃波浪線`,\n    ja: `波線の下線`,\n    ko: `물결 밑줄`,\n  },\n  wavy_line_bold: {\n    zh: `下划波浪线加粗`,\n    en: `Wavy Underline Bold`,\n    zh_TW: `下劃波浪線加粗`,\n    ja: `波線の下線 (太字)`,\n    ko: `굵은 물결 밑줄`,\n  },\n  fuzzy: {\n    zh: `模糊`,\n    en: `Fuzzy`,\n    zh_TW: `模糊`,\n    ja: `ぼかし`,\n    ko: `흐림`,\n  },\n  highlight: {\n    zh: `高亮`,\n    en: `Highlight`,\n    zh_TW: `反白標示`,\n    ja: `ハイライト`,\n    ko: `하이라이트`,\n  },\n  blockquote: {\n    zh: `引用`,\n    en: `Blockquote`,\n    zh_TW: `引用`,\n    ja: `引用`,\n    ko: `인용`,\n  },\n  gradient: {\n    zh: `渐变`,\n    en: `Gradient`,\n    zh_TW: `漸變`,\n    ja: `グラデーション`,\n    ko: `그라데이션`,\n  },\n  blink: {\n    zh: `闪现`,\n    en: `Blink`,\n    zh_TW: `閃現`,\n    ja: `点滅`,\n    ko: `깜박임`,\n  },\n  glow: {\n    zh: `发光`,\n    en: `Glow`,\n    zh_TW: `發光`,\n    ja: `発光`,\n    ko: `발광`,\n  },\n  colorful: {\n    zh: `多彩`,\n    en: `Colorful`,\n    zh_TW: `多彩`,\n    ja: `カラフル`,\n    ko: `다채롭게`,\n  },\n  setting: {\n    zh: `设置`,\n    en: `Setting`,\n    zh_TW: `設定`,\n    ja: `設定`,\n    ko: `설정`,\n  },\n  pattern: {\n    zh: `匹配网址`,\n    en: `URL pattern`,\n    zh_TW: `匹配網址`,\n    ja: `URLパターン`,\n    ko: `URL 패턴`,\n  },\n  pattern_helper: {\n    zh: `1、支持星号(*)通配符。2、多个URL用换行或英文逗号“,”分隔。`,\n    en: `1. Supports the asterisk (*) wildcard character. 2. Separate multiple URLs with newlines or English commas \",\".`,\n    zh_TW: `1. 支援星號 (*) 萬用字元。2. 多個 URL 請以換行或英文逗號「,」分隔。`,\n    ja: `1. アスタリスク (*) ワイルドカードをサポートします。 2. 複数のURLは改行または英語のコンマ「,」で区切ります。`,\n    ko: `1. 별표(*) 와일드카드 문자를 지원합니다. 2. 여러 URL은 줄바꿈 또는 영어 쉼표 \",\"로 구분합니다.`,\n  },\n  selector_helper: {\n    zh: `1、需要翻译的目标元素。2、开启自动扫描页面后，本设置无效。3、遵循CSS选择器语法。`,\n    en: `1. The target element to be translated. 2. This setting is invalid when automatic page scanning is enabled. 3. Follow the CSS selector syntax.`,\n    zh_TW: `1、需要翻譯的目標元素。 2.開啟自動掃描頁面後，本設定無效。 3.遵循CSS選擇器語法。`,\n    ja: `1. 翻訳対象の要素。 2. ページの自動スキャンを有効にすると、この設定は無効になります。 3. CSSセレクタ構文に従ってください。`,\n    ko: `1. 번역할 대상 요소입니다. 2. 자동 페이지 스캔이 활성화되면 이 설정은 무효화됩니다. 3. CSS 선택자 구문을 따릅니다.`,\n  },\n  translate_switch: {\n    zh: `开启翻译`,\n    en: `Translate Switch`,\n    zh_TW: `開啟翻譯`,\n    ja: `翻訳を有効にする`,\n    ko: `번역 켜기`,\n  },\n  default_enabled: {\n    zh: `默认开启`,\n    en: `Enabled`,\n    zh_TW: `預設開啟`,\n    ja: `デフォルトで有効`,\n    ko: `기본으로 사용`,\n  },\n  default_disabled: {\n    zh: `默认关闭`,\n    en: `Disabled`,\n    zh_TW: `預設關閉`,\n    ja: `デフォルトで無効`,\n    ko: `기본으로 사용 안함`,\n  },\n  selector: {\n    zh: `选择器`,\n    en: `Selector`,\n    zh_TW: `選擇器`,\n    ja: `セレクタ`,\n    ko: `선택자`,\n  },\n  target_selector: {\n    zh: `目标元素选择器`,\n    en: `Target element selector`,\n    zh_TW: `目標元素選擇器`,\n    ja: `対象要素セレクタ`,\n    ko: `대상 요소 선택자`,\n  },\n  keep_selector: {\n    zh: `保留元素选择器`,\n    en: `Keep unchanged selector`,\n    zh_TW: `保留元素選擇器`,\n    ja: `保持要素セレクタ`,\n    ko: `유지할 요소 선택자`,\n  },\n  keep_selector_helper: {\n    zh: `1、目标元素下面需要原样保留的子节点。2、遵循CSS选择器语法。`,\n    en: `1. The child nodes under the target element need to remain intact. 2. Follow the CSS selector syntax.`,\n    zh_TW: `1. 目標元素下的子節點需要保持原樣。 2. 遵循 CSS 選擇器語法。`,\n    ja: `1. 対象要素の下にある、そのまま保持する必要がある子ノード。 2. CSSセレクタ構文に従ってください。`,\n    ko: `1. 대상 요소 아래의 자식 노드 중 그대로 유지해야 하는 노드. 2. CSS 선택자 구문을 따릅니다.`,\n  },\n  root_selector: {\n    zh: `根节点选择器`,\n    en: `Root node selector`,\n    zh_TW: `根節點選擇器`,\n    ja: `ルートノードセレクタ`,\n    ko: `루트 노드 선택자`,\n  },\n  root_selector_helper: {\n    zh: `1、用于缩小页面翻译范围。2、遵循CSS选择器语法。`,\n    en: `1. Used to narrow the translation scope of the page. 2. Follow the CSS selector syntax.`,\n    zh_TW: `1.用於縮小頁面翻譯範圍。 2、遵循CSS選擇器語法。`,\n    ja: `1. ページの翻訳範囲を絞り込むために使用します。 2. CSSセレクタ構文に従ってください。`,\n    ko: `1. 페이지의 번역 범위를 좁히는 데 사용됩니다. 2. CSS 선택자 구문을 따릅니다.`,\n  },\n  ignore_selector: {\n    zh: `不翻译节点选择器`,\n    en: `Ignore node selectors`,\n    zh_TW: `不翻譯節點選擇器`,\n    ja: `翻訳しないノードセレクタ`,\n    ko: `번역 무시 노드 선택자`,\n  },\n  ignore_selector_helper: {\n    zh: `1、需要忽略的节点。2、遵循CSS选择器语法。`,\n    en: `1. Nodes to be ignored. 2. Follow CSS selector syntax.`,\n    zh_TW: `1、需要忽略的節點。 2、遵循CSS選擇器語法。`,\n    ja: `1. 無視するノード。 2. CSSセレクタ構文に従ってください。`,\n    ko: `1. 무시할 노드. 2. CSS 선택자 구문을 따릅니다.`,\n  },\n  terms: {\n    zh: `专业术语`,\n    en: `Terms`,\n    zh_TW: `專業術語`,\n    ja: `専門用語`,\n    ko: `전문 용어`,\n  },\n  terms_helper: {\n    zh: `1、支持正则表达式匹配，无需斜杆，不支持修饰符。2、多条术语用换行或分号“;”隔开。3、术语和译文用英文逗号“,”隔开。4、没有译文视为不翻译术语。`,\n    en: `1. Supports regular expression matching, no slash required, and no modifiers are supported. 2. Separate multiple terms with newlines or semicolons \";\". 3. Terms and translations are separated by English commas \",\". 4. If there is no translation, the term will be deemed not to be translated.`,\n    zh_TW: `1. 支援正則表達式比對，無需斜線，且不支援修飾符。2. 多條術語以換行或分號「;」分隔。3. 術語與譯文以英文逗號「,」分隔。4. 無譯文者視為不翻譯該術語。`,\n    ja: `1. 正規表現マッチングをサポート (スラッシュ不要、修飾子非対応)。 2. 複数の用語は改行またはセミコロン「;」で区切ります。 3. 用語と翻訳は英語のコンマ「,」で区切ります。 4. 翻訳がない場合は、その用語を翻訳しないものとみなします。`,\n    ko: `1. 정규식 일치를 지원하며, 슬래시가 필요 없고 수정자는 지원되지 않습니다. 2. 여러 용어는 줄바꿈 또는 세미콜론 \";\"으로 구분합니다. 3. 용어와 번역은 영어 쉼표 \",\"로 구분합니다. 4. 번역이 없는 경우 해당 용어를 번역하지 않는 것으로 간주합니다.`,\n  },\n  ai_terms: {\n    zh: `AI专业术语`,\n    en: `AI Terms`,\n    zh_TW: `AI專業術語`,\n    ja: `AI専門用語`,\n    ko: `AI 전문 용어`,\n  },\n  ai_terms_helper: {\n    zh: `1、AI智能替换，不支持正则表达式。2、多条术语用换行或分号“;”隔开。3、术语和译文用英文逗号“,”隔开。4、没有译文视为不翻译术语。`,\n    en: `1. AI intelligent replacement does not support regular expressions.2. Separate multiple terms with newlines or semicolons \";\". 3. Terms and translations are separated by English commas \",\". 4. If there is no translation, the term will be deemed not to be translated.`,\n    zh_TW: `1.AI智能替換，不支援正規表示式。2. 多條術語以換行或分號「;」分隔。3. 術語與譯文以英文逗號「,」分隔。4. 無譯文者視為不翻譯該術語。`,\n    ja: `1. AIによるインテリジェントな置換 (正規表現非対応)。 2. 複数の用語は改行またはセミコロン「;」で区切ります。 3. 用語と翻訳は英語のコンマ「,」で区切ります。 4. 翻訳がない場合は、その用語を翻訳しないものとみなします。`,\n    ko: `1. AI 지능형 대체, 정규식을 지원하지 않습니다. 2. 여러 용어는 줄바꿈 또는 세미콜론 \";\"으로 구분합니다. 3. 용어와 번역은 영어 쉼표 \",\"로 구분합니다. 4. 번역이 없는 경우 해당 용어를 번역하지 않는 것으로 간주합니다.`,\n  },\n  text_ext_style: {\n    zh: `译文附加样式`,\n    en: `Translation additional styles`,\n    zh_TW: `譯文附加樣式`,\n    ja: `翻訳の追加スタイル`,\n    ko: `번역 추가 스타일`,\n  },\n  selector_style: {\n    zh: `选择器节点样式`,\n    en: `Selector Style`,\n    zh_TW: `選擇器節點樣式`,\n    ja: `セレクタノードスタイル`,\n    ko: `선택자 노드 스타일`,\n  },\n  terms_style: {\n    zh: `专业术语样式`,\n    en: `Terms Style`,\n    zh_TW: `專業術語樣式`,\n    ja: `専門用語スタイル`,\n    ko: `전문 용어 스타일`,\n  },\n  highlight_style: {\n    zh: `词汇高亮样式`,\n    en: `Fav Words highlight style`,\n    zh_TW: `詞彙高亮樣式`,\n    ja: `単語ハイライトスタイル`,\n    ko: `단어 하이라이트 스타일`,\n  },\n  selector_style_helper: {\n    zh: `开启翻译时注入。`,\n    en: `It is injected when translation is turned on.`,\n    zh_TW: `在開啟翻譯時注入。`,\n    ja: `翻訳が有効なときに注入されます。`,\n    ko: `번역이 켜져 있을 때 주입됩니다.`,\n  },\n  selector_parent_style: {\n    zh: `选择器父节点样式`,\n    en: `Parent Selector Style`,\n    zh_TW: `選擇器父節點樣式`,\n    ja: `親セレクタスタイル`,\n    ko: `부모 선택자 스타일`,\n  },\n  selector_grand_style: {\n    zh: `选择器祖节点样式`,\n    en: `Grand Selector Style`,\n    zh_TW: `選擇器祖節點樣式`,\n    ja: `祖先セレクタスタイル`,\n    ko: `상위 선택자 스타일`,\n  },\n  inject_js: {\n    zh: `注入JS`,\n    en: `Inject JS`,\n    zh_TW: `注入 JS`,\n    ja: `JSを注入`,\n    ko: `JS 주입`,\n  },\n  inject_js_helper: {\n    zh: `预加载时注入，一个页面仅运行一次。内置全局对象 KT: {\n      apiTranslate,\n      apiDectect,\n      apiSetting,\n      apisMap,\n      toLang,\n      docInfo,\n      glossary,\n    }`,\n    en: `Injected during preload, runs only once per page. Built-in global object KT: {\n      apiTranslate,\n      apiDectect,\n      apiSetting,\n      apisMap,\n      toLang,\n      docInfo,\n      glossary,\n    }`,\n    zh_TW: `預先載入時注入，一個頁面僅運行一次。內建全域物件 KT: {\n      apiTranslate,\n      apiDectect,\n      apiSetting,\n      apisMap,\n      toLang,\n      docInfo,\n      glossary,\n    }`,\n    ja: `プリロード時に注入され、ページごとに1回だけ実行されます。組み込みグローバルオブジェクト KT: {\n      apiTranslate,\n      apiDectect,\n      apiSetting,\n      apisMap,\n      toLang,\n      docInfo,\n      glossary,\n    }`,\n    ko: `미리 로드 시 주입되며 페이지당 한 번만 실행됩니다. 내장 전역 객체 KT: {\n      apiTranslate,\n      apiDectect,\n      apiSetting,\n      apisMap,\n      toLang,\n      docInfo,\n      glossary,\n    }`,\n  },\n  inject_css: {\n    zh: `注入CSS`,\n    en: `Inject CSS`,\n    zh_TW: `注入 CSS`,\n    ja: `CSSを注入`,\n    ko: `CSS 주입`,\n  },\n  inject_css_helper: {\n    zh: `预加载时注入，一个页面仅运行一次。`,\n    en: `Injected during preload, runs only once per page.`,\n    zh_TW: `預先載入時注入，一個頁面僅運行一次。`,\n    ja: `プリロード時に注入され、ページごとに1回だけ実行されます。`,\n    ko: `미리 로드 시 주입되며 페이지당 한 번만 실행됩니다.`,\n  },\n  fixer_function: {\n    zh: `修复函数`,\n    en: `Fixer Function`,\n    zh_TW: `修復函式`,\n    ja: `修正関数`,\n    ko: `수정 함수`,\n  },\n  fixer_function_helper: {\n    zh: `1、br是将<br>换行替换成<p \"kiss-p\">。2、bn是将\\\\n换行替换成<p \"kiss-p\">。3、brToDiv和bnToDiv是替换成<div class=\"kiss-p\">。`,\n    en: `1. br replaces <br> line breaks with <p \"kiss-p\">. 2. bn replaces \\\\n newline with <p \"kiss-p\">. 3. brToDiv and bnToDiv are replaced with <div class=\"kiss-p\">.`,\n    zh_TW: `1. br 會將 <br> 換行替換為 <p \"kiss-p\">。2. bn 會將 \\\\n 換行替換為 <p \"kiss-p\">。3. brToDiv 與 bnToDiv 會替換為 <div class=\"kiss-p\">。`,\n    ja: `1. br は <br> 改行を <p \"kiss-p\"> に置き換えます。 2. bn は \\\\n 改行を <p \"kiss-p\"> に置き換えます。 3. brToDiv と bnToDiv は <div class=\"kiss-p\"> に置き換えます。`,\n    ko: `1. br은 <br> 줄바꿈을 <p \"kiss-p\">로 대체합니다. 2. bn은 \\\\n 줄바꿈을 <p \"kiss-p\">로 대체합니다. 3. brToDiv 및 bnToDiv는 <div class=\"kiss-p\">로 대체됩니다.`,\n  },\n  import: {\n    zh: `导入`,\n    en: `Import`,\n    zh_TW: `匯入`,\n    ja: `インポート`,\n    ko: `가져오기`,\n  },\n  export: {\n    zh: `导出`,\n    en: `Export`,\n    zh_TW: `匯出`,\n    ja: `エクスポート`,\n    ko: `내보내기`,\n  },\n  export_translation: {\n    zh: `导出释义`,\n    en: `Export Translation`,\n    zh_TW: `匯出釋義`,\n    ja: `訳文のエクスポート`,\n    ko: `번역 내보내기`,\n  },\n  error_cant_be_blank: {\n    zh: `不能为空`,\n    en: `Can not be blank`,\n    zh_TW: `不可為空`,\n    ja: `空白にすることはできません`,\n    ko: `비워둘 수 없습니다`,\n  },\n  error_duplicate_values: {\n    zh: `存在重复的值`,\n    en: `There are duplicate values`,\n    zh_TW: `存在重複的值`,\n    ja: `重複する値が存在します`,\n    ko: `중복된 값이 있습니다`,\n  },\n  error_wrong_file_type: {\n    zh: `错误的文件类型`,\n    en: `Wrong file type`,\n    zh_TW: `檔案類型錯誤`,\n    ja: `不正なファイルタイプです`,\n    ko: `잘못된 파일 형식입니다`,\n  },\n  error_fetch_url: {\n    zh: `请检查url地址是否正确或稍后再试。`,\n    en: `Please check if the url address is correct or try again later.`,\n    zh_TW: `請檢查 URL 是否正確或稍後再試。`,\n    ja: `URLアドレスが正しいか確認するか、後でもう一度お試しください。`,\n    ko: `URL 주소가 올바른지 확인하거나 나중에 다시 시도하십시오.`,\n  },\n  deepl_api: {\n    zh: `DeepL 接口`,\n    en: `DeepL API`,\n    zh_TW: `DeepL 介面`,\n    ja: `DeepL API`,\n    ko: `DeepL API`,\n  },\n  deepl_key: {\n    zh: `DeepL 密钥`,\n    en: `DeepL Key`,\n    zh_TW: `DeepL 金鑰`,\n    ja: `DeepL キー`,\n    ko: `DeepL 키`,\n  },\n  openai_api: {\n    zh: `OpenAI 接口`,\n    en: `OpenAI API`,\n    zh_TW: `OpenAI 介面`,\n    ja: `OpenAI API`,\n    ko: `OpenAI API`,\n  },\n  openai_key: {\n    zh: `OpenAI 密钥`,\n    en: `OpenAI Key`,\n    zh_TW: `OpenAI 金鑰`,\n    ja: `OpenAI キー`,\n    ko: `OpenAI 키`,\n  },\n  openai_model: {\n    zh: `OpenAI 模型`,\n    en: `OpenAI Model`,\n    zh_TW: `OpenAI 模型`,\n    ja: `OpenAI モデル`,\n    ko: `OpenAI 모델`,\n  },\n  openai_prompt: {\n    zh: `OpenAI 提示词`,\n    en: `OpenAI Prompt`,\n    zh_TW: `OpenAI 提示詞`,\n    ja: `OpenAI プロンプト`,\n    ko: `OpenAI 프롬프트`,\n  },\n  if_clear_cache: {\n    zh: `是否清除缓存（默认缓存7天）`,\n    en: `Whether clear cache (Default cache is 7 days)`,\n    zh_TW: `是否清除快取（預設快取7天）`,\n    ja: `キャッシュをクリアしますか（デフォルトのキャッシュ期間は7日間です）`,\n    ko: `캐시를 지우시겠습니까 (기본 캐시 7일)`,\n  },\n  clear_cache_never: {\n    zh: `不清除缓存`,\n    en: `Never clear cache`,\n    zh_TW: `不清除快取`,\n    ja: `キャッシュをクリアしない`,\n    ko: `캐시 지우지 않음`,\n  },\n  clear_cache_restart: {\n    zh: `重启浏览器时清除缓存`,\n    en: `Clear cache when restarting browser`,\n    zh_TW: `重新啟動瀏覽器時清除快取`,\n    ja: `ブラウザ再起動時にキャッシュをクリア`,\n    ko: `브라우저 재시작 시 캐시 지우기`,\n  },\n  data_sync_type: {\n    zh: `数据同步方式`,\n    en: `Data Sync Type`,\n    zh_TW: `資料同步方式`,\n    ja: `データ同期タイプ`,\n    ko: `데이터 동기화 유형`,\n  },\n  data_sync_url: {\n    zh: `数据同步接口`,\n    en: `Data Sync API`,\n    zh_TW: `資料同步介面`,\n    ja: `データ同期API`,\n    ko: `데이터 동기화 API`,\n  },\n  data_sync_user: {\n    zh: `数据同步账户`,\n    en: `Data Sync User`,\n    zh_TW: `資料同步帳號`,\n    ja: `データ同期アカウント`,\n    ko: `데이터 동기화 계정`,\n  },\n  data_sync_key: {\n    zh: `数据同步密钥`,\n    en: `Data Sync Key`,\n    zh_TW: `資料同步金鑰`,\n    ja: `データ同期キー`,\n    ko: `데이터 동기화 키`,\n  },\n  sync_now: {\n    zh: `立即同步`,\n    en: `Sync Now`,\n    zh_TW: `立即同步`,\n    ja: `今すぐ同期`,\n    ko: `지금 동기화`,\n  },\n  sync_success: {\n    zh: `同步成功！`,\n    en: `Sync Success`,\n    zh_TW: `同步成功！`,\n    ja: `同期成功！`,\n    ko: `동기화 성공!`,\n  },\n  sync_failed: {\n    zh: `同步失败！`,\n    en: `Sync Error`,\n    zh_TW: `同步失敗！`,\n    ja: `同期失敗！`,\n    ko: `동기화 실패!`,\n  },\n  error_got_some_wrong: {\n    zh: `抱歉，出错了！`,\n    en: `Sorry, something went wrong!`,\n    zh_TW: `抱歉，發生錯誤！`,\n    ja: `申し訳ありません、エラーが発生しました！`,\n    ko: `죄송합니다, 오류가 발생했습니다!`,\n  },\n  error_sync_setting: {\n    zh: `您的同步类型必须为“KISS-Worker”，且需填写完整`,\n    en: `Your sync type must be \"KISS-Worker\" and must be filled in completely`,\n    zh_TW: `您的同步型態必須為「KISS-Worker」，且需填寫完整。`,\n    ja: `同期タイプは「KISS-Worker」である必要があり、すべて入力する必要があります。`,\n    ko: `동기화 유형은 \"KISS-Worker\"여야 하며, 모든 항목을 빠짐없이 입력해야 합니다.`,\n  },\n  click_test: {\n    zh: `点击测试`,\n    en: `Click Test`,\n    zh_TW: `點擊測試`,\n    ja: `クリックしてテスト`,\n    ko: `클릭 테스트`,\n  },\n  test_success: {\n    zh: `测试成功`,\n    en: `Test success`,\n    zh_TW: `測試成功`,\n    ja: `テスト成功`,\n    ko: `테스트 성공`,\n  },\n  test_failed: {\n    zh: `测试失败`,\n    en: `Test failed`,\n    zh_TW: `測試失敗`,\n    ja: `テスト失敗`,\n    ko: `테스트 실패`,\n  },\n  clear_all_cache_now: {\n    zh: `立即清除全部缓存`,\n    en: `Clear all cache now`,\n    zh_TW: `立即清除全部快取`,\n    ja: `すべてのキャッシュを今すぐクリア`,\n    ko: `모든 캐시 지금 지우기`,\n  },\n  clear_cache: {\n    zh: `清除缓存`,\n    en: `Clear Cache`,\n    zh_TW: `清除快取`,\n    ja: `キャッシュをクリア`,\n    ko: `캐시 지우기`,\n  },\n  disable_on_mobile: {\n    zh: `移动端禁用`,\n    en: `Disable on Mobile`,\n    zh_TW: `行動裝置停用`,\n    ja: `モバイルで無効にする`,\n    ko: `모바일에서 비활성화`,\n  },\n  clear_success: {\n    zh: `清除成功`,\n    en: `Clear success`,\n    zh_TW: `清除成功`,\n    ja: `クリア成功`,\n    ko: `지우기 성공`,\n  },\n  clear_failed: {\n    zh: `清除失败`,\n    en: `Clear failed`,\n    zh_TW: `清除失敗`,\n    ja: `クリア失敗`,\n    ko: `지우기 실패`,\n  },\n  share: {\n    zh: `分享`,\n    en: `Share`,\n    zh_TW: `分享`,\n    ja: `共有`,\n    ko: `공유`,\n  },\n  clear_all: {\n    zh: `清空`,\n    en: `Clear All`,\n    zh_TW: `清空`,\n    ja: `すべてクリア`,\n    ko: `모두 지우기`,\n  },\n  help: {\n    zh: `求助`,\n    en: `Help`,\n    zh_TW: `求助`,\n    ja: `ヘルプ`,\n    ko: `도움말`,\n  },\n  restore_default: {\n    zh: `恢复默认`,\n    en: `Restore Default`,\n    zh_TW: `恢復預設`,\n    ja: `デフォルトに戻す`,\n    ko: `기본값 복원`,\n  },\n  shortcuts_setting: {\n    zh: `快捷键设置`,\n    en: `Shortcuts Setting`,\n    zh_TW: `快捷鍵設定`,\n    ja: `ショートカット設定`,\n    ko: `단축키 설정`,\n  },\n  toggle_translate_shortcut: {\n    zh: `\"开启翻译\"快捷键`,\n    en: `\"Toggle Translate\" Shortcut`,\n    zh_TW: `「開啟翻譯」快捷鍵`,\n    ja: `「翻訳切り替え」ショートカット`,\n    ko: `\"번역 켜기\" 단축키`,\n  },\n  toggle_style_shortcut: {\n    zh: `\"切换样式\"快捷键`,\n    en: `\"Toggle Style\" Shortcut`,\n    zh_TW: `「切換樣式」快捷鍵`,\n    ja: `「スタイル切り替え」ショートカット`,\n    ko: `\"스타일 전환\" 단축키`,\n  },\n  toggle_popup_shortcut: {\n    zh: `\"打开弹窗\"快捷键`,\n    en: `\"Open Popup\" Shortcut`,\n    zh_TW: `「開啟彈窗」快捷鍵`,\n    ja: `「ポップアップを開く」ショートカット`,\n    ko: `\"팝업 열기\" 단축키`,\n  },\n  open_setting_shortcut: {\n    zh: `\"打开设置\"快捷键`,\n    en: `\"Open Setting\" Shortcut`,\n    zh_TW: `「開啟設定」快捷鍵`,\n    ja: `「設定を開く」ショートカット`,\n    ko: `\"설정 열기\" 단축키`,\n  },\n  hide_fab_button: {\n    zh: `隐藏悬浮按钮`,\n    en: `Hide Fab Button`,\n    zh_TW: `隱藏懸浮按鈕`,\n    ja: `フローティングボタンを隠す`,\n    ko: `플로팅 버튼 숨기기`,\n  },\n  fab_click_action: {\n    zh: `单击悬浮按钮动作`,\n    en: `Single Click Fab Action`,\n    zh_TW: `單擊懸浮按钮動作`,\n    ja: `フローティングボタンのクリック動作`,\n    ko: `플로팅 버튼 클릭 동작`,\n  },\n  fab_click_menu: {\n    zh: `弹出菜单`,\n    en: `Popup Menu`,\n    zh_TW: `彈出選單`,\n    ja: `メニューを開く`,\n    ko: `팝업 메뉴`,\n  },\n  fab_click_translate: {\n    zh: `直接翻译`,\n    en: `Translate`,\n    zh_TW: `直接翻譯`,\n    ja: `直接翻訳`,\n    ko: `바로 번역`,\n  },\n  hide_tran_button: {\n    zh: `隐藏翻译按钮`,\n    en: `Hide Translate Button`,\n    zh_TW: `隱藏翻譯按鈕`,\n    ja: `翻訳ボタンを隠す`,\n    ko: `번역 버튼 숨기기`,\n  },\n  hide_click_away: {\n    zh: `点击外部关闭弹窗`,\n    en: `Click outside to close the pop-up window`,\n    zh_TW: `點擊外部關閉彈窗`,\n    ja: `外部クリックでポップアップを閉じる`,\n    ko: `바깥쪽 클릭 시 팝업 닫기`,\n  },\n  use_simple_style: {\n    zh: `使用简洁界面`,\n    en: `Use a simple interface`,\n    zh_TW: `使用簡潔介面`,\n    ja: `シンプルUIを使用`,\n    ko: `간단한 인터페이스 사용`,\n  },\n  show: {\n    zh: `显示`,\n    en: `Show`,\n    zh_TW: `顯示`,\n    ja: `表示`,\n    ko: `표시`,\n  },\n  hide: {\n    zh: `隐藏`,\n    en: `Hide`,\n    zh_TW: `隱藏`,\n    ja: `非表示`,\n    ko: `숨기기`,\n  },\n  save_rule: {\n    zh: `保存本站规则`,\n    en: `Save this site rule`,\n    zh_TW: `保存本站規則`,\n    ja: `このサイトのルールを保存`,\n    ko: `이 사이트 규칙 저장`,\n  },\n  domain: {\n    zh: `网域`,\n    en: `Domain`,\n    zh_TW: `網域`,\n    ja: `ドメイン`,\n    ko: `도메인`,\n  },\n  global_rule: {\n    zh: `全局规则`,\n    en: `Global Rule`,\n    zh_TW: `全域規則`,\n    ja: `グローバルルール`,\n    ko: `전역 규칙`,\n  },\n  input_translate: {\n    zh: `输入框翻译`,\n    en: `Input Box Translation`,\n    zh_TW: `輸入框翻譯`,\n    ja: `入力ボックス翻訳`,\n    ko: `입력창 번역`,\n  },\n  use_input_box_translation: {\n    zh: `启用输入框翻译`,\n    en: `Input Box Translation`,\n    zh_TW: `啟用輸入框翻譯`,\n    ja: `入力ボックス翻訳を有効にする`,\n    ko: `입력창 번역 사용`,\n  },\n  input_selector: {\n    zh: `输入框选择器`,\n    en: `Input Selector`,\n    zh_TW: `輸入框選擇器`,\n    ja: `入力ボックスセレクタ`,\n    ko: `입력창 선택자`,\n  },\n  input_selector_helper: {\n    zh: `用于输入框翻译。`,\n    en: `Used for input box translation.`,\n    zh_TW: `用於輸入框翻譯。`,\n    ja: `入力ボックスの翻訳に使用します。`,\n    ko: `입력창 번역에 사용됩니다.`,\n  },\n  trigger_trans_shortcut: {\n    zh: `触发翻译快捷键`,\n    en: `Trigger Translation Shortcut Keys`,\n    zh_TW: `觸發翻譯快捷鍵`,\n    ja: `翻訳ショートカットキー`,\n    ko: `번역 실행 단축키`,\n  },\n  trigger_trans_shortcut_help: {\n    zh: `默认为单击“AltLeft+KeyI”`,\n    en: `Default is \"AltLeft+KeyI\"`,\n    zh_TW: `預設為按下「AltLeft+KeyI」`,\n    ja: `デフォルトは「AltLeft+KeyI」です`,\n    ko: `기본값 \"AltLeft+KeyI\"`,\n  },\n  shortcut_press_count: {\n    zh: `快捷键连击次数`,\n    en: `Shortcut Press Number`,\n    zh_TW: `快捷鍵連擊次數`,\n    ja: `ショートカットの連続プレス回数`,\n    ko: `단축키 연속 입력 횟수`,\n  },\n  combo_timeout: {\n    zh: `连击超时时间 (10-1000ms)`,\n    en: `Combo Timeout (10-1000ms)`,\n    zh_TW: `連擊逾時 (10-1000ms)`,\n    ja: `連続プレスタイムアウト (10-1000ms)`,\n    ko: `연속 입력 시간 초과 (10-1000ms)`,\n  },\n  input_trans_start_sign: {\n    zh: `翻译起始标识`,\n    en: `Translation Start Sign`,\n    zh_TW: `翻譯起始標記`,\n    ja: `翻訳開始記号`,\n    ko: `번역 시작 표시`,\n  },\n  input_trans_start_sign_help: {\n    zh: `标识后面可以加目标语言代码，如： “/en 你好”、“/zh hello”`,\n    en: `The target language code can be added after the sign, such as: \"/en 你好\", \"/zh hello\"`,\n    zh_TW: `標記後可加上目標語言代碼，例如：「/en 你好」、「/zh hello」`,\n    ja: `記号の後に対象言語コードを追加できます。例：「/en 你好」、「/zh hello」`,\n    ko: `표시 뒤에 대상 언어 코드를 추가할 수 있습니다. 예: \"/en 你好\", \"/zh hello\"`,\n  },\n  detect_lang_remote: {\n    zh: `远程语言检测`,\n    en: `Remote language detection`,\n    zh_TW: `遠端語言偵測`,\n    ja: `リモート言語検出`,\n    ko: `원격 언어 감지`,\n  },\n  detect_lang_remote_help: {\n    zh: `启用后检测准确度增加，但会降低翻译速度，请酌情开启`,\n    en: `After enabling, the detection accuracy will increase, but it will reduce the translation speed. Please enable it as appropriate.`,\n    zh_TW: `啟用後可提升偵測準確度，但會降低翻譯速度，請視需要開啟。`,\n    ja: `有効にすると検出精度が向上しますが、翻訳速度が低下する可能性があります。必要に応じて有効にしてください。`,\n    ko: `활성화하면 감지 정확도가 높아지지만 번역 속도가 느려질 수 있습니다. 적절히 활성화하십시오.`,\n  },\n  detect_lang_service: {\n    zh: `语言检测服务`,\n    en: `Language detect service`,\n    zh_TW: `語言檢測服務`,\n    ja: `言語検出サービス`,\n    ko: `언어 감지 서비스`,\n  },\n  disable: {\n    zh: `禁用`,\n    en: `Disable`,\n    zh_TW: `停用`,\n    ja: `無効`,\n    ko: `비활성화`,\n  },\n  enable: {\n    zh: `启用`,\n    en: `Enable`,\n    zh_TW: `啟用`,\n    ja: `有効`,\n    ko: `활성화`,\n  },\n  selection_translate: {\n    zh: `划词翻译`,\n    en: `Selection Translation`,\n    zh_TW: `劃詞翻譯`,\n    ja: `選択翻訳`,\n    ko: `선택 번역`,\n  },\n  toggle_selection_translate: {\n    zh: `启用划词翻译`,\n    en: `Use Selection Translate`,\n    zh_TW: `啟用劃詞翻譯`,\n    ja: `選択翻訳を有効にする`,\n    ko: `선택 번역 사용`,\n  },\n  trigger_tranbox_shortcut: {\n    zh: `显示翻译框/翻译选中文字快捷键`,\n    en: `Open Translate Popup/Translate Selected Shortcut`,\n    zh_TW: `顯示翻譯框／翻譯選中文字快捷鍵`,\n    ja: `翻訳ポップアップ表示/選択翻訳ショートカット`,\n    ko: `번역창 표시/선택 번역 단축키`,\n  },\n  tranbtn_offset_x: {\n    zh: `翻译按钮偏移X（±200）`,\n    en: `Translate Button Offset X (±200)`,\n    zh_TW: `翻譯按鈕位移 X（±200）`,\n    ja: `翻訳ボタンオフセットX (±200)`,\n    ko: `번역 버튼 오프셋 X (±200)`,\n  },\n  tranbtn_offset_y: {\n    zh: `翻译按钮偏移Y（±200）`,\n    en: `Translate Button Offset Y (±200)`,\n    zh_TW: `翻譯按鈕位移 Y（±200）`,\n    ja: `翻訳ボタンオフセットY (±200)`,\n    ko: `번역 버튼 오프셋 Y (±200)`,\n  },\n  tranbox_offset_x: {\n    zh: `翻译框偏移X（±200）`,\n    en: `Translate Box Offset X (±200)`,\n    zh_TW: `翻譯框位移 X（±200）`,\n    ja: `翻訳ボックスオフセットX (±200)`,\n    ko: `번역창 오프셋 X (±200)`,\n  },\n  tranbox_offset_y: {\n    zh: `翻译框偏移Y（±200）`,\n    en: `Translate Box Offset Y (±200)`,\n    zh_TW: `翻譯框位移 Y（±200）`,\n    ja: `翻訳ボックスオフセットY (±200)`,\n    ko: `번역창 오프셋 Y (±200)`,\n  },\n  translated_text: {\n    zh: `译文`,\n    en: `Translated Text`,\n    zh_TW: `譯文`,\n    ja: `翻訳済みテキスト`,\n    ko: `번역된 텍스트`,\n  },\n  original_text: {\n    zh: `原文`,\n    en: `Original Text`,\n    zh_TW: `原文`,\n    ja: `原文`,\n    ko: `원본 텍스트`,\n  },\n  favorite_words: {\n    zh: `收藏词汇`,\n    en: `Favorite Words`,\n    zh_TW: `收藏詞彙`,\n    ja: `お気に入り単語`,\n    ko: `즐겨찾는 단어`,\n  },\n  touch_setting: {\n    zh: `触屏设置`,\n    en: `Touch Setting`,\n    zh_TW: `觸控設定`,\n    ja: `タッチ設定`,\n    ko: `터치 설정`,\n  },\n  touch_translate_shortcut: {\n    zh: `触屏翻译快捷方式 (支持多选)`,\n    en: `Touch Translate Shortcut (multiple supported)`,\n    zh_TW: `觸控翻譯捷徑 (支援多選)`,\n    ja: `タッチ翻訳ショートカット (複数選択可)`,\n    ko: `터치 번역 단축키 (다중 선택 지원)`,\n  },\n  touch_tap_0: {\n    zh: `禁用`,\n    en: `Disable`,\n    zh_TW: `停用`,\n    ja: `無効`,\n    ko: `비활성화`,\n  },\n  touch_tap_2: {\n    zh: `双指轻触`,\n    en: `Two finger tap`,\n    zh_TW: `雙指輕觸`,\n    ja: `2本指タップ`,\n    ko: `두 손가락 탭`,\n  },\n  touch_tap_3: {\n    zh: `三指轻触`,\n    en: `Three finger tap`,\n    zh_TW: `三指輕觸`,\n    ja: `3本指タップ`,\n    ko: `세 손가락 탭`,\n  },\n  touch_tap_4: {\n    zh: `四指轻触`,\n    en: `Four finger tap`,\n    zh_TW: `四指輕觸`,\n    ja: `4本指タップ`,\n    ko: `네 손가락 탭`,\n  },\n  touch_tap_5: {\n    zh: `单指双击`,\n    en: `Double-click`,\n    zh_TW: `單指雙擊`,\n    ja: `ダブルクリック`,\n    ko: `더블 클릭`,\n  },\n  touch_tap_6: {\n    zh: `单指三击`,\n    en: `Triple-click`,\n    zh_TW: `單指三擊`,\n    ja: `トリプルクリック`,\n    ko: `트리플 클릭`,\n  },\n  touch_tap_7: {\n    zh: `双指双击`,\n    en: `Two-finger double-click`,\n    zh_TW: `雙指雙擊`,\n    ja: `2本指ダブルクリック`,\n    ko: `두 손가락 더블 클릭`,\n  },\n  translate_blacklist: {\n    zh: `禁用翻译名单`,\n    en: `Translate Blacklist`,\n    zh_TW: `停用翻譯名單`,\n    ja: `翻訳ブラックリスト`,\n    ko: `번역 블랙리스트`,\n  },\n  disabled_orilist: {\n    zh: `禁用Origin名单`,\n    en: `Disabled Origin List`,\n    zh_TW: `停用 Origin 名單`,\n    ja: `無効化Originリスト`,\n    ko: `비활성화된 Origin 목록`,\n  },\n  disabled_csplist: {\n    zh: `禁用CSP名单`,\n    en: `Disabled CSP List`,\n    zh_TW: `停用 CSP 名單`,\n    ja: `無効化CSPリスト`,\n    ko: `비활성화된 CSP 목록`,\n  },\n  disabled_csplist_helper: {\n    zh: `3、通过调整CSP策略，使得某些页面能够注入JS/CSS/Media，请谨慎使用，除非您已知晓相关风险。`,\n    en: `3. By adjusting the CSP policy, some pages can inject JS/CSS/Media. Please use it with caution unless you are aware of the related risks.`,\n    zh_TW: `3. 透過調整 CSP 政策，使部分頁面可注入 JS/CSS/Media。請謹慎使用，除非您已知悉相關風險。`,\n    ja: `3. CSPポリシーを調整することにより、一部のページでJS/CSS/Mediaの注入が可能になります。関連するリスクを承知していない限り、慎重に使用してください。`,\n    ko: `3. CSP 정책을 조정하여 일부 페이지에서 JS/CSS/Media를 주입할 수 있습니다. 관련된 위험을 인지하고 있는 경우가 아니라면 주의해서 사용하십시오.`,\n  },\n  skip_langs: {\n    zh: `不翻译的语言`,\n    en: `Disable Languages`,\n    zh_TW: `不翻譯的語言`,\n    ja: `翻訳しない言語`,\n    ko: `번역하지 않을 언어`,\n  },\n  skip_langs_helper: {\n    zh: `此功能依赖准确的语言检测，建议启用远程语言检测。`,\n    en: `This feature relies on accurate language detection. It is recommended to enable remote language detection.`,\n    zh_TW: `此功能仰賴準確的語言偵測，建議啟用遠端語言偵測。`,\n    ja: `この機能は正確な言語検出に依存しているため、リモート言語検出を有効にすることをお勧めします。`,\n    ko: `이 기능은 정확한 언어 감지에 의존하므로 원격 언어 감지를 활성화하는 것이 좋습니다.`,\n  },\n  context_menus: {\n    zh: `右键菜单`,\n    en: `Context Menus`,\n    zh_TW: `右鍵選單`,\n    ja: `コンテキストメニュー`,\n    ko: `컨텍스트 메뉴`,\n  },\n  hide_context_menus: {\n    zh: `隐藏右键菜单`,\n    en: `Hide Context Menus`,\n    zh_TW: `隱藏右鍵選單`,\n    ja: `コンテキストメニューを隠す`,\n    ko: `컨텍스트 메뉴 숨기기`,\n  },\n  simple_context_menus: {\n    zh: `简单右键菜单`,\n    en: `Simple_context_menus Context Menus`,\n    zh_TW: `簡易右鍵選單`,\n    ja: `シンプルコンテキストメニュー`,\n    ko: `간단한 컨텍스트 메뉴`,\n  },\n  secondary_context_menus: {\n    zh: `二级右键菜单`,\n    en: `Secondary Context Menus`,\n    zh_TW: `次級右鍵選單`,\n    ja: `サブコンテキストメニュー`,\n    ko: `보조 컨텍스트 메뉴`,\n  },\n  mulkeys_help: {\n    zh: `支持用换行或英文逗号“,”分隔，轮询调用。`,\n    en: `Supports polling calls separated by newlines or English commas \",\".`,\n    zh_TW: `支援以換行或英文逗號「,」分隔，輪詢呼叫。`,\n    ja: `改行または英語のコンマ「,」で区切ってポーリングコールをサポートします。`,\n    ko: `줄바꿈 또는 영어 쉼표 \",\"로 구분된 폴링 호출을 지원합니다.`,\n  },\n  translation_element_tag: {\n    zh: `译文元素标签`,\n    en: `Translation Element Tag`,\n    zh_TW: `譯文元素標籤`,\n    ja: `翻訳要素タグ`,\n    ko: `번역 요소 태그`,\n  },\n  show_only_translations: {\n    zh: `仅显示译文`,\n    en: `Show Only Translations`,\n    zh_TW: `僅顯示譯文`,\n    ja: `翻訳のみ表示`,\n    ko: `번역만 보기`,\n  },\n  show_only_translations_help: {\n    zh: `非完美实现，某些页面可能有样式等问题。`,\n    en: `It is not a perfect implementation and some pages may have style issues.`,\n    zh_TW: `此為非完美實作，部分頁面可能出現樣式等問題。`,\n    ja: `完全な実装ではなく、一部のページでスタイルの問題が発生する可能性があります。`,\n    ko: `완벽한 구현이 아니며 일부 페이지에서 스타일 문제가 발생할 수 있습니다.`,\n  },\n  translate_page_title: {\n    zh: `是否翻译页面标题`,\n    en: `Translate Page Title`,\n    zh_TW: `是否翻譯頁面標題`,\n    ja: `ページタイトルを翻訳する`,\n    ko: `페이지 제목 번역`,\n  },\n  more: {\n    zh: `更多`,\n    en: `More`,\n    zh_TW: `更多`,\n    ja: `もっと見る`,\n    ko: `더보기`,\n  },\n  less: {\n    zh: `更少`,\n    en: `Less`,\n    zh_TW: `更少`,\n    ja: `少なく`,\n    ko: `줄이기`,\n  },\n  fixer_selector: {\n    zh: `网页修复选择器`,\n    en: `Fixer Selector`,\n    zh_TW: `網頁修復選擇器`,\n    ja: `Web修正セレクタ`,\n    ko: `웹페이지 수정 선택자`,\n  },\n  reg_niutrans: {\n    zh: `获取小牛翻译密钥【简约翻译专属新用户注册赠送300万字符】`,\n    en: `Get NiuTrans APIKey [KISS Translator Exclusive New User Registration Free 3 Million Characters]`,\n    zh_TW: `取得小牛翻譯金鑰【簡約翻譯專屬新用戶註冊贈送 300 萬字元】`,\n    ja: `NiuTrans APIキーを取得 [KISS翻訳 専用 新規ユーザー登録で300万文字無料]`,\n    ko: `NiuTrans API 키 받기 [KISS 번역기 신규 사용자 등록 시 300만 자 무료 제공]`,\n  },\n  trigger_mode: {\n    zh: `触发方式`,\n    en: `Trigger Mode`,\n    zh_TW: `觸發方式`,\n    ja: `トリガーモード`,\n    ko: `트리거 모드`,\n  },\n  trigger_click: {\n    zh: `点击触发`,\n    en: `Click Trigger`,\n    zh_TW: `點擊觸發`,\n    ja: `クリックトリガー`,\n    ko: `클릭 트리거`,\n  },\n  trigger_hover: {\n    zh: `鼠标悬停触发`,\n    en: `Hover Trigger`,\n    zh_TW: `滑鼠懸停觸發`,\n    ja: `ホバートリガー`,\n    ko: `호버 트리거`,\n  },\n  trigger_select: {\n    zh: `选中触发`,\n    en: `Select Trigger`,\n    zh_TW: `選取觸發`,\n    ja: `選択トリガー`,\n    ko: `선택 트리거`,\n  },\n  extend_styles: {\n    zh: `附加样式`,\n    en: `Extend Styles`,\n    zh_TW: `附加樣式`,\n    ja: `拡張スタイル`,\n    ko: `확장 스타일`,\n  },\n  custom_option: {\n    zh: `自定义选项`,\n    en: `Custom Option`,\n    zh_TW: `自訂選項`,\n    ja: `カスタムオプション`,\n    ko: `사용자 지정 옵션`,\n  },\n  translate_selected_text: {\n    zh: `翻译选中文字`,\n    en: `Translate Selected Text`,\n    zh_TW: `翻譯選取文字`,\n    ja: `選択したテキストを翻訳`,\n    ko: `선택한 텍스트 번역`,\n  },\n  toggle_style: {\n    zh: `切换样式`,\n    en: `Toggle Style`,\n    zh_TW: `切換樣式`,\n    ja: `スタイルを切り替え`,\n    ko: `스타일 전환`,\n  },\n  open_menu: {\n    zh: `打开弹窗菜单`,\n    en: `Open Popup Menu`,\n    zh_TW: `開啟彈窗選單`,\n    ja: `ポップアップメニューを開く`,\n    ko: `팝업 메뉴 열기`,\n  },\n  open_setting: {\n    zh: `打开设置`,\n    en: `Open Setting`,\n    zh_TW: `開啟設定`,\n    ja: `設定を開く`,\n    ko: `설정 열기`,\n  },\n  follow_selection: {\n    zh: `翻译框跟随选中文本`,\n    en: `Transbox Follow Selection`,\n    zh_TW: `翻譯框跟隨選取文字`,\n    ja: `翻訳ボックスを選択範囲に追従`,\n    ko: `번역 상자가 선택 항목 따라가기`,\n  },\n  tranbox_auto_height: {\n    zh: `翻译框自适应高度`,\n    en: `Translation box adaptive height`,\n    zh_TW: `翻譯框自適應高度`,\n    ja: `翻訳ボックスの高さ自動調整`,\n    ko: `번역 상자 높이 자동 조절`,\n  },\n  translate_start_hook: {\n    zh: `翻译开始钩子函数`,\n    en: `Translate Start Hook`,\n    zh_TW: `翻譯開始 Hook`,\n    ja: `翻訳開始フック`,\n    ko: `번역 시작 후크`,\n  },\n  translate_start_hook_helper: {\n    zh: `翻译前时运行，入参为： {text,\n      fromLang,\n      toLang,\n      apiSetting,\n      docInfo,\n      glossary,}`,\n    en: `Run before translation, input parameters are: {text,\n      fromLang,\n      toLang,\n      apiSetting,\n      docInfo,\n      glossary,}`,\n    zh_TW: `翻譯前時運行，入參為： {text,\n      fromLang,\n      toLang,\n      apiSetting,\n      docInfo,\n      glossary,}`,\n    ja: `翻訳前に実行、入力パラメータ: {text,\n      fromLang,\n      toLang,\n      apiSetting,\n      docInfo,\n      glossary,}`,\n    ko: `번역 전 실행, 입력 매개변수: {text,\n      fromLang,\n      toLang,\n      apiSetting,\n      docInfo,\n      glossary,}`,\n  },\n  translate_end_hook: {\n    zh: `翻译完成钩子函数`,\n    en: `Translate End Hook`,\n    zh_TW: `翻譯完成 Hook`,\n    ja: `翻訳完了フック`,\n    ko: `번역 완료 후크`,\n  },\n  translate_end_hook_helper: {\n    zh: `翻译完成时运行，入参为： ({hostNode, parentNode, nodes, wrapperNode, innerNode})`,\n    en: `Run when translation is complete, input parameters are: ({hostNode, parentNode, nodes, wrapperNode, innerNode})`,\n    zh_TW: `翻譯完成時運行，入參為： ({hostNode, parentNode, nodes, wrapperNode, innerNode})`,\n    ja: `翻訳完了時に実行、入力パラメータ: ({hostNode, parentNode, nodes, wrapperNode, innerNode})`,\n    ko: `번역 완료 시 실행, 입력 매개변수: ({hostNode, parentNode, nodes, wrapperNode, innerNode})`,\n  },\n  translate_remove_hook: {\n    zh: `翻译移除钩子函数`,\n    en: `Translate Removed Hook`,\n    zh_TW: `翻譯移除 Hook`,\n    ja: `翻訳削除フック`,\n    ko: `번역 제거 후크`,\n  },\n  translate_remove_hook_helper: {\n    zh: `翻译移除时运行，入参为： 翻译节点。`,\n    en: `Run when translation is removed, the input parameters are: translation node.`,\n    zh_TW: `移除翻譯時執行，入參為：翻譯節點。`,\n    ja: `翻訳削除時に実行、入力パラメータ: 翻訳ノード。`,\n    ko: `번역 제거 시 실행, 입력 매개변수: 번역 노드.`,\n  },\n  english_dict: {\n    zh: `英文词典`,\n    en: `English Dictionary`,\n    zh_TW: `英文字典`,\n    ja: `英語辞書`,\n    ko: `영어 사전`,\n  },\n  english_suggest: {\n    zh: `英文建议`,\n    en: `English Suggest`,\n    zh_TW: `英文建議`,\n    ja: `英語サジェスト`,\n    ko: `영어 제안`,\n  },\n  api_name: {\n    zh: `接口名称`,\n    en: `API Name`,\n    zh_TW: `介面名稱`,\n    ja: `API名`,\n    ko: `API 이름`,\n  },\n  sort_order: {\n    zh: `排序权重`,\n    en: `Sort Order`,\n    zh_TW: `排序權重`,\n    ja: `ソート順序`,\n    ko: `정렬 순서`,\n  },\n  sort_order_help: {\n    zh: `数值越小越靠前`,\n    en: `Smaller values appear first`,\n    zh_TW: `數值越小越靠前`,\n    ja: `小さい値が先に表示されます`,\n    ko: `작은 값이 먼저 표시됩니다`,\n  },\n  is_disabled: {\n    zh: `是否禁用`,\n    en: `Is Disabled`,\n    zh_TW: `是否停用`,\n    ja: `無効にする`,\n    ko: `비활성화 여부`,\n  },\n  translate_selected: {\n    zh: `是否启用划词翻译`,\n    en: `If translate selected`,\n    zh_TW: `是否啟用劃詞翻譯`,\n    ja: `選択範囲の翻訳を有効にする`,\n    ko: `선택 번역 사용 여부`,\n  },\n  use_batch_fetch: {\n    zh: `是否聚合发送翻译请求`,\n    en: `Whether to aggregate and send translation requests`,\n    zh_TW: `是否聚合發送翻譯請求`,\n    ja: `翻訳リクエストをまとめて送信`,\n    ko: `번역 요청 일괄 전송 여부`,\n  },\n  batch_interval: {\n    zh: `聚合请求等待时间(10-10000)`,\n    en: `Aggregation request waiting time (10-10000)`,\n    zh_TW: `聚合請求等待時間(10-10000)`,\n    ja: `一括リクエストの待機時間(10-10000)`,\n    ko: `일괄 요청 대기 시간(10-10000)`,\n  },\n  batch_size: {\n    zh: `聚合请求最大段落数(1-100)`,\n    en: `Maximum number of paragraphs in an aggregation request (1-100)`,\n    zh_TW: `聚合請求最大段落數(1-100)`,\n    ja: `一括リクエストの最大段落数(1-100)`,\n    ko: `일괄 요청 최대 단락 수(1-100)`,\n  },\n  batch_length: {\n    zh: `聚合请求最大文本长度(1000-100000)`,\n    en: `Maximum text length for aggregation requests (1000-100000)`,\n    zh_TW: `聚合請求最大文字長度(1000-100000)`,\n    ja: `一括リクエストの最大テキスト長(1000-100000)`,\n    ko: `일괄 요청 최대 텍스트 길이(1000-100000)`,\n  },\n  use_stream: {\n    zh: `是否启用流式传输`,\n    en: `Whether to enable streaming`,\n    zh_TW: `是否啟用串流傳輸`,\n    ja: `ストリーミングを有効にする`,\n    ko: `스트리밍 활성화 여부`,\n  },\n  use_context: {\n    zh: `是否启用智能上下文`,\n    en: `Whether to enable AI context`,\n    zh_TW: `是否啟用智慧上下文`,\n    ja: `AIコンテキストを有効にする`,\n    ko: `AI 컨텍스트 활성화 여부`,\n  },\n  context_size: {\n    zh: `上下文会话数量(1-20)`,\n    en: `Number of context sessions(1-20)`,\n    zh_TW: `上下文會話數量(1-20)`,\n    ja: `コンテキストセッション数(1-20)`,\n    ko: `컨텍스트 세션 수(1-20)`,\n  },\n  auto_scan_page: {\n    zh: `自动扫描页面`,\n    en: `Auto scan page`,\n    zh_TW: `自動掃描頁面`,\n    ja: `ページを自動スキャン`,\n    ko: `페이지 자동 스캔`,\n  },\n  has_rich_text: {\n    zh: `启用富文本翻译`,\n    en: `Enable rich text translation`,\n    zh_TW: `啟用富文本翻譯`,\n    ja: `リッチテキスト翻訳を有効にする`,\n    ko: `리치 텍스트 번역 활성화`,\n  },\n  has_shadowroot: {\n    zh: `扫描Shadowroot`,\n    en: `Scan Shadowroot`,\n    zh_TW: `掃描Shadowroot`,\n    ja: `Shadowrootをスキャン`,\n    ko: `Shadowroot 스캔`,\n  },\n  mousehover_translate: {\n    zh: `鼠标悬停翻译`,\n    en: `Mouseover Translation`,\n    zh_TW: `滑鼠懸停翻譯`,\n    ja: `マウスオーバー翻訳`,\n    ko: `마우스오버 번역`,\n  },\n  use_mousehover_translation: {\n    zh: `启用鼠标悬停翻译`,\n    en: `Enable mouseover translation`,\n    zh_TW: `啟用滑鼠懸停翻譯`,\n    ja: `マウスオーバー翻訳を有効にする`,\n    ko: `마우스오버 번역 활성화`,\n  },\n  selected_translation_alert: {\n    zh: `划词翻译的开启和关闭请到“规则设置”里面设置。`,\n    en: `To turn selected translation on or off, please go to \"Rule Settings\".`,\n    zh_TW: `劃詞翻譯的開啟和關閉請到「規則設定」裡面設定。`,\n    ja: `選択翻訳のオン/オフは「ルール設定」で行ってください。`,\n    ko: `선택 번역 활성화/비활성화는 \"규칙 설정\"에서 하십시오.`,\n  },\n  mousehover_key_help: {\n    zh: `当快捷键置空时表示鼠标懸停直接翻译`,\n    en: `When the shortcut key is empty, it means that the mouse hovers to translate directly`,\n    zh_TW: `當快捷鍵置空時表示滑鼠懸停直接翻譯`,\n    ja: `ショートカットキーが空の場合、マウスオーバーで直接翻訳します`,\n    ko: `단축키가 비어 있으면 마우스오버 시 바로 번역합니다`,\n  },\n  autoscan_alt: {\n    zh: `自动扫描`,\n    en: `Auto Scan`,\n    zh_TW: `自動掃描`,\n    ja: `自動スキャン`,\n    ko: `자동 스캔`,\n  },\n  scan_all_nodes: {\n    zh: `扫描全部节点`,\n    en: `Scan All Nodes`,\n    zh_TW: `掃描全部節點`,\n    ja: `すべてのノードをスキャン`,\n    ko: `모든 노드 스캔`,\n  },\n  shadowroot_alt: {\n    zh: `ShadowRoot`,\n    en: `ShadowRoot`,\n    zh_TW: `ShadowRoot`,\n    ja: `ShadowRoot`,\n    ko: `ShadowRoot`,\n  },\n  richtext_alt: {\n    zh: `保留富文本`,\n    en: `Rich Text`,\n    zh_TW: `保留富文本`,\n    ja: `リッチテキスト`,\n    ko: `리치 텍스트`,\n  },\n  transonly_alt: {\n    zh: `隐藏原文`,\n    en: `Hide Original`,\n    zh_TW: `隱藏原文`,\n    ja: `原文を隠す`,\n    ko: `원문 숨기기`,\n  },\n  confirm_title: {\n    zh: `确认`,\n    en: `Confirm`,\n    zh_TW: `確認`,\n    ja: `確認`,\n    ko: `확인`,\n  },\n  confirm_message: {\n    zh: `确定操作吗？`,\n    en: `Are you sure you want to proceed?`,\n    zh_TW: `確定操作嗎？`,\n    ja: `操作を続行しますか？`,\n    ko: `계속하시겠습니까?`,\n  },\n  confirm_action: {\n    zh: `确定`,\n    en: `Confirm`,\n    zh_TW: `確定`,\n    ja: `確認`,\n    ko: `확인`,\n  },\n  cancel_action: {\n    zh: `取消`,\n    en: `Cancel`,\n    zh_TW: `取消`,\n    ja: `キャンセル`,\n    ko: `취소`,\n  },\n  pls_press_shortcut: {\n    zh: `请按下快捷键组合`,\n    en: `Please press the shortcut key combination`,\n    zh_TW: `請按下快速鍵組合`,\n    ja: `ショートカットキーを押してください`,\n    ko: `단축키 조합을 누르세요`,\n  },\n  load_setting_err: {\n    zh: `数据加载出错，请刷新页面或卸载后重新安装。`,\n    en: `Please press the shortcut key combination`, // 注意：这里的英文和繁体是用户上次错误的拷贝\n    zh_TW: `請按下快速鍵組合`, // 注意：这里的英文和繁体是用户上次错误的拷贝\n    ja: `データ読み込みエラー。ページを更新するか、アンインストール後に再インストールしてください。`, // 翻译自 \"zh\"\n    ko: `데이터 로딩 오류. 페이지를 새로 고치거나 제거 후 다시 설치하세요.`, // 翻译自 \"zh\"\n  },\n  translation_style: {\n    zh: `翻译风格`,\n    en: `Translation style`,\n    zh_TW: `翻譯風格`,\n    ja: `翻訳スタイル`,\n    ko: `번역 스타일`,\n  },\n  placeholder: {\n    zh: `占位符`,\n    en: `Placeholder`,\n    zh_TW: `佔位符`,\n    ja: `プレースホルダー`,\n    ko: `플레이스홀더`,\n  },\n  tag_name: {\n    zh: `占位标签名`,\n    en: `Placeholder tag name`,\n    zh_TW: `佔位標名`,\n    ja: `プレースホルダータグ名`,\n    ko: `플레이스홀더 태그 이름`,\n  },\n  system_prompt_helper_1: {\n    zh: `1. 根据实际情况选择AI支持的聚合格式：`,\n    en: `1. Select the aggregation format supported by the AI according to your needs:`,\n    zh_TW: `1. 請依實際情況選擇 AI 支援的聚合格式：`,\n    ja: `1. 実際の状況に応じて、AI が対応している集約形式を選択してください：`,\n    ko: `1. 상황에 맞게 AI에서 지원하는 집계 형식을 선택하세요:`,\n  },\n  json_output: {\n    zh: `点击切换 “JSON 格式“`,\n    en: `Click to switch to \"JSON Format\"`,\n    zh_TW: `點擊切換「JSON 格式」`,\n    ja: `クリックして「JSON 形式」に切り替え`,\n    ko: `클릭하여 \"JSON 형식\"으로 전환`,\n  },\n  xml_output: {\n    zh: `点击切换 “XML 格式“`,\n    en: `Click to switch to \"XML Format\"`,\n    zh_TW: `點擊切換「XML 格式」`,\n    ja: `クリックして「XML 形式」に切り替え`,\n    ko: `클릭하여 \"XML 형식\"으로 전환`,\n  },\n  textlines_output: {\n    zh: `点击切换 “多行文本格式“`,\n    en: `Click to switch to \"Multi-line Text Format\"`,\n    zh_TW: `點擊切換「多行文字格式」`,\n    ja: `クリックして「複数行テキスト形式」に切り替え`,\n    ko: `클릭하여 \"여러 줄 텍스트 형식\"으로 전환`,\n  },\n  system_prompt_helper_2: {\n    zh: `2. 在未完全理解默认Prompt的情况下，请勿随意修改，否则可能无法工作。`,\n    en: `2. Do not modify the default prompt without fully understanding it, otherwise it may not work.`,\n    zh_TW: `2. 在未完全理解預設Prompt的情況下，請勿隨意修改，否則可能無法運作。`,\n    ja: `2. デフォルトのプロンプトを完全に理解せずに変更しないでください。動作しなくなる可能性があります。`,\n    ko: `2. 기본 프롬프트를 완전히 이해하지 않고 수정하지 마십시오. 작동하지 않을 수 있습니다.`,\n  },\n  if_pre_init: {\n    zh: `是否预初始化`,\n    en: `Whether to pre-initialize`,\n    zh_TW: `是否預初始化`,\n    ja: `事前初期化するか`,\n    ko: `사전 초기화 여부`,\n  },\n  export_old: {\n    zh: `导出旧版`,\n    en: `Export old version`,\n    zh_TW: `匯出舊版`,\n    ja: `旧バージョンをエクスポート`,\n    ko: `이전 버전 내보내기`,\n  },\n  favorite_words_helper: {\n    zh: `导入词汇请使用txt文件，每一行一个单词。`,\n    en: `To import vocabulary, please use a txt file with one word per line.`,\n    zh_TW: `匯入詞彙請使用txt文件，每一行一個單字。`,\n    ja: `単語をインポートするには、1行に1単語ずつ記述したtxtファイルを使用してください。`,\n    ko: `단어를 가져오려면 한 줄에 한 단어씩 .txt 파일을 사용하세요.`,\n  },\n  btn_tip_click_away: {\n    zh: `失焦隐藏/显示`,\n    en: `Loss of focus hide/show`,\n    zh_TW: `失焦隱藏/顯示`,\n    ja: `フォーカスを失った時に非表示/表示`,\n    ko: `포커스 잃을 시 숨기기/표시`,\n  },\n  btn_tip_follow_selection: {\n    zh: `跟随/固定模式`,\n    en: `Follow/Fixed Mode`,\n    zh_TW: `跟隨/固定模式`,\n    ja: `追従/固定モード`,\n    ko: `따라가기/고정 모드`,\n  },\n  btn_tip_simple_style: {\n    zh: `迷你/常规模式`,\n    en: `Mini/Regular Mode`,\n    zh_TW: `迷你/常規模式`,\n    ja: `ミニ/通常モード`,\n    ko: `미니/일반 모드`,\n  },\n  api_placeholder: {\n    zh: `占位符`,\n    en: `Placeholder`,\n    zh_TW: `佔位符`,\n    ja: `プレースホルダー`,\n    ko: `플레이스홀더`,\n  },\n  api_placetag: {\n    zh: `占位标签`,\n    en: `Placeholder tags`,\n    zh_TW: `佔位標`,\n    ja: `プレースホルダタグ`,\n    ko: `플레이스홀더 태그`,\n  },\n  placetag_format: {\n    zh: `占位符格式`,\n    en: `Placeholder Format`,\n    zh_TW: `佔位符格式`,\n    ja: `プレースホルダー形式`,\n    ko: `자리 표시자 형식`,\n  },\n  format_compact: {\n    zh: `简洁格式 <a1>`,\n    en: `Compact Format <a1>`,\n    zh_TW: `簡潔格式 <a1>`,\n    ja: `簡潔形式 <a1>`,\n    ko: `간결 형식 <a1>`,\n  },\n  format_attribute: {\n    zh: `属性格式 <a i=1>`,\n    en: `Attribute Format <a i=1>`,\n    zh_TW: `屬性格式 <a i=1>`,\n    ja: `属性形式 <a i=1>`,\n    ko: `속성 형식 <a i=1>`,\n  },\n  detected_lang: {\n    zh: `语言检测`,\n    en: `Language detection`,\n    zh_TW: `語言偵測`,\n    ja: `言語検出`,\n    ko: `언어 감지`,\n  },\n  detected_result: {\n    zh: `检测结果`,\n    en: `Detect result`,\n    zh_TW: `檢測結果`,\n    ja: `検出結果`,\n    ko: `감지 결과`,\n  },\n  subtitle_translate: {\n    zh: `字幕翻译`,\n    en: `Subtitle Translation`,\n    zh_TW: `字幕翻譯`,\n    ja: `字幕翻訳`,\n    ko: `자막 번역`,\n  },\n  toggle_subtitle_translate: {\n    zh: `启用字幕翻译`,\n    en: `Enable subtitle translation`,\n    zh_TW: `啟用字幕翻譯`,\n    ja: `字幕翻訳を有効にする`,\n    ko: `자막 번역 활성화`,\n  },\n  is_bilingual_view: {\n    zh: `双语显示`,\n    en: `Enable bilingual display`,\n    zh_TW: `雙語顯示`,\n    ja: `バイリンガル表示`,\n    ko: `이중 언어 표시`,\n  },\n  is_skip_ad: {\n    zh: `快进广告`,\n    en: `Skip AD`,\n    zh_TW: `快轉廣告`,\n    ja: `広告をスキップ`,\n    ko: `광고 건너뛰기`,\n  },\n  download_subtitles: {\n    zh: `下载字幕`,\n    en: `Download subtitles`,\n    zh_TW: `下载字幕`,\n    ja: `字幕をダウンロード`,\n    ko: `자막 다운로드`,\n  },\n  background_styles: {\n    zh: `背景样式`,\n    en: `DBackground Style`,\n    zh_TW: `背景樣式`,\n    ja: `背景スタイル`,\n    ko: `배경 스타일`,\n  },\n  origin_styles: {\n    zh: `原文样式`,\n    en: `Original style`,\n    zh_TW: `原文樣式`,\n    ja: `原文スタイル`,\n    ko: `원문 스타일`,\n  },\n  translation_styles: {\n    zh: `译文样式`,\n    en: `Translation style`,\n    zh_TW: `譯文樣式`,\n    ja: `翻訳スタイル`,\n    ko: `번역문 스타일`,\n  },\n  ai_segmentation: {\n    zh: `AI智能断句`,\n    en: `AI intelligent punctuation`,\n    zh_TW: `AI智慧斷句`,\n    ja: `AIによるインテリジェントな文分割`,\n    ko: `AI 지능형 문장 분리`,\n  },\n  ai_chunk_length: {\n    zh: `AI处理切割长度(200-20000)`,\n    en: `AI processing chunk length(200-20000)`,\n    zh_TW: `AI处理切割长度(200-20000)`,\n    ja: `AI処理のチャンク長(200-20000)`,\n    ko: `AI 처리 청크 길이(200-20000)`,\n  },\n  subtitle_helper_1: {\n    zh: `1、目前仅支持Youtube桌面网站。`,\n    en: `1. Currently only supports Youtube desktop website.`,\n    zh_TW: `1.目前僅支援Youtube桌面網站，且僅支援瀏覽器擴充功能。`,\n    ja: `1. 現在、Youtubeのデスクトップサイトのみサポートしています。`,\n    ko: `1. 현재 Youtube 데스크톱 웹사이트만 지원합니다.`,\n  },\n  subtitle_helper_2: {\n    zh: `2、插件内置基础的字幕合并、断句算法，可满足大部分情况。`,\n    en: `2. The plug-in has built-in basic subtitle merging and sentence segmentation algorithms, which can meet most situations.`,\n    zh_TW: `2.插件內建基礎的字幕合併、斷句演算法，可滿足大部分情況。`,\n    ja: `2. プラグインには基本的な字幕結合と文分割アルゴリズムが組み込まれており、ほとんどの状況に対応できます。`,\n    ko: `2. 플러그인에는 기본적인 자막 병합 및 문장 분리 알고리즘이 내장되어 있어 대부분의 상황에 대응할 수 있습니다.`,\n  },\n  subtitle_helper_3: {\n    zh: `3、亦可以启用AI智能断句，但需考虑切割长度及AI接口能力，可能处理时间会很长，甚至处理失败，导致无法看到字幕。`,\n    en: `3. You can also enable AI intelligent segmentation, but you need to consider the segmentation length and AI interface capabilities. The processing time may be very long or even fail, resulting in the inability to see subtitles.`,\n    zh_TW: `3.亦可啟用AI智能斷句，但需考慮切割長度及AI介面能力，可能處理時間會很長，甚至處理失敗，導致無法看到字幕。`,\n    ja: `3. AIインテリジェント文分割を有効にすることもできますが、分割長とAIインターフェースの能力を考慮する必要があり、処理時間が長くなったり、失敗して字幕が表示されなくなる可能性があります。`,\n    ko: `3. AI 지능형 분리를 활성화할 수도 있지만, 분리 길이와 AI 인터페이스의 능력을 고려해야 하며, 처리 시간이 매우 길거나 실패하여 자막을 볼 수 없게 될 수도 있습니다.`,\n  },\n  show_subtitle_list: {\n    zh: `显示字幕列表`,\n    en: `Show Subtitle List`,\n    zh_TW: `顯示字幕列表`,\n    ja: `字幕リストを表示`,\n    ko: `자막 목록 표시`,\n  },\n  default_styles_example: {\n    zh: `默认样式参考：`,\n    en: `Default styles reference:`,\n    zh_TW: `認樣式參考：`,\n    ja: `デフォルトスタイルの例：`,\n    ko: `기본 스타일 예시:`,\n  },\n  subtitle_load_succeed: {\n    zh: `双语字幕加载成功！`,\n    en: `Bilingual subtitles loaded successfully!`,\n    zh_TW: `双语字幕加载成功！`,\n    ja: `バイリンガル字幕の読み込みに成功しました！`,\n    ko: `이중 언어 자막 로딩 성공!`,\n  },\n  subtitle_load_failed: {\n    zh: `双语字幕加载失败！`,\n    en: `Failed to load bilingual subtitles!`,\n    zh_TW: `双语字幕加载失败！`,\n    ja: `バイリンガル字幕の読み込みに失敗しました！`,\n    ko: `이중 언어 자막 로딩 실패!`,\n  },\n  try_get_subtitle_data: {\n    zh: `尝试获取字幕数据，请稍候...`,\n    en: `Trying to get subtitle data, please wait...`,\n    zh_TW: `尝试获取字幕数据，请稍候...`,\n    ja: `字幕データを取得しています。お待ちください...`,\n    ko: `자막 데이터를 가져오는 중입니다. 잠시 기다려주세요...`,\n  },\n  subtitle_data_processing: {\n    zh: `字幕数据处理中...`,\n    en: `Subtitle data processing...`,\n    zh_TW: `字幕数据处理中...`,\n    ja: `字幕データを処理中...`,\n    ko: `자막 데이터 처리 중...`,\n  },\n  starting_to_process_subtitle: {\n    zh: `开始处理字幕数据...`,\n    en: `Starting to process subtitle data...`,\n    zh_TW: `开始处理字幕数据...`,\n    ja: `字幕データの処理を開始します...`,\n    ko: `자막 데이터 처리를 시작합니다...`,\n  },\n  subtitle_data_is_ready: {\n    zh: `字幕数据已准备就绪，请点击KT按钮加载`,\n    en: `The subtitle data is ready, please click the KT button to load it`,\n    zh_TW: `字幕資料已準備就緒，請點擊KT按鈕加載`,\n    ja: `字幕データの準備ができました。KTボタンをクリックして読み込んでください`,\n    ko: `자막 데이터가 준비되었습니다. KT 버튼을 클릭하여 로드하세요`,\n  },\n  starting_reprocess_events: {\n    zh: `重新处理字幕数据...`,\n    en: `Reprocess the subtitle data...`,\n    zh_TW: `重新处理字幕数据...`,\n    ja: `字幕データを再処理しています...`,\n    ko: `자막 데이터를 다시 처리 중...`,\n  },\n  waitting_for_subtitle: {\n    zh: `请等待字幕数据`,\n    en: `Please wait for the subtitle data.`,\n    zh_TW: `请等待字幕数据`,\n    ja: `字幕データを待機中`,\n    ko: `자막 데이터를 기다려주세요`,\n  },\n  ai_processing_pls_wait: {\n    zh: `AI处理中，请稍等...`,\n    en: `AI processing in progress, please wait...`,\n    zh_TW: `AI处理中，请稍等...`,\n    ja: `AI処理中です。お待ちください...`,\n    ko: `AI 처리 중입니다. 잠시 기다려주세요...`,\n  },\n  processing_subtitles: {\n    zh: `字幕处理中...`,\n    en: `Subtitle processing...`,\n    zh_TW: `字幕处理中...`,\n    ja: `字幕処理中...`,\n    ko: `자막 처리 중...`,\n  },\n  waiting_subtitles: {\n    zh: `等待字幕中`,\n    en: `Waiting for subtitles`,\n    zh_TW: `等待字幕中`,\n    ja: `字幕待機中`,\n    ko: `자막 대기 중`,\n  },\n  subtitle_is_not_yet_ready: {\n    zh: `字幕数据尚未准备好`,\n    en: `Subtitle is not yet ready.`,\n    zh_TW: `字幕数据尚未准备好`,\n    ja: `字幕データの準備がまだできていません。`,\n    ko: `자막 데이터가 아직 준비되지 않았습니다.`,\n  },\n  log_level: {\n    zh: `日志级别`,\n    en: `Log Level`,\n    zh_TW: `日誌等級`,\n    ja: `ログレベル`,\n    ko: `로그 레벨`,\n  },\n  goto_custom_api_example: {\n    zh: `点击查看【自定义接口示例】`,\n    en: `Click to view [Custom Interface Example]`,\n    zh_TW: `點選查看【自訂介面範例】`,\n    ja: `【カスタムインターフェースの例】を見る`,\n    ko: `[사용자 지정 인터페이스 예시] 보기`,\n  },\n  split_paragraph: {\n    zh: `切分长段落`,\n    en: `Split long paragraph`,\n    zh_TW: `切分長段落`,\n    ja: `長い段落を分割`,\n    ko: `긴 단락 나누기`,\n  },\n  split_length: {\n    zh: `切分长度 (0-10000)`,\n    en: `Segmentation length(0-10000)`,\n    zh_TW: `切分長度(0-10000)`,\n    ja: `分割長(0-10000)`,\n    ko: `분할 길이(0-10000)`,\n  },\n  highlight_words: {\n    zh: `高亮收藏词汇`,\n    en: `Highlight favorite words`,\n    zh_TW: `高亮收藏詞彙`,\n    ja: `お気に入り単語をハイライト`,\n    ko: `즐겨찾는 단어 하이라이트`,\n  },\n  split_disable: {\n    zh: `禁用`,\n    en: `Disable`,\n    zh_TW: `停用`,\n    ja: `無効`,\n    ko: `비활성화`,\n  },\n  split_textlength: {\n    zh: `按照长度切分`,\n    en: `Split by length`,\n    zh_TW: `依長度切分`,\n    ja: `長さで分割`,\n    ko: `길이로 나누기`,\n  },\n  split_punctuation: {\n    zh: `按照句子切分`,\n    en: `Split by sentence`,\n    zh_TW: `按照句子切分`,\n    ja: `文で分割`,\n    ko: `문장으로 나누기`,\n  },\n  highlight_disable: {\n    zh: `禁用`,\n    en: `Disable`,\n    zh_TW: `停用`,\n    ja: `無効`,\n    ko: `비활성화`,\n  },\n  highlight_beforetrans: {\n    zh: `翻译前高亮`,\n    en: `Highlight before translation`,\n    zh_TW: `翻譯前高亮`,\n    ja: `翻訳前にハイライト`,\n    ko: `번역 전 하이라이트`,\n  },\n  highlight_aftertrans: {\n    zh: `翻译后高亮`,\n    en: `Highlight after translation`,\n    zh_TW: `翻譯後高亮`,\n    ja: `翻訳後にハイライト`,\n    ko: `번역 후 하이라이트`,\n  },\n  pagescroll_root_margin: {\n    zh: `滚动加载提前触发 (0-10000px)`,\n    en: `Early triggering of scroll loading (0-10000px)`,\n    zh_TW: `滾動載入提前觸發 (0-10000px)`,\n    ja: `スクロール読み込みの事前トリガー (0-10000px)`,\n    ko: `스크롤 로딩 미리 트리거 (0-10000px)`,\n  },\n  styles_setting: {\n    zh: `样式设置`,\n    en: `Style Setting`,\n    zh_TW: `樣式設定`,\n    ja: `スタイル設定`,\n    ko: `스타일 설정`,\n  },\n  style_name: {\n    zh: `样式名称`,\n    en: `Style Name`,\n    zh_TW: `樣式名稱`,\n    ja: `スタイル名`,\n    ko: `스타일 이름`,\n  },\n  style_code: {\n    zh: `样式代码`,\n    en: `Style Code`,\n    zh_TW: `樣式程式碼`,\n    ja: `スタイルコード`,\n    ko: `스타일 코드`,\n  },\n  pre_trans_seconds: {\n    zh: `提前翻译时长 (10-36000s)`,\n    en: `Pre translation seconds (10-36000s)`,\n    zh_TW: `提前翻译时长 (10-36000s)`,\n    ja: `事前翻訳時間 (10-36000s)`,\n    ko: `미리 번역 시간 (10-36000s)`,\n  },\n  throttle_trans_interval: {\n    zh: `节流翻译间隔 (1-3600s)`,\n    en: `Throttling translation interval (1-3600s)`,\n    zh_TW: `节流翻译间隔 (1-3600s)`,\n    ja: `翻訳間隔のスロットリング (1-3600s)`,\n    ko: `번역 간격 조절 (1-3600s)`,\n  },\n  show_origin_subtitle: {\n    zh: `显示原字幕`,\n    en: `Show original subtitles`,\n    zh_TW: `显示原字幕`,\n    ja: `原字幕を表示`,\n    ko: `원본 자막 표시`,\n  },\n  subtitle_same_lang: {\n    zh: `原语言与目标语言相同，字幕不予处理`,\n    en: `The source language is the same as the target language, subtitles will not be processed`,\n    zh_TW: `原語言與目標語言相同時，字幕不予處理`,\n    ja: `原言語と目標言語が同じ場合、字幕は処理されません`,\n    ko: `원본 언어와 대상 언어가 동일한 경우, 자막은 처리되지 않습니다`,\n  },\n  plain_text_translate: {\n    zh: `纯文本翻译`,\n    en: `Plain text translation`,\n    zh_TW: `純文字翻譯`,\n    ja: `プレーンテキスト翻訳`,\n    ko: `순수 텍스트 번역`,\n  },\n  is_enable_enhance: {\n    zh: `启用增强功能`,\n    en: `Enable Enhancement Features`,\n    zh_TW: `啟用增強功能`,\n    ja: `強化機能を有効にする`,\n    ko: `향상 기능 활성화`,\n  },\n  open_separate_window: {\n    zh: `独立窗口打开`,\n    en: `Open in Separate Window`,\n    zh_TW: `在獨立視窗中開啟`,\n    ja: `別ウィンドウで開く`,\n    ko: `별도 창에서 열기`,\n  },\n  comment_support: {\n    zh: `好评支持`,\n    en: `Leave a Positive Review`,\n    zh_TW: `好評支持`,\n    ja: `高評価で応援`,\n    ko: `좋은 평가로 응원`,\n  },\n  appreciate_support: {\n    zh: `赞赏支持`,\n    en: `Support with a Tip`,\n    zh_TW: `贊賞支持`,\n    ja: `投げ銭で応援`,\n    ko: `후원하기`,\n  },\n  toggle_transbox: {\n    zh: `切换翻译窗`,\n    en: `Toggle Translation Box`,\n    zh_TW: `切換翻譯視窗`,\n    ja: `翻訳ウィンドウを切り替え`,\n    ko: `번역 창 전환`,\n  },\n  copy: {\n    zh: `复制`,\n    en: `Copy`,\n    zh_TW: `複製`,\n    ja: `コピー`,\n    ko: `복사`,\n  },\n  paste: {\n    zh: `黏贴`,\n    en: `Paste`,\n    zh_TW: `貼上`,\n    ja: `貼り付け`,\n    ko: `붙여넣기`,\n  },\n  submit: {\n    zh: `提交`,\n    en: `Submit`,\n    zh_TW: `提交`,\n    ja: `送信`,\n    ko: `제출`,\n  },\n  collect: {\n    zh: `收藏`,\n    en: `Save`,\n    zh_TW: `收藏`,\n    ja: `保存`,\n    ko: `저장`,\n  },\n  show_translation_dot: {\n    zh: `显示翻译圆点`,\n    en: `Show Translation Dot`,\n    zh_TW: `顯示翻譯圓點`,\n    ja: `翻訳ドットを表示`,\n    ko: `번역 점 표시`,\n  },\n  show_dot_mobile: {\n    zh: `仅移动端`,\n    en: `Mobile Only`,\n    zh_TW: `僅移動端`,\n    ja: `モバイルのみ`,\n    ko: `모바일 전용`,\n  },\n  show_dot_always: {\n    zh: `总是显示`,\n    en: `Always`,\n    zh_TW: `總是顯示`,\n    ja: `常に表示`,\n    ko: `항상 표시`,\n  },\n  show_dot_disable: {\n    zh: `禁用`,\n    en: `Disable`,\n    zh_TW: `禁用`,\n    ja: `無効`,\n    ko: `사용 안 함`,\n  },\n};\n\nexport const newI18n = (lang) => (key) => I18N[key]?.[lang] || \"\";\n"
  },
  {
    "path": "src/config/index.js",
    "content": "export * from \"./app\";\nexport * from \"./rules\";\nexport * from \"./api\";\nexport * from \"./setting\";\nexport * from \"./i18n\";\nexport * from \"./storage\";\nexport * from \"./url\";\nexport * from \"./msg\";\nexport * from \"./client\";\nexport * from \"./styles\";\n"
  },
  {
    "path": "src/config/msg.js",
    "content": "export const CMD_TOGGLE_TRANSLATE = \"toggleTranslate\";\nexport const CMD_TOGGLE_STYLE = \"toggleStyle\";\nexport const CMD_OPEN_OPTIONS = \"openOptions\";\nexport const CMD_OPEN_TRANBOX = \"openTranbox\";\nexport const CMD_OPEN_SEPARATE_WINDOW = \"openSeparateWindow\";\n\nexport const MSG_FETCH = \"kiss_fetch\";\nexport const MSG_GET_HTTPCACHE = \"get_httpcache\";\nexport const MSG_PUT_HTTPCACHE = \"put_httpcache\";\nexport const MSG_OPEN_OPTIONS = \"open_options\";\nexport const MSG_SAVE_RULE = \"save_rule\";\nexport const MSG_TRANS_TOGGLE = \"toggle_translate\";\nexport const MSG_TRANS_TOGGLE_STYLE = \"toggle_styles\";\nexport const MSG_OPEN_TRANBOX = \"open_tranbox\";\nexport const MSG_TRANS_GETRULE = \"trans_getrule\";\nexport const MSG_TRANS_PUTRULE = \"trans_putrule\";\nexport const MSG_TRANS_CURRULE = \"trans_currule\";\nexport const MSG_TRANSBOX_TOGGLE = \"toggle_transbox\";\nexport const MSG_POPUP_TOGGLE = \"toggle_popup\";\nexport const MSG_MOUSEHOVER_TOGGLE = \"toggle_mousehover\";\nexport const MSG_HOVERNODE_TOGGLE = \"toggle_hover_node\";\nexport const MSG_TRANSINPUT_TOGGLE = \"toggle_input_translation\";\nexport const MSG_INPUT_TRANSLATE = \"input_translate\";\nexport const MSG_CONTEXT_MENUS = \"context_menus\";\nexport const MSG_COMMAND_SHORTCUTS = \"command_shortcuts\";\nexport const MSG_INJECT_JS = \"inject_js\";\nexport const MSG_INJECT_CSS = \"inject_css\";\nexport const MSG_UPDATE_CSP = \"update_csp\";\nexport const MSG_BUILTINAI_DETECT = \"builtinai_detect\";\nexport const MSG_BUILTINAI_TRANSLATE = \"builtinai_translte\";\nexport const MSG_SET_LOGLEVEL = \"set_loglevel\";\nexport const MSG_CLEAR_CACHES = \"clear_caches\";\nexport const MSG_OPEN_SEPARATE_WINDOW = \"open_separate_window\";\nexport const PORT_STREAM_FETCH = \"kiss_stream_fetch\";\nexport const MSG_UPDATE_ICON = \"update_icon\";\n\nexport const EVENT_KISS_INNER = \"kiss_translator_inner\";\nexport const EVENT_KISS_TRANSLATOR = \"kiss_translator\";\n\nexport const MSG_XHR_DATA_YOUTUBE = \"KISS_XHR_DATA_YOUTUBE\";\n// export const MSG_GLOBAL_VAR_FETCH = \"KISS_GLOBAL_VAR_FETCH\";\n// export const MSG_GLOBAL_VAR_BACK = \"KISS_GLOBAL_VAR_BACK\";\n\nexport const MSG_MENUS_PROGRESSED = \"progressed\";\nexport const MSG_MENUS_UPDATEFORM = \"updateFormData\";\n"
  },
  {
    "path": "src/config/quotes.js",
    "content": "const quotes = [\n  {\n    en: \"The unexamined life is not worth living.\",\n    zh: \"未经审视的人生不值得过。\",\n    zh_TW: \"未經審視的人生不值得過。\",\n    ja: \"吟味されない人生は生きるに値しない。\",\n    ko: \"성찰하지 않는 삶은 살 가치가 없다。\",\n  },\n  {\n    en: \"I think, therefore I am.\",\n    zh: \"我思故我在。\",\n    zh_TW: \"我思故我在。\",\n    ja: \"我思う、ゆえに我あり。\",\n    ko: \"나는 생각한다, 고로 존재한다。\",\n  },\n  {\n    en: \"He who has a why to live for can bear almost any how.\",\n    zh: \"知道为何而活的人，几乎能忍受任何一种生活。\",\n    zh_TW: \"知道為何而活的人，幾乎能忍受任何一種生活。\",\n    ja: \"生きるための「なぜ」を持つ者は、ほとんどあらゆる「どのように」にも耐えることができる。\",\n    ko: \"살아야 할 이유를 아는 사람은 거의 모든 상황을 견딜 수 있다。\",\n  },\n  {\n    en: \"Life is what happens when you're busy making other plans.\",\n    zh: \"生活就是当你忙着制定其他计划时所发生的事情。\",\n    zh_TW: \"生活就是當你忙著制定其他計劃時所發生的事情。\",\n    ja: \"人生とは、他の計画を立てるのに忙しいときに起こるものだ。\",\n    ko: \"인생은 다른 계획을 세우느라 바쁠 때 일어나는 일이다。\",\n  },\n  {\n    en: \"Get busy living or get busy dying.\",\n    zh: \"要么忙着活，要么忙着死。\",\n    zh_TW: \"要么忙著活，要么忙著死。\",\n    ja: \"必死に生きるか、必死に死ぬかだ。\",\n    ko: \"바쁘게 살거나, 바쁘게 죽거나。\",\n  },\n  {\n    en: \"We are what we repeatedly do. Excellence, then, is not an act, but a habit.\",\n    zh: \"我们由我们反复做的事情构成的。因此，卓越不是一种行为，而是一种习惯。\",\n    zh_TW:\n      \"我們由我們反覆做的事情構成的。因此，卓越不是一種行為，而是一種習慣。\",\n    ja: \"我々は繰り返し行うことの集大成である。卓越とは行為ではなく、習慣なのだ。\",\n    ko: \"우리는 우리가 반복적으로 하는 일의 결과물이다. 그렇다면 탁월함은 행동이 아니라 습관이다。\",\n  },\n  {\n    en: \"Man is condemned to be free.\",\n    zh: \"人注定是自由的。\",\n    zh_TW: \"人註定是自由的。\",\n    ja: \"人間は自由であるように呪われている。\",\n    ko: \"인간은 자유롭도록 저주받았다。\",\n  },\n  {\n    en: \"To be, or not to be: that is the question.\",\n    zh: \"生存还是毁灭，这是一个问题。\",\n    zh_TW: \"生存還是毀滅，這是一個問題。\",\n    ja: \"生きるべきか、死ぬべきか、それが問題だ。\",\n    ko: \"죽느냐 사느냐, 그것이 문제로다。\",\n  },\n  {\n    en: \"The purpose of life is not to be happy. It is to be useful, to be honorable, to be compassionate, to have it make some difference that you have lived and lived well.\",\n    zh: \"人生的目的不是快乐，而是有用、高尚、富有同情心，让你活过并且活得好，从而使世界有所不同。\",\n    zh_TW:\n      \"人生的目的不是快樂，而是有用、高尚、富有同情心，讓你活過並且活得好，從而使世界有所不同。\",\n    ja: \"人生（じんせい）の目的（もくてき）は幸（しあわ）せになることではない。役（やく）に立（た）つこと、名誉（めいよ）あること、思（おも）いやりを持（も）つこと、そして自分（じぶん）が生（い）きてきたこと、よく生（い）きたことが何（なに）かの違（ちが）いをもたらすようにすることだ。\",\n    ko: \"삶의 목적은 행복해지는 것이 아니다. 유용하고, 명예롭고, 자비로우며, 당신이 살았고 잘 살았다는 것이 어떤 차이를 만들도록 하는 것이다。\",\n  },\n  {\n    en: \"Life is 10% what happens to us and 90% how we react to it.\",\n    zh: \"生活 10% 取决于发生在我们身上的事，90% 取决于我们如何反应。\",\n    zh_TW: \"生活 10% 取決於發生在我們身上的事，90% 取決於我們如何反應。\",\n    ja: \"人生は、我々に起こることが10％で、それにどう反応するかが90％だ。\",\n    ko: \"인생은 우리에게 일어나는 일이 10%이고, 그 일에 대해 우리가 어떻게 반응하느냐가 90%이다。\",\n  },\n  {\n    en: \"The two most important days in your life are the day you are born and the day you find out why.\",\n    zh: \"你一生中最重要的两天是：你出生的那天和你明白你为何出生的那天。\",\n    zh_TW: \"你一生中最重要的兩天是：你出生的那天和你明白你為何出生的那天。\",\n    ja: \"人生で最も重要な日は二日ある。生まれた日と、なぜ生まれたかを悟る日だ。\",\n    ko: \"당신의 인생에서 가장 중요한 날은 두 번이다. 당신이 태어난 날과 그 이유를 깨닫는 날이다。\",\n  },\n  {\n    en: \"In three words I can sum up everything I've learned about life: it goes on.\",\n    zh: \"关于人生，我所学到的一切可以总结为三个词：它在继续。\",\n    zh_TW: \"關於人生，我所學到的一切可以總結為三個詞：它在繼續。\",\n    ja: \"人生について学んだすべてを3語でまとめることができる。それは「それでも続く」ということだ。\",\n    ko: \"내가 인생에 대해 배운 모든 것을 세 단어로 요약할 수 있다: '삶은 계속된다'는 것이다。\",\n  },\n  {\n    en: \"Not all those who wander are lost.\",\n    zh: \"并非所有流浪者都迷失了方向。\",\n    zh_TW: \"並非所有流浪者都迷失了方向。\",\n    ja: \"さまよう者がすべて道に迷っているわけではない。\",\n    ko: \"방황하는 자가 다 길을 잃은 것은 아니다。\",\n  },\n  {\n    en: \"Life is simple, but we insist on making it complicated.\",\n    zh: \"生活本简单，但我们坚持要把它弄复杂。\",\n    zh_TW: \"生活本簡單，但我們堅持要把它弄複雜。\",\n    ja: \"人生はシンプルだ。だが我々はそれを複雑にしようと躍起になる。\",\n    ko: \"인생은 단순하지만, 우리가 복잡하게 만들기를 고집한다。\",\n  },\n  {\n    en: \"Our life is what our thoughts make it.\",\n    zh: \"我们的生活是由我们的思想造成的。\",\n    zh_TW: \"我們的生活是由我們的思想造成的。\",\n    ja: \"我々の人生は、我々の思考が作るものだ。\",\n    ko: \"우리의 삶은 우리의 생각이 만드는 것이다。\",\n  },\n  {\n    en: \"Find purpose, the means will follow.\",\n    zh: \"找到目标，方法自会随之而来。\",\n    zh_TW: \"找到目標，方法自會隨之而來。\",\n    ja: \"目的を見つけよ、手段は後からついてくる。\",\n    ko: \"목적을 찾으라, 수단은 따라올 것이다。\",\n  },\n  {\n    en: \"The goal of life is living in agreement with nature.\",\n    zh: \"生活的目标是与自然和谐相处。\",\n    zh_TW: \"生活的目標是與自然和諧相處。\",\n    ja: \"人生の目標は、自然と調和して生きることである。\",\n    ko: \"삶의 목표는 자연과 조화를 이루며 사는 것이다。\",\n  },\n  {\n    en: \"The only true wisdom is in knowing you know nothing.\",\n    zh: \"唯一的真正智慧在于知道自己一无所有。\",\n    zh_TW: \"唯一的真正智慧在於知道自己一無所有。\",\n    ja: \"唯一真の知恵は、自分が何も知らないことを知ることにある。\",\n    ko: \"유일한 참된 지혜는 자신이 아무것도 모른다는 것을 아는 것이다。\",\n  },\n  {\n    en: \"Knowledge is power.\",\n    zh: \"知识就是力量。\",\n    zh_TW: \"知識就是力量。\",\n    ja: \"知識は力なり。\",\n    ko: \"아는 것이 힘이다。\",\n  },\n  {\n    en: \"Knowing yourself is the beginning of all wisdom.\",\n    zh: \"了解自己是所有智慧的开端。\",\n    zh_TW: \"了解自己是所有智慧的開端。\",\n    ja: \"自分自身を知ることが、すべての知恵の始まりである。\",\n    ko: \"자신을 아는 것이 모든 지혜의 시작이다。\",\n  },\n  {\n    en: \"The journey of a thousand miles begins with a single step.\",\n    zh: \"千里之行，始于足下。\",\n    zh_TW: \"千里之行，始於足下。\",\n    ja: \"千里の道も一歩から。\",\n    ko: \"천 리 길도 한 걸음부터。\",\n  },\n  {\n    en: \"The only source of knowledge is experience.\",\n    zh: \"知识的唯一来源是经验。\",\n    zh_TW: \"知識的唯一來源是經驗。\",\n    ja: \"知識の唯一の源泉は経験である。\",\n    ko: \"지식의 유일한 원천은 경험이다。\",\n  },\n  {\n    en: \"A fool thinks himself to be wise, but a wise man knows himself to be a fool.\",\n    zh: \"愚者自以为聪明，智者自知愚蠢。\",\n    zh_TW: \"愚者自以為聰明，智者自知愚蠢。\",\n    ja: \"愚か者は自分を賢いと思うが、賢い者は自分が愚かであることを知っている。\",\n    ko: \"바보는 자신이 현명하다고 생각하지만, 현명한 사람은 자신이 바보라는 것을 안다。\",\n  },\n  {\n    en: \"We learn from failure, not from success!\",\n    zh: \"我们从失败中学习，而不是从成功中！\",\n    zh_TW: \"我們從失敗中學習，而不是從成功中！\",\n    ja: \"我々は成功からではなく、失敗から学ぶ！\",\n    ko: \"우리는 성공이 아닌, 실패로부터 배운다!\",\n  },\n  {\n    en: \"The wise man is one who knows what he does not know.\",\n    zh: \"智者，知其所不知。\",\n    zh_TW: \"智者，知其所不知。\",\n    ja: \"賢い者とは、自分が何を知らないかを知っている者である。\",\n    ko: \"현명한 사람은 자신이 모르는 것을 아는 사람이다。\",\n  },\n  {\n    en: \"To know that we know what we know, and that we do not know what we do not know, that is true knowledge.\",\n    zh: \"知之为知之，不知为不知，是知也。\",\n    zh_TW: \"知之為知之，不知為不知，是知也。\",\n    ja: \"知るを知るとなし、知らざるを知らずとなす、これ知るなり。\",\n    ko: \"아는 것을 안다고 하고, 모르는 것을 모른다고 하는 것, 그것이 참된 앎이다。\",\n  },\n  {\n    en: \"Curiosity is the wick in the candle of learning.\",\n    zh: \"好奇心是学习这支蜡烛的灯芯。\",\n    zh_TW: \"好奇心是學習這支蠟燭的燈芯。\",\n    ja: \"好奇心は、学習というロウソクの芯である。\",\n    ko: \"호기심은 배움이라는 촛불의 심지이다。\",\n  },\n  {\n    en: \"It is the mark of an educated mind to be able to entertain a thought without accepting it.\",\n    zh: \"能够容纳一种思想而不同意它，这是一个受过教育的头脑的标志。\",\n    zh_TW: \"能夠容納一種思想而不同意它，這是一個受過教育的頭腦的標誌。\",\n    ja: \"ある考えを受け入れずに、その考えを持ち続けることができるのが、教育ある精神の証である。\",\n    ko: \"어떤 생각을 받아들이지 않고도 그 생각을 해볼 수 있는 것이 교육받은 마음의 특징이다。\",\n  },\n  {\n    en: \"Never stop questioning.\",\n    zh: \"永远不要停止提问。\",\n    zh_TW: \"永遠不要停止提問。\",\n    ja: \"疑問を持つことを決してやめるな。\",\n    ko: \"질문하는 것을 절대 멈추지 마라。\",\n  },\n  {\n    en: \"The man who asks a question is a fool for a minute, the man who does not ask is a fool for life.\",\n    zh: \"问问题的人，只傻一分钟；不问的人，傻一生。\",\n    zh_TW: \"問問題的人，只傻一分鐘；不問的人，傻一生。\",\n    ja: \"問う者は一時の恥、問わぬ者は一生の恥。\",\n    ko: \"질문하는 사람은 1분 동안 바보가 되지만, 질문하지 않는 사람은 평생 바보가 된다。\",\n  },\n  {\n    en: \"Wisdom is not a product of schooling but of the lifelong attempt to acquire it.\",\n    zh: \"智慧不是学校教育的产物，而是终生努力获得的产物。\",\n    zh_TW: \"智慧不是學校教育的產物，而是終生努力獲得的產物。\",\n    ja: \"知恵とは学校教育の産物ではなく、生涯をかけて獲得しようと試みることで得られるものである。\",\n    ko: \"지혜는 학교 교육의 산물이 아니라, 평생에 걸쳐 그것을 얻으려는 노력의 산물이다。\",\n  },\n  {\n    en: \"The greatest enemy of knowledge is not ignorance, it is the illusion of knowledge.\",\n    zh: \"知识最大的敌人不是无知，而是自以为拥有知识的幻觉。\",\n    zh_TW: \"知識最大的敵人不是無知，而是自以為擁有知識的幻覺。\",\n    ja: \"知識の最大の敵は無知ではなく、知っているという幻想である。\",\n    ko: \"지식의 가장 큰 적은 무지가 아니라, 안다는 착각이다。\",\n  },\n  {\n    en: \"True wisdom comes to each of us when we realize how little we understand about life, ourselves, and the world around us.\",\n    zh: \"当我们认识到自己对生命、对自身、对周围世界了解得多么少时，真正的智慧才会降临到我们每个人身上。\",\n    zh_TW:\n      \"當我們認識到自己對生命、對自身、對周圍世界了解得多麼少時，真正的智慧才會降臨到我們每個人身上。\",\n    ja: \"真の知恵は、我々が人生や自分自身、そして我々を取り巻く世界について、いかにわずかしか理解していないかを悟ったときに訪れる。\",\n    ko: \"진정한 지혜는 우리가 삶과 우리 자신, 그리고 우리를 둘러싼 세계에 대해 얼마나 아는 것이 없는지를 깨달을 때 찾아온다。\",\n  },\n  {\n    en: \"Beware of false knowledge; it is more dangerous than ignorance.\",\n    zh: \"谨防虚假的知识；它比无知更危险。\",\n    zh_TW: \"謹防虛假的知識；它比無知更危險。\",\n    ja: \"偽りの知識に用心せよ。それは無知よりも危険である。\",\n    ko: \"거짓된 지식을 경계하라. 그것은 무지보다 더 위험하다。\",\n  },\n  {\n    en: \"What does not kill me makes me stronger.\",\n    zh: \"杀不死我的，使我更强大。\",\n    zh_TW: \"殺不死我的，使我更強大。\",\n    ja: \"私を殺さないものは、私をより強くする。\",\n    ko: \"나를 죽이지 못하는 것은 나를 더 강하게 만든다。\",\n  },\n  {\n    en: \"The only constant in life is change.\",\n    zh: \"生活中唯一不变的就是变化。\",\n    zh_TW: \"生活中唯一不變的就是變化。\",\n    ja: \"人生で唯一変わらないものは、変化そのものである。\",\n    ko: \"삶에서 유일하게 변하지 않는 것은 변화뿐이다。\",\n  },\n  {\n    en: \"If you are going through hell, keep going.\",\n    zh: \"如果你正在经历地狱，那就继续走下去。\",\n    zh_TW: \"如果你正在經歷地獄，那就繼續走下去。\",\n    ja: \"地獄を経験しているなら、進み続けろ。\",\n    ko: \"지옥을 겪고 있다면, 계속 나아가라。\",\n  },\n  {\n    en: \"In the middle of difficulty lies opportunity.\",\n    zh: \"机会蕴藏在困难之中。\",\n    zh_TW: \"機會蘊藏在困難之中。\",\n    ja: \"困難の真っ只中に、好機がある。\",\n    ko: \"어려움의 한가운데에 기회가 있다。\",\n  },\n  {\n    en: \"It is not the strongest of the species that survive, nor the most intelligent, but the one most responsive to change.\",\n    zh: \"存活下来的物种不是最强壮的，也不是最聪明的，而是最能适应变化的。\",\n    zh_TW: \"存活下來的物種不是最强壯的，也不是最聰明的，而是最能適應變化的。\",\n    ja: \"生き残る種とは、最も強いものでも、最も知的なものでもない。最も変化に対応できるものである。\",\n    ko: \"살아남는 종은 가장 강한 종도, 가장 지능이 높은 종도 아니다. 변화에 가장 잘 적응하는 종이다。\",\n  },\n  {\n    en: \"We must become the change we wish to see in the world.\",\n    zh: \"我们必须成为我们希望在世界上看到的改变。\",\n    zh_TW: \"我們必須成為我們希望在世界上看到的改變。\",\n    ja: \"世界に変化を望むなら、まず自らがその変化となれ。\",\n    ko: \"우리는 세상에서 보고 싶은 변화가 되어야 한다。\",\n  },\n  {\n    en: \"A smooth sea never made a skilled sailor.\",\n    zh: \"平静的大海练不出熟练的水手。\",\n    zh_TW: \"平靜的大海練不出熟練的水手。\",\n    ja: \"穏やかな海は、熟練した船乗りを育てない。\",\n    ko: \"순탄한 바다는 노련한 뱃사공을 만들지 못한다。\",\n  },\n  {\n    en: \"Obstacles don't block the path, they are the path.\",\n    zh: \"障碍不是挡住了路，障碍本身就是路。\",\n    zh_TW: \"障礙不是擋住了路，障礙本身就是路。\",\n    ja: \"障害は道を塞ぐものではなく、道そのものである。\",\n    ko: \"장애물은 길을 막는 것이 아니라, 그 자체가 길이다。\",\n  },\n  {\n    en: \"Fall seven times, stand up eight.\",\n    zh: \"七次跌倒，八次站起。\",\n    zh_TW: \"七次跌倒，八次站起。\",\n    ja: \"七転び八起き。\",\n    ko: \"일곱 번 넘어져도, 여덟 번 일어선다。\",\n  },\n  {\n    en: \"The art of life lies in a constant readjustment to our surroundings.\",\n    zh: \"生活的艺术在于不断地调整自己以适应环境。\",\n    zh_TW: \"生活的藝術在於不斷地調整自己以適應環境。\",\n    ja: \"人生（じんせい）の芸術（げいじゅつ）は、我々（われわれ）の環境（かんきょう）に対（たい）する絶（た）え間（ま）ない再調整（さいちょうせい）にある。\",\n    ko: \"삶의 기술은 우리를 둘러싼 환경에 끊임없이 재적응하는 데 있다。\",\n  },\n  {\n    en: \"Adversity introduces a man to himself.\",\n    zh: \"逆境使人认识自己。\",\n    zh_TW: \"逆境使人認識自己。\",\n    ja: \"逆境は、人に自分自身を教えてくれる。\",\n    ko: \"역경은 사람에게 자기 자신을 소개한다。\",\n  },\n  {\n    en: \"The wound is the place where the Light enters you.\",\n    zh: \"伤口是光进入你内心的入口。\",\n    zh_TW: \"傷口是光進入你內心的入口。\",\n    ja: \"傷口は、光があなたの中に入る場所だ。\",\n    ko: \"상처는 빛이 당신에게 들어오는 곳이다。\",\n  },\n  {\n    en: \"When we are no longer able to change a situation, we are challenged to change ourselves.\",\n    zh: \"当我们无法改变现状时，我们就需要改变自己。\",\n    zh_TW: \"當我們無法改變現狀時，我們就需要改變自己。\",\n    ja: \"状況を変えることができなくなったとき、我々は自分自身を変えることを求められる。\",\n    ko: \"상황을 더 이상 바꿀 수 없을 때, 우리는 자신을 바꿔야 하는 도전에 직면한다。\",\n  },\n  {\n    en: \"Be the change you wish to see in the world.\",\n    zh: \"成为你希望在世界上看到的改变。\",\n    zh_TW: \"成為你希望在世界上看到的改變。\",\n    ja: \"あなたが世界に見たいと願う変化に、あなた自身がなりなさい。\",\n    ko: \"세상에서 보고 싶은 변화가 있다면, 당신 자신이 그 변화가 되어라。\",\n  },\n  {\n    en: \"Do not pray for an easy life, pray for the strength to endure a difficult one.\",\n    zh: \"不要祈祷生活安逸，要祈祷有力量去忍受艰难的生活。\",\n    zh_TW: \"不要祈禱生活安逸，要祈禱有力量去忍受艱難的生活。\",\n    ja: \"楽な人生を祈るな。困難な人生を耐え抜く強さを祈れ。\",\n    ko: \"편안한 삶을 기도하지 말고, 어려운 삶을 견뎌낼 힘을 기도하라。\",\n  },\n  {\n    en: \"A pessimist sees the difficulty in every opportunity; an optimist sees the opportunity in every difficulty.\",\n    zh: \"悲观者在每个机会中都看到困难；乐观者在每个困难中都看到机会。\",\n    zh_TW: \"悲觀者在每個機會中都看到困難；樂觀者在每個困難中都看到機會。\",\n    ja: \"悲観主義者はあらゆる好機の中に困難を見る。楽観主義者はあらゆる困難の中に好機を見る。\",\n    ko: \"비관론자는 모든 기회에서 어려움을 보고, 낙관론자는 모든 어려움에서 기회를 본다。\",\n  },\n  {\n    en: \"It's not what happens to you, but how you react to it that matters.\",\n    zh: \"重要的不是发生在你身上的事，而是你如何应对它。\",\n    zh_TW: \"重要的不是發生在你身上的事，而是你如何應對它。\",\n    ja: \"あなたに何が起こるかではなく、それにどう反応するかが重要だ。\",\n    ko: \"당신에게 무슨 일이 일어났는지가 중요한 것이 아니라, 당신이 그것에 어떻게 반응하는지가 중요하다。\",\n  },\n  {\n    en: \"To love oneself is the beginning of a lifelong romance.\",\n    zh: \"爱自己是终身浪漫的开始。\",\n    zh_TW: \"愛自己是終身浪漫的開始。\",\n    ja: \"自分自身を愛することは、一生続くロマンスの始まりだ。\",\n    ko: \"자신을 사랑하는 것은 평생 지속되는 로맨스의 시작이다。\",\n  },\n  {\n    en: \"Love is composed of a single soul inhabiting two bodies.\",\n    zh: \"爱是栖息于两个身体中的同一个灵魂。\",\n    zh_TW: \"愛是棲息於兩個身體中的同一個靈魂。\",\n    ja: \"愛とは、二つの体に宿る一つの魂で構成されている。\",\n    ko: \"사랑은 두 개의 몸에 깃든 하나의 영혼으로 이루어져 있다。\",\n  },\n  {\n    en: \"Man is the measure of all things.\",\n    zh: \"人是万物的尺度。\",\n    zh_TW: \"人是萬物的尺度。\",\n    ja: \"人間は万物の尺度である。\",\n    ko: \"인간은 만물의 척도이다。\",\n  },\n  {\n    en: \"The best and most beautiful things in this world cannot be seen or even heard, but must be felt with the heart.\",\n    zh: \"世界上最好最美的东西是看不见也听不见的，必须用心去感受。\",\n    zh_TW: \"世界上最好最美的東西是看不見也聽不見的，必須用心去感受。\",\n    ja: \"この世で最も素晴らしく、最も美しいものは、目で見たり聞いたりすることはできない。心で感じなければならない。\",\n    ko: \"이 세상에서 가장 좋고 가장 아름다운 것들은 보이거나 들리지 않는다. 오직 마음으로만 느껴야 한다。\",\n  },\n  {\n    en: \"Where there is love there is life.\",\n    zh: \"有爱的地方就有生命。\",\n    zh_TW: \"有愛的地方就有生命。\",\n    ja: \"愛があるところに人生がある。\",\n    ko: \"사랑이 있는 곳에 삶이 있다。\",\n  },\n  {\n    en: \"If you want to be loved, be lovable.\",\n    zh: \"如果你想被爱，就要变得可爱。\",\n    zh_TW: \"如果你想被愛，就要變得可愛。\",\n    ja: \"愛されたいなら、愛らしくあれ。\",\n    ko: \"사랑받고 싶다면, 사랑스러워져라。\",\n  },\n  {\n    en: \"We are all in the gutter, but some of us are looking at the stars.\",\n    zh: \"我们都身处沟渠，但仍有人仰望星空。\",\n    zh_TW: \"我們都身處溝渠，但仍有人仰望星空。\",\n    ja: \"我々はみな溝の中にいる。だが、そこから星を見上げている者もいるのだ。\",\n    ko: \"우리는 모두 시궁창에 있지만, 우리 중 일부는 별을 바라보고 있다。\",\n  },\n  {\n    en: \"The only thing we have to fear is fear itself.\",\n    zh: \"我们唯一需要恐惧的就是恐惧本身。\",\n    zh_TW: \"我們唯一需要恐懼的就是恐懼本身。\",\n    ja: \"我々が恐れるべき唯一のものは、恐れそのものである。\",\n    ko: \"우리가 두려워해야 할 유일한 것은 두려움 그 자체이다。\",\n  },\n  {\n    en: \"Be kind, for everyone you meet is fighting a hard battle.\",\n    zh: \"要友善，因为你遇到的每个人都在打一场艰苦的战斗。\",\n    zh_TW: \"要友善，因為你遇到的每個人都在打一場艱苦的戰鬥。\",\n    ja: \"親切にしなさい。あなたが出会う誰もが、困難な戦いを戦っているのだから。\",\n    ko: \"친절하라. 당신이 만나는 모든 사람은 힘겨운 싸움을 하고 있기 때문이다。\",\n  },\n  {\n    en: \"Man is born free, and everywhere he is in chains.\",\n    zh: \"人生而自由，却无往不在枷锁之中。\",\n    zh_TW: \"人生而自由，卻無往不在枷鎖之中。\",\n    ja: \"人は生まれながらにして自由だが、いたるところで鎖につながれている。\",\n    ko: \"인간은 자유롭게 태어났으나, 어디에서나 쇠사슬에 묶여 있다。\",\n  },\n  {\n    en: \"We love the things we love for what they are.\",\n    zh: \"我们爱我们所爱之物，只因它们本来的样子。\",\n    zh_TW: \"我們愛我們所愛之物，只因它們本來的樣子。\",\n    ja: \"我々が愛するものを愛するのは、それがそれであるからだ。\",\n    ko: \"우리는 우리가 사랑하는 것들을 그 자체로 사랑한다。\",\n  },\n  {\n    en: \"Darkness cannot drive out darkness; only light can do that. Hate cannot drive out hate; only love can do that.\",\n    zh: \"黑暗无法驱逐黑暗，只有光明可以；仇恨无法驱逐仇恨，只有爱可以。\",\n    zh_TW: \"黑暗無法驅逐黑暗，只有光明可以；仇恨無法驅逐仇恨，只有愛可以。\",\n    ja: \"闇は闇を追い払うことはできない。光だけがそれを可能にする。憎しみは憎しみを追い払うことはできない。愛だけがそれを可能にする。\",\n    ko: \"어둠은 어둠을 몰아낼 수 없다. 오직 빛만이 할 수 있다. 증오는 증오를 몰아낼 수 없다. 오직 사랑만이 할 수 있다。\",\n  },\n  {\n    en: \"An eye for an eye only ends up making the whole world blind.\",\n    zh: \"以眼还眼，只会让整个世界都盲目。\",\n    zh_TW: \"以眼還眼，只會讓整個世界都盲目。\",\n    ja: \"「目には目を」は、全世界を盲目にするだけだ。\",\n    ko: \"'눈에는 눈'은 결국 온 세상을 눈멀게 할 뿐이다。\",\n  },\n  {\n    en: \"Hell is other people.\",\n    zh: \"他人即地狱。\",\n    zh_TW: \"他人即地獄。\",\n    ja: \"地獄とは、他人である。\",\n    ko: \"타인은 지옥이다。\",\n  },\n  {\n    en: \"You will not be punished for your anger, you will be punished by your anger.\",\n    zh: \"你不会因为你的愤怒而受到惩罚，你会被你的愤怒所惩罚。\",\n    zh_TW: \"你不會因為你的憤怒而受到懲罰，你會被你的憤怒所懲罰。\",\n    ja: \"あなたは怒りのために罰せられるのではない。怒りによって罰せられるのだ。\",\n    ko: \"당신은 당신의 분노 때문에 벌을 받는 것이 아니라, 당신의 분노에 의해 벌을 받을 것이다。\",\n  },\n  {\n    en: \"To err is human, to forgive divine.\",\n    zh: \"犯错是人性，宽恕是神性。\",\n    zh_TW: \"犯錯是人性，寬恕是神性。\",\n    ja: \"過つは人の常、許すは神の業。\",\n    ko: \"실수하는 것은 인간이고, 용서하는 것은 신이다。\",\n  },\n  {\n    en: \"Man is the only creature who refuses to be what he is.\",\n    zh: \"人是唯一拒绝承认自己本质的生物。\",\n    zh_TW: \"人是唯一拒絕承認自己本質的生物。\",\n    ja: \"人間は、自分が何者であるかを拒否する唯一の生き物である。\",\n    ko: \"인간은 자신이 무엇인지를 거부하는 유일한 생물이다。\",\n  },\n  {\n    en: \"Beauty is in the eye of the beholder.\",\n    zh: \"情人眼里出西施。\",\n    zh_TW: \"情人眼裡出西施。\",\n    ja: \"美は見る人の目の中にある。\",\n    ko: \"아름다움은 보는 사람의 눈에 달려 있다。\",\n  },\n  {\n    en: \"All that we see or seem is but a dream within a dream.\",\n    zh: \"我们所见所感，皆如梦中之梦。\",\n    zh_TW: \"我們所見所感，皆如夢中之夢。\",\n    ja: \"我々が見たり感じたりするすべては、夢の中の夢にすぎない。\",\n    ko: \"우리가 보거나 보이는 모든 것은 꿈속의 꿈일 뿐이다。\",\n  },\n  {\n    en: \"Everything you can imagine is real.\",\n    zh: \"你能想象的一切都是真实的。\",\n    zh_TW: \"你能想像的一切都是真實的。\",\n    ja: \"想像できることは、すべて現実なのだ。\",\n    ko: \"당신이 상상할 수 있는 모든 것은 현실이다。\",\n  },\n  {\n    en: \"The map is not the territory.\",\n    zh: \"地图并非领土。\",\n    zh_TW: \"地圖並非領土。\",\n    ja: \"地図は領土ではない。\",\n    ko: \"지도는 영토가 아니다。\",\n  },\n  {\n    en: \"We don't see things as they are, we see them as we are.\",\n    zh: \"我们看到的不是事物的原貌，而是我们自己的样子。\",\n    zh_TW: \"我們看到的不是事物的原貌，而是我們自己的樣子。\",\n    ja: \"我々は物事をあるがままに見ているのではない。我々があるがままに見ているのだ。\",\n    ko: \"우리는 사물을 있는 그대로 보지 않고, 우리 자신(의 모습)대로 본다。\",\n  },\n  {\n    en: \"There are two ways to be fooled. One is to believe what isn't true; the other is to refuse to believe what is true.\",\n    zh: \"被愚弄有两种方式。一种是相信不真实的东西；另一种是拒绝相信真实的东西。\",\n    zh_TW:\n      \"被愚弄有兩種方式。一種是相信不真實的東西；另一種是拒絕相信真實的東西。\",\n    ja: \"騙される方法は二つある。一つは真実でないことを信じること。もう一つは真実であることを信じようとしないことだ。\",\n    ko: \"속는 방법에는 두 가지가 있다. 하나는 사실이 아닌 것을 믿는 것이고, 다른 하나는 사실인 것을 믿기를 거부하는 것이다。\",\n  },\n  {\n    en: \"Simplicity is the ultimate sophistication.\",\n    zh: \"简约是极致的复杂。\",\n    zh_TW: \"簡約是極致的複雜。\",\n    ja: \"シンプルさは、究極の洗練である。\",\n    ko: \"단순함은 궁극의 정교함이다。\",\n  },\n  {\n    en: \"The truth will set you free.\",\n    zh: \"真相将使你自由。\",\n    zh_TW: \"真相將使你自由。\",\n    ja: \"真実は、あなたを自由にする。\",\n    ko: \"진리가 너희를 자유롭게 하리라。\",\n  },\n  {\n    en: \"Reality is merely an illusion, albeit a very persistent one.\",\n    zh: \"现实只是一种幻觉，尽管是一种非常持久的幻觉。\",\n    zh_TW: \"現實只是一種幻覺，儘管是一種非常持久的幻覺。\",\n    ja: \"現実とは、非常に根強いただの幻想にすぎない。\",\n    ko: \"현실은 단지 환상일 뿐이다. 비록 매우 집요한 환상이긴 하지만。\",\n  },\n  {\n    en: \"What is rational is actual and what is actual is rational.\",\n    zh: \"凡是合乎理性的东西都是现实的，凡是现实的东西都是合乎理性的。\",\n    zh_TW: \"凡是合乎理性的東西都是現實的，凡是現實的東西都是合乎理性的。\",\n    ja: \"理性的なものは現実的であり、現実的なものは理性的である。\",\n    ko: \"이성적인 것은 현실적이고, 현실적인 것은 이성적이다。\",\n  },\n  {\n    en: \"Truth is like the sun. You can shut it out for a time, but it ain't goin' away.\",\n    zh: \"真相就像太阳。你可以暂时将它遮住，但它不会消失。\",\n    zh_TW: \"真相就像太陽。你可以暫時將它遮住，但它不會消失。\",\n    ja: \"真実は太陽のようなものだ。一時的に隠すことはできても、決してなくなりはしない。\",\n    ko: \"진실은 태양과 같다. 잠시 가릴 수는 있지만, 사라지게 할 수는 없다。\",\n  },\n  {\n    en: \"Everything we hear is an opinion, not a fact. Everything we see is a perspective, not the truth.\",\n    zh: \"我们听到的一切都只是观点，而非事实。我们看到的一切都只是视角，而非真相。\",\n    zh_TW:\n      \"我們聽到的一切都只是觀點，而非事實。我們看到的一切都只是視角，而非真相。\",\n    ja: \"我々が聞くことすべてが意見であり、事実ではない。我々が見ることすべてが視点であり、真実ではない。\",\n    ko: \"우리가 듣는 모든 것은 의견이지, 사실이 아니다. 우리가 보는 모든 것은 관점이지, 진실이 아니다。\",\n  },\n  {\n    en: \"There is no truth. There is only perception.\",\n    zh: \"没有真相，只有认知。\",\n    zh_TW: \"沒有真相，只有認知。\",\n    ja: \"真実などない。ただ認識があるだけだ。\",\n    ko: \"진실은 없다. 오직 인식만이 있을 뿐이다。\",\n  },\n  {\n    en: \"If you look deep enough into anything, you will find mathematics.\",\n    zh: \"如果你对任何事物看得足够深入，你都会发现数学。\",\n    zh_TW: \"如果你對任何事物看得足夠深入，你都會發現數學。\",\n    ja: \"何事も深く見つめれば、そこには数学がある。\",\n    ko: \"무엇이든 충분히 깊이 들여다보면, 수학을 발견하게 될 것이다。\",\n  },\n  {\n    en: \"The medium is the message.\",\n    zh: \"媒介即信息。\",\n    zh_TW: \"媒介即訊息。\",\n    ja: \"メディアはメッセージである。\",\n    ko: \"미디어는 메시지다。\",\n  },\n  {\n    en: \"Nothing is true, everything is permitted.\",\n    zh: \"没有什么是真实的，一切都被允许。\",\n    zh_TW: \"沒有什麼是真實的，一切都被允許。\",\n    ja: \"真実などない、すべては許されている。\",\n    ko: \"진실은 없으며, 모든 것이 허용된다。\",\n  },\n  {\n    en: \"We are what we believe we are.\",\n    zh: \"我们相信自己是什么，我们就是什么。\",\n    zh_TW: \"我們相信自己是什麼，我們就是什麼。\",\n    ja: \"我々は、我々が信じる通りの人間である。\",\n    ko: \"우리는 우리가 그렇다고 믿는 존재이다。\",\n  },\n  {\n    en: \"Yesterday is history, tomorrow is a mystery, but today is a gift. That is why it is called the present.\",\n    zh: \"昨天是历史，明天是谜团，但今天是礼物。这就是为什么它被称为‘现在’(Present)。\",\n    zh_TW:\n      \"昨天是歷史，明天是謎團，但今天是禮物。這就是為什麼它被稱為‘現在’(Present)。\",\n    ja: \"昨日は歴史、明日はミステリー、しかし今日は贈り物だ。だからこそ、それは『プレゼント (現在)』と呼ばれる。\",\n    ko: \"어제는 역사이고, 내일은 미스터리이며, 오늘은 선물이다. 그래서 오늘을 '선물(present)'이라고 부른다。\",\n  },\n  {\n    en: \"Time is money.\",\n    zh: \"时间就是金钱。\",\n    zh_TW: \"時間就是金錢。\",\n    ja: \"時は金なり。\",\n    ko: \"시간은 돈이다。\",\n  },\n  {\n    en: \"The only thing necessary for the triumph of evil is for good men to do nothing.\",\n    zh: \"邪恶得逞的唯一条件是好人袖手旁观。\",\n    zh_TW: \"邪惡得逞的唯一條件是好人袖手旁觀。\",\n    ja: \"悪が勝利するために必要なのは、善人が何もしないことだけである。\",\n    ko: \"악의 승리를 위해 필요한 유일한 것은 선한 사람들이 아무것도 하지 않는 것이다。\",\n  },\n  {\n    en: \"Carpe diem.\",\n    zh: \"活在当下。\",\n    zh_TW: \"活在當下。\",\n    ja: \"今を生きよ（カルペ・ディエム）。\",\n    ko: \"현재를 즐겨라 (카르페 디엠)。\",\n  },\n  {\n    en: \"Do not dwell in the past, do not dream of the future, concentrate the mind on the present moment.\",\n    zh: \"不要沉湎于过去，不要幻想未来，集中精神活在当下。\",\n    zh_TW: \"不要沉湎於過去，不要幻想未來，集中精神活在當下。\",\n    ja: \"過去に生きるな、未来を夢見るな、現在の瞬間に心を集中させよ。\",\n    ko: \"과거에 머물지 말고, 미래를 꿈꾸지 말며, 현재 이 순간에 마음을 집중하라。\",\n  },\n  {\n    en: \"The best time to plant a tree was 20 years ago. The second best time is now.\",\n    zh: \"种树的最佳时机是20年前。其次是现在。\",\n    zh_TW: \"種樹的最佳時機是20年前。其次是現在。\",\n    ja: \"木を植えるのに最適な時期は20年前だった。二番目に最適な時期は、今だ。\",\n    ko: \"나무를 심기에 가장 좋은 때는 20년 전이었다. 두 번째로 좋은 때는 바로 지금이다。\",\n  },\n  {\n    en: \"Action speaks louder than words.\",\n    zh: \"事实胜于雄辩。\",\n    zh_TW: \"事實勝於雄辯。\",\n    ja: \"行動は言葉よりも雄弁である。\",\n    ko: \"말보다 행동이 더 중요하다。\",\n  },\n  {\n    en: \"Honesty is the first chapter in the book of wisdom.\",\n    zh: \"诚实是智慧之书的第一章。\",\n    zh_TW: \"誠實是智慧之書的第一章。\",\n    ja: \"誠実さは、知恵という本の第一章である。\",\n    ko: \"정직은 지혜라는 책의 첫 장이다。\",\n  },\n  {\n    en: \"Two things are infinite: the universe and human stupidity; and I'm not sure about the universe.\",\n    zh: \"有两样东西是无限的：宇宙和人类的愚蠢；而且我不太确定宇宙是否无限。\",\n    zh_TW: \"有兩樣東西是無限的：宇宙和人類的愚蠢；而且我不太確定宇宙是否無限。\",\n    ja: \"無限なものは二つある。宇宙と人間の愚かさだ。ただ、宇宙については私にもよく分からない。\",\n    ko: \"무한한 것은 두 가지뿐이다. 우주와 인간의 어리석음. 그런데 우주에 대해선 나도 확신이 없다。\",\n  },\n  {\n    en: \"You cannot step twice into the same river.\",\n    zh: \"人不能两次踏进同一条河流。\",\n    zh_TW: \"人不能兩次踏進同一條河流。\",\n    ja: \"同（おな）じ川（かわ）に二度（にど）入（はい）ることはできない。\",\n    ko: \"같은 강물에 두 번 발을 담글 수 없다。\",\n  },\n  {\n    en: \"The future belongs to those who believe in the beauty of their dreams.\",\n    zh: \"未来属于那些相信梦想之美的人。\",\n    zh_TW: \"未來屬於那些相信夢想之美的人。\",\n    ja: \"未来は、自分の夢の美しさを信じる者のものである。\",\n    ko: \"미래는 자신의 꿈의 아름다움을 믿는 사람들의 것이다。\",\n  },\n  {\n    en: \"Procrastination is the thief of time.\",\n    zh: \"拖延是时间的大敌。\",\n    zh_TW: \"拖延是時間的大敵。\",\n    ja: \"先延ばしは時間泥棒である。\",\n    ko: \"미루는 습관은 시간 도둑이다。\",\n  },\n  {\n    en: \"An investment in knowledge pays the best interest.\",\n    zh: \"投资知识，收益最佳。\",\n    zh_TW: \"投資知識，收益最佳。\",\n    ja: \"知識への投資は、最良の利息を生む。\",\n    ko: \"지식에 대한 투자는 최고의 이자를 지불한다。\",\n  },\n  {\n    en: \"I have not failed. I've just found 10,000 ways that won't work.\",\n    zh: \"我没有失败。我只是找到了一万种行不通的方法。\",\n    zh_TW: \"我沒有失敗。我只是找到了一萬種行不通的方法。\",\n    ja: \"私は失敗したことがない。ただ、うまくいかない1万通りの方法を見つけただけだ。\",\n    ko: \"나는 실패하지 않았다. 단지 작동하지 않는 1만 가지 방법을 찾았을 뿐이다。\",\n  },\n  {\n    en: \"That which is done, is done.\",\n    zh: \"木已成舟。\",\n    zh_TW: \"木已成舟。\",\n    ja: \"なされたことは、なされたことだ。（覆水盆に返らず）\",\n    ko: \"일어난 일은 일어난 일이다. (이미 엎질러진 물이다.)\",\n  },\n];\n\nexport function getRandomQuote() {\n  const randomIndex = Math.floor(Math.random() * quotes.length);\n  return quotes[randomIndex];\n}\n"
  },
  {
    "path": "src/config/rules.js",
    "content": "import { OPT_TRANS_MICROSOFT } from \"./api\";\nimport { OPT_STYLE_NONE } from \"./styles\";\n\nexport const GLOBAL_KEY = \"*\";\nexport const REMAIN_KEY = \"-\";\nexport const SHADOW_KEY = \">>>\";\n\nexport const DEFAULT_COLOR = \"#209CEE\"; // 默认高亮背景色/线条颜色\n\nexport const DEFAULT_TRANS_TAG = \"font\";\nexport const DEFAULT_SELECT_STYLE =\n  \"-webkit-line-clamp: unset; max-height: none; height: auto;\";\n\nexport const OPT_TIMING_PAGESCROLL = \"mk_pagescroll\"; // 滚动加载翻译\nexport const OPT_TIMING_PAGEOPEN = \"mk_pageopen\"; // 直接翻译到底\nexport const OPT_TIMING_MOUSEOVER = \"mk_mouseover\";\nexport const OPT_TIMING_CONTROL = \"mk_ctrlKey\";\nexport const OPT_TIMING_SHIFT = \"mk_shiftKey\";\nexport const OPT_TIMING_ALT = \"mk_altKey\";\nexport const OPT_TIMING_ALL = [\n  OPT_TIMING_PAGESCROLL,\n  OPT_TIMING_PAGEOPEN,\n  OPT_TIMING_MOUSEOVER,\n  OPT_TIMING_CONTROL,\n  OPT_TIMING_SHIFT,\n  OPT_TIMING_ALT,\n];\n\nexport const OPT_SPLIT_PARAGRAPH_DISABLE = \"split_disable\";\nexport const OPT_SPLIT_PARAGRAPH_TEXTLENGTH = \"split_textlength\";\nexport const OPT_SPLIT_PARAGRAPH_PUNCTUATION = \"split_punctuation\";\nexport const OPT_SPLIT_PARAGRAPH_ALL = [\n  OPT_SPLIT_PARAGRAPH_DISABLE,\n  OPT_SPLIT_PARAGRAPH_PUNCTUATION,\n  OPT_SPLIT_PARAGRAPH_TEXTLENGTH,\n];\n\nexport const OPT_HIGHLIGHT_WORDS_DISABLE = \"highlight_disable\";\nexport const OPT_HIGHLIGHT_WORDS_BEFORETRANS = \"highlight_beforetrans\";\nexport const OPT_HIGHLIGHT_WORDS_AFTERTRANS = \"highlight_aftertrans\";\nexport const OPT_HIGHLIGHT_WORDS_ALL = [\n  OPT_HIGHLIGHT_WORDS_DISABLE,\n  OPT_HIGHLIGHT_WORDS_BEFORETRANS,\n  OPT_HIGHLIGHT_WORDS_AFTERTRANS,\n];\n\nexport const DEFAULT_SELECTOR =\n  \"h1, h2, h3, h4, h5, h6, li, p, dd, blockquote, figcaption, label, legend\";\nexport const DEFAULT_IGNORE_SELECTOR = \"button, footer, pre, mark, nav\";\nexport const DEFAULT_KEEP_SELECTOR = `code, cite, math, .math, a:has(code)`;\nexport const DEFAULT_RULE = {\n  pattern: \"\", // 匹配网址\n  selector: \"\", // 选择器\n  keepSelector: \"\", // 保留元素选择器\n  terms: \"\", // 专业术语\n  aiTerms: \"\", // AI专业术语\n  apiSlug: GLOBAL_KEY, // 翻译服务\n  fromLang: GLOBAL_KEY, // 源语言\n  toLang: GLOBAL_KEY, // 目标语言\n  textStyle: GLOBAL_KEY, // 译文样式\n  transOpen: GLOBAL_KEY, // 开启翻译\n  // bgColor: \"\", // 译文颜色 (作废)\n  // textDiyStyle: \"\", // 自定义译文样式 (作废)\n  textExtStyle: \"\", // 译文附加样式\n  termsStyle: \"\", // 专业术语样式\n  highlightStyle: \"\", // 高亮词汇样式\n  selectStyle: \"\", // 选择器节点样式\n  parentStyle: \"\", // 选择器父节点样式\n  grandStyle: \"\", // 选择器父节点样式\n  injectJs: \"\", // 注入JS\n  // injectCss: \"\", // 注入CSS (作废)\n  transOnly: GLOBAL_KEY, // 是否仅显示译文\n  // transTiming: GLOBAL_KEY, // 翻译时机/鼠标悬停翻译  (暂时作废)\n  transTag: GLOBAL_KEY, // 译文元素标签\n  transTitle: GLOBAL_KEY, // 是否同时翻译页面标题\n  // transSelected: GLOBAL_KEY, // 是否启用划词翻译 (移回setting)\n  // detectRemote: GLOBAL_KEY, // 是否使用远程语言检测 (移回setting)\n  // skipLangs: [], // 不翻译的语言 (移回setting)\n  // fixerSelector: \"\", // 修复函数选择器 (暂时作废)\n  // fixerFunc: GLOBAL_KEY, // 修复函数 (暂时作废)\n  transStartHook: \"\", // 钩子函数\n  transEndHook: \"\", // 钩子函数\n  // transRemoveHook: \"\", // 钩子函数 (暂时作废)\n  autoScan: GLOBAL_KEY, // 是否自动识别文本节点\n  hasRichText: GLOBAL_KEY, // 是否启用富文本翻译\n  hasShadowroot: GLOBAL_KEY, // 是否包含shadowroot\n  scanAll: GLOBAL_KEY, // 是否扫描全部节点\n  rootsSelector: \"\", // 翻译范围选择器\n  ignoreSelector: \"\", // 不翻译的选择器\n  splitParagraph: GLOBAL_KEY, // 切分段落\n  splitLength: 0, // 切分段落长度\n  highlightWords: GLOBAL_KEY, // 高亮词汇\n};\n\n// 全局规则\nexport const GLOBLA_RULE = {\n  pattern: \"*\", // 匹配网址\n  selector: DEFAULT_SELECTOR, // 选择器\n  keepSelector: DEFAULT_KEEP_SELECTOR, // 保留元素选择器\n  terms: \"\", // 专业术语\n  aiTerms: \"\", // AI专业术语\n  apiSlug: OPT_TRANS_MICROSOFT, // 翻译服务\n  fromLang: \"auto\", // 源语言\n  toLang: \"zh-CN\", // 目标语言\n  textStyle: OPT_STYLE_NONE, // 译文样式\n  transOpen: \"false\", // 开启翻译\n  // bgColor: DEFAULT_COLOR, // 译文颜色 (作废)\n  // textDiyStyle: DEFAULT_DIY_STYLE, // 自定义译文样式 (作废)\n  textExtStyle: \"\", // 译文附加样式\n  termsStyle: \"font-weight: bold;\", // 专业术语样式\n  highlightStyle: \"color: red;\", // 高亮词汇样式\n  selectStyle: DEFAULT_SELECT_STYLE, // 选择器节点样式\n  parentStyle: \"\", // 选择器父节点样式\n  grandStyle: \"\", // 选择器祖节点样式\n  injectJs: \"\", // 注入JS\n  injectCss: \"\", // 注入CSS\n  transOnly: \"false\", // 是否仅显示译文\n  // transTiming: OPT_TIMING_PAGESCROLL, // 翻译时机/鼠标悬停翻译 (暂时作废)\n  transTag: DEFAULT_TRANS_TAG, // 译文元素标签\n  transTitle: \"false\", // 是否同时翻译页面标题\n  // transSelected: \"true\", // 是否启用划词翻译 (移回setting)\n  // detectRemote: \"true\", // 是否使用远程语言检测 (移回setting)\n  // skipLangs: [], // 不翻译的语言 (移回setting)\n  // fixerSelector: \"\", // 修复函数选择器 (暂时作废)\n  // fixerFunc: \"-\", // 修复函数 (暂时作废)\n  transStartHook: \"\", // 钩子函数\n  transEndHook: \"\", // 钩子函数\n  // transRemoveHook: \"\", // 钩子函数 (暂时作废)\n  autoScan: \"true\", // 是否自动识别文本节点\n  hasRichText: \"true\", // 是否启用富文本翻译\n  hasShadowroot: \"false\", // 是否包含shadowroot\n  scanAll: \"false\", // 是否扫描全部节点\n  rootsSelector: \"body\", // 翻译范围选择器\n  ignoreSelector: DEFAULT_IGNORE_SELECTOR, // 不翻译的选择器\n  splitParagraph: OPT_SPLIT_PARAGRAPH_DISABLE, // 切分段落\n  splitLength: 100, // 切分段落长度\n  highlightWords: OPT_HIGHLIGHT_WORDS_DISABLE, // 高亮词汇\n};\n\nexport const DEFAULT_RULES = [GLOBLA_RULE];\n\n// todo: 校验几个内置规则\nconst RULES_MAP = {\n  // \"www.google.com/search\": {\n  //   rootsSelector: `#rcnt`,\n  // },\n  \"en.wikipedia.org\": {\n    ignoreSelector: `.button, code, footer, form, mark, pre, .mwe-math-element, .mw-editsection`,\n  },\n  \"news.ycombinator.com\": {\n    selector: `p, .titleline, .commtext, .hn-item-title, .hn-comment-text, .hn-story-title`,\n    keepSelector: `code, img, svg, pre, .sitebit`,\n    ignoreSelector: `button, code, footer, form, header, mark, nav, pre, .reply`,\n    autoScan: `false`,\n  },\n  \"twitter.com, https://x.com\": {\n    selector: `[data-testid='tweetText'], [data-testid='twitter-article-title'], [data-testid='UserDescription'], .public-DraftStyleDefault-block, span.text-body, div.css-175oi2r.r-3pj75a div.css-175oi2r>span, div.css-175oi2r.r-3pj75a li>span, div.r-1s2bzr4>div.r-16dba41, div.r-16y2uox>div.r-1jeg54m`,\n    keepSelector: `img, svg, a, span:has(a), div:has(a)`,\n    ignoreSelector: `[data-testid='videoPlayer'], [data-testid^='tweetTextarea']`,\n    autoScan: `false`,\n    selectStyle: `-webkit-line-clamp: unset; max-height: none; height: auto;`,\n  },\n  \"www.youtube.com/live_chat\": {\n    rootsSelector: `div#items`,\n    selector: `span.yt-live-chat-text-message-renderer`,\n    autoScan: `false`,\n  },\n  \"www.youtube.com\": {\n    rootsSelector: `ytd-page-manager`,\n    ignoreSelector: `aside, button, footer, form, header, pre, mark, nav, #player, #container, .caption-window, .ytp-settings-menu, #kiss-youtube-subtitle-list-container`,\n    selectStyle: `-webkit-line-clamp: unset; max-height: none; height: auto;`,\n    parentStyle: `-webkit-line-clamp: unset; max-height: none; height: auto;`,\n    grandStyle: `-webkit-line-clamp: unset; max-height: none; height: auto;`,\n  },\n  \"web.telegram.org\": {\n    autoScan: `false`,\n    selector: \".text-content, .embedded-text-wrapper\",\n    rootsSelector: \".Transition\",\n  },\n  \"github.com\": {\n    autoScan: `false`,\n    selector: `h1, h2, h3, h4, h5, h6, .markdown-body li, p, dd, blockquote, figcaption, label, legend, .user-profile-bio>div, [data-testid=\"results-list\"] .search-match, .Subhead-description, [class^=\"prc-SelectPanel-Subtitle-\"], [class^=\"prc-ActionList-ItemLabel-\"], [role=\"dialog\"] .overflow-auto, .h4, .repos-list-description, .discussion-title, [class*=\"PinnedIssue-module__Link\"] span, .js-wiki-sidebar-page-container :is(.Truncate-text, .Link--primary)`,\n    ignoreSelector: `button, p.pinned-item-desc+p`,\n  },\n};\n\nexport const BUILTIN_RULES = Object.entries(RULES_MAP).map(\n  ([pattern, rule]) => ({\n    // ...DEFAULT_RULE,\n    ...rule,\n    pattern,\n  })\n);\n"
  },
  {
    "path": "src/config/setting.js",
    "content": "import { LogLevel } from \"../libs/log\";\nimport {\n  OPT_DICT_BING,\n  OPT_SUG_YOUDAO,\n  DEFAULT_HTTP_TIMEOUT,\n  OPT_TRANS_MICROSOFT,\n  DEFAULT_API_LIST,\n} from \"./api\";\nimport { DEFAULT_CUSTOM_STYLES } from \"./styles\";\n\n// 默认快捷键\nexport const OPT_SHORTCUT_TRANSLATE = \"toggleTranslate\";\nexport const OPT_SHORTCUT_STYLE = \"toggleStyle\";\nexport const OPT_SHORTCUT_POPUP = \"togglePopup\";\nexport const OPT_SHORTCUT_SETTING = \"openSetting\";\nexport const DEFAULT_SHORTCUTS = {\n  [OPT_SHORTCUT_TRANSLATE]: [\"AltLeft\", \"KeyQ\"],\n  [OPT_SHORTCUT_STYLE]: [\"AltLeft\", \"KeyC\"],\n  [OPT_SHORTCUT_POPUP]: [\"AltLeft\", \"KeyK\"],\n  [OPT_SHORTCUT_SETTING]: [\"AltLeft\", \"KeyO\"],\n};\n\nexport const TRANS_MIN_LENGTH = 2; // 最短翻译长度\nexport const TRANS_MAX_LENGTH = 100000; // 最长翻译长度\nexport const TRANS_NEWLINE_LENGTH = 20; // 换行字符数\nexport const DEFAULT_BLACKLIST = [\n  \"https://fishjar.github.io/kiss-translator/options.html\",\n  \"https://translate.google.com\",\n  \"https://www.deepl.com/translator\",\n]; // 禁用翻译名单\nexport const DEFAULT_CSPLIST = []; // 禁用CSP名单\nexport const DEFAULT_ORILIST = [\"https://dict.youdao.com\"]; // 移除Origin名单\n\n// 同步设置\nexport const OPT_SYNCTYPE_WORKER = \"KISS-Worker\";\nexport const OPT_SYNCTYPE_WEBDAV = \"WebDAV\";\nexport const OPT_SYNCTOKEN_PERFIX = \"kt_\";\nexport const OPT_SYNCTYPE_ALL = [OPT_SYNCTYPE_WORKER, OPT_SYNCTYPE_WEBDAV];\nexport const DEFAULT_SYNC = {\n  syncType: OPT_SYNCTYPE_WORKER, // 同步方式\n  syncUrl: \"\", // 数据同步接口\n  syncUser: \"\", // 数据同步用户名\n  syncKey: \"\", // 数据同步密钥\n  syncMeta: {}, // 数据更新及同步信息\n  subRulesSyncAt: 0, // 订阅规则同步时间\n  dataCaches: {}, // 缓存同步时间\n};\n\n// 输入框图标显示\nexport const OPT_INPUT_DOT_DISABLE = \"-\";\nexport const OPT_INPUT_DOT_MOBILE = \"mobile\";\nexport const OPT_INPUT_DOT_ALWAYS = \"always\";\n\n// 输入框翻译\nexport const OPT_INPUT_TRANS_SIGNS = [\"/\", \"//\", \"\\\\\", \"\\\\\\\\\", \">\", \">>\"];\nexport const DEFAULT_INPUT_SHORTCUT = [\"AltLeft\", \"KeyI\"];\nexport const DEFAULT_INPUT_RULE = {\n  transOpen: true,\n  apiSlug: OPT_TRANS_MICROSOFT,\n  fromLang: \"auto\",\n  toLang: \"en\",\n  triggerShortcut: DEFAULT_INPUT_SHORTCUT,\n  triggerCount: 1,\n  triggerTime: 200,\n  transSign: OPT_INPUT_TRANS_SIGNS[0],\n  showDot: OPT_INPUT_DOT_MOBILE,\n};\n\n// 划词翻译\nexport const PHONIC_MAP = {\n  en_phonic: [\"英\", \"uk\"],\n  us_phonic: [\"美\", \"en\"],\n};\nexport const OPT_TRANBOX_TRIGGER_CLICK = \"click\";\nexport const OPT_TRANBOX_TRIGGER_HOVER = \"hover\";\nexport const OPT_TRANBOX_TRIGGER_SELECT = \"select\";\nexport const OPT_TRANBOX_TRIGGER_ALL = [\n  OPT_TRANBOX_TRIGGER_CLICK,\n  OPT_TRANBOX_TRIGGER_HOVER,\n  OPT_TRANBOX_TRIGGER_SELECT,\n];\nexport const DEFAULT_TRANBOX_SHORTCUT = [\"AltLeft\", \"KeyS\"];\nexport const DEFAULT_TRANBOX_SETTING = {\n  transOpen: true, // 是否启用划词翻译\n  apiSlugs: [OPT_TRANS_MICROSOFT],\n  fromLang: \"auto\",\n  toLang: \"zh-CN\",\n  toLang2: \"en\",\n  tranboxShortcut: DEFAULT_TRANBOX_SHORTCUT,\n  btnOffsetX: 10,\n  btnOffsetY: 10,\n  boxOffsetX: 0,\n  boxOffsetY: 10,\n  hideTranBtn: false, // 是否隐藏翻译按钮\n  hideClickAway: false, // 是否点击外部关闭弹窗\n  simpleStyle: false, // 是否简洁界面\n  followSelection: false, // 翻译框是否跟随选中文本\n  autoHeight: false, // 自适应高度\n  triggerMode: OPT_TRANBOX_TRIGGER_CLICK, // 触发翻译方式\n  // extStyles: \"\", // 附加样式\n  enDict: OPT_DICT_BING, // 英文词典\n  enSug: OPT_SUG_YOUDAO, // 英文建议\n};\n\nconst SUBTITLE_WINDOW_STYLE = `padding: 0.5em 1em;\nbackground-color: rgba(0, 0, 0, 0.5);\ncolor: white;\nline-height: 1.3;\ntext-shadow: 1px 1px 2px black;\ndisplay: inline-block`;\n\nconst SUBTITLE_ORIGIN_STYLE = `font-size: clamp(1rem, 2cqw, 3rem);`;\nconst SUBTITLE_TRANSLATION_STYLE = `font-size: clamp(1rem, 2cqw, 3rem);`;\n\nexport const OPT_ENHANCE_ON = \"on\";\nexport const OPT_ENHANCE_OFF = \"off\";\nexport const OPT_ENHANCE_MOBILE_OFF = \"mobile_off\";\n\nexport const DEFAULT_SUBTITLE_SETTING = {\n  enabled: true, // 是否开启\n  apiSlug: OPT_TRANS_MICROSOFT,\n  segSlug: \"-\", // AI智能断句\n  chunkLength: 1000, // AI处理切割长度\n  preTrans: 90, // 提前翻译时长\n  throttleTrans: 30, // 节流翻译间隔\n  // fromLang: \"en\",\n  toLang: \"zh-CN\",\n  isBilingual: true, // 是否双语显示\n  skipAd: false, // 是否快进广告\n  windowStyle: SUBTITLE_WINDOW_STYLE, // 背景样式\n  originStyle: SUBTITLE_ORIGIN_STYLE, // 原文样式\n  translationStyle: SUBTITLE_TRANSLATION_STYLE, // 译文样式\n  enhanceMode: OPT_ENHANCE_MOBILE_OFF, // 增强功能：on/off/mobile_off\n  showList: true, // 是否显示滚动字幕\n};\n\n// 订阅列表\nexport const DEFAULT_SUBRULES_LIST = [\n  {\n    url: process.env.REACT_APP_RULESURL,\n    selected: true,\n  },\n  {\n    url: process.env.REACT_APP_RULESURL_ON,\n    selected: false,\n  },\n  {\n    url: process.env.REACT_APP_RULESURL_OFF,\n    selected: false,\n  },\n];\n\nexport const DEFAULT_MOUSEHOVER_KEY = [\"ControlLeft\"];\nexport const DEFAULT_MOUSE_HOVER_SETTING = {\n  useMouseHover: false, // 是否启用鼠标悬停翻译\n  mouseHoverKey: DEFAULT_MOUSEHOVER_KEY, // 鼠标悬停翻译组合键\n};\n\nexport const DEFAULT_SETTING = {\n  darkMode: \"auto\", // 深色模式\n  uiLang: \"en\", // 界面语言\n  // fetchLimit: DEFAULT_FETCH_LIMIT, // 最大任务数量(移至rule，作废)\n  // fetchInterval: DEFAULT_FETCH_INTERVAL, // 任务间隔时间(移至rule，作废)\n  minLength: TRANS_MIN_LENGTH,\n  maxLength: TRANS_MAX_LENGTH,\n  newlineLength: TRANS_NEWLINE_LENGTH,\n  httpTimeout: DEFAULT_HTTP_TIMEOUT,\n  clearCache: false, // 是否在浏览器下次启动时清除缓存\n  injectRules: true, // 是否注入订阅规则\n  fabClickAction: 0, // 悬浮按钮点击行为\n  // injectWebfix: true, // 是否注入修复补丁(作废)\n  // detectRemote: false, // 是否使用远程语言检测 （从rule移回）\n  // contextMenus: true, // 是否添加右键菜单(作废)\n  contextMenuType: 1, // 右键菜单类型(0不显示，1简单菜单，2多级菜单)\n  // transTag: DEFAULT_TRANS_TAG, // 译文元素标签(移至rule，作废)\n  // transOnly: false, // 是否仅显示译文(移至rule，作废)\n  // transTitle: false, // 是否同时翻译页面标题(移至rule，作废)\n  subrulesList: DEFAULT_SUBRULES_LIST, // 订阅列表\n  // owSubrule: DEFAULT_OW_RULE, // 覆写订阅规则 (作废)\n  transApis: DEFAULT_API_LIST, // 翻译接口 (v2.0 对象改为数组)\n  // mouseKey: OPT_TIMING_PAGESCROLL, // 翻译时机/鼠标悬停翻译(移至rule，作废)\n  shortcuts: DEFAULT_SHORTCUTS, // 快捷键\n  inputRule: DEFAULT_INPUT_RULE, // 输入框设置\n  tranboxSetting: DEFAULT_TRANBOX_SETTING, // 划词翻译设置\n  // touchTranslate: 2, // 触屏翻译 {5:单指双击，6:单指三击，7:双指双击} (作废)\n  touchModes: [2], // 触屏翻译 {5:单指双击，6:单指三击，7:双指双击} (多选)\n  blacklist: DEFAULT_BLACKLIST.join(\",\\n\"), // 禁用翻译名单\n  csplist: DEFAULT_CSPLIST.join(\",\\n\"), // 禁用CSP名单\n  orilist: DEFAULT_ORILIST.join(\",\\n\"), // 禁用CSP名单\n  // disableLangs: [], // 不翻译的语言(移至rule，作废)\n  skipLangs: [], // 不翻译的语言（从rule移回）\n  transInterval: 100, // 翻译等待时间\n  langDetector: \"-\", // 远程语言识别服务\n  mouseHoverSetting: DEFAULT_MOUSE_HOVER_SETTING, // 鼠标悬停翻译\n  preInit: true, // 是否预加载脚本\n  transAllnow: false, // 是否立即全部翻译\n  subtitleSetting: DEFAULT_SUBTITLE_SETTING, // 字幕设置\n  logLevel: LogLevel.INFO.value, // 日志级别\n  rootMargin: 500, // 提前触发翻译\n  customStyles: DEFAULT_CUSTOM_STYLES, // 自定义样式列表\n};\n"
  },
  {
    "path": "src/config/storage.js",
    "content": "import { APP_NAME, APP_VERSION } from \"./app\";\n\nexport const KV_RULES_KEY = `kiss-rules_v${APP_VERSION[0]}.json`;\nexport const KV_WORDS_KEY = \"kiss-words.json\";\nexport const KV_RULES_SHARE_KEY = `kiss-rules-share_v${APP_VERSION[0]}.json`;\nexport const KV_SETTING_KEY = `kiss-setting_v${APP_VERSION[0]}.json`;\nexport const KV_SALT_SYNC = \"KISS-Translator-SYNC\";\nexport const KV_SALT_SHARE = \"KISS-Translator-SHARE\";\n\nexport const STOKEY_MSAUTH = `${APP_NAME}_msauth`;\nexport const STOKEY_BDAUTH = `${APP_NAME}_bdauth`;\nexport const STOKEY_SETTING_OLD = `${APP_NAME}_setting`;\nexport const STOKEY_RULES_OLD = `${APP_NAME}_rules`;\nexport const STOKEY_SETTING = `${APP_NAME}_setting_v${APP_VERSION[0]}`;\nexport const STOKEY_RULES = `${APP_NAME}_rules_v${APP_VERSION[0]}`;\nexport const STOKEY_WORDS = `${APP_NAME}_words`;\nexport const STOKEY_SYNC = `${APP_NAME}_sync`;\nexport const STOKEY_FAB = `${APP_NAME}_fab`;\nexport const STOKEY_TRANBOX = `${APP_NAME}_tranbox`;\nexport const STOKEY_SEPARATE_WINDOW = `${APP_NAME}_separate_window`;\nexport const STOKEY_RULESCACHE_PREFIX = `${APP_NAME}_rulescache_`;\n\nexport const CACHE_NAME = `${APP_NAME}_cache`;\nexport const DEFAULT_CACHE_TIMEOUT = 3600 * 24 * 7; // 缓存超时时间(7天)\n"
  },
  {
    "path": "src/config/styles.js",
    "content": "export const OPT_STYLE_NONE = \"style_none\"; // 无\nexport const OPT_STYLE_LINE = \"under_line\"; // 下划线\nexport const OPT_STYLE_DOTLINE = \"dot_line\"; // 点状线\nexport const OPT_STYLE_DASHLINE = \"dash_line\"; // 虚线\nexport const OPT_STYLE_DASHLINE_BOLD = \"dash_line_bold\"; // 虚线加粗\nexport const OPT_STYLE_DASHBOX = \"dash_box\"; // 虚线框\nexport const OPT_STYLE_DASHBOX_BOLD = \"dash_box_bold\"; // 虚线框加粗\nexport const OPT_STYLE_WAVYLINE = \"wavy_line\"; // 波浪线\nexport const OPT_STYLE_WAVYLINE_BOLD = \"wavy_line_bold\"; // 波浪线加粗\nexport const OPT_STYLE_MARKER = \"marker\"; // 马克笔\nexport const OPT_STYLE_GRADIENT_MARKER = \"gradient_marker\"; // 渐变马克笔\nexport const OPT_STYLE_FUZZY = \"fuzzy\"; // 模糊\nexport const OPT_STYLE_HIGHLIGHT = \"highlight\"; // 高亮\nexport const OPT_STYLE_BLOCKQUOTE = \"blockquote\"; // 引用\nexport const OPT_STYLE_GRADIENT = \"gradient\"; // 渐变\nexport const OPT_STYLE_BLINK = \"blink\"; // 闪现\nexport const OPT_STYLE_GLOW = \"glow\"; // 发光\nexport const OPT_STYLE_COLORFUL = \"colorful\"; // 多彩\nexport const OPT_STYLE_ALL = [\n  OPT_STYLE_NONE,\n  OPT_STYLE_LINE,\n  OPT_STYLE_DOTLINE,\n  OPT_STYLE_DASHLINE,\n  OPT_STYLE_DASHLINE_BOLD,\n  OPT_STYLE_WAVYLINE,\n  OPT_STYLE_WAVYLINE_BOLD,\n  OPT_STYLE_DASHBOX,\n  OPT_STYLE_DASHBOX_BOLD,\n  OPT_STYLE_MARKER,\n  OPT_STYLE_GRADIENT_MARKER,\n  OPT_STYLE_FUZZY,\n  OPT_STYLE_HIGHLIGHT,\n  OPT_STYLE_BLOCKQUOTE,\n  OPT_STYLE_GRADIENT,\n  OPT_STYLE_BLINK,\n  OPT_STYLE_GLOW,\n  OPT_STYLE_COLORFUL,\n];\n\nexport const DEFAULT_CUSTOM_STYLES = [\n  {\n    styleSlug: \"custom\",\n    styleName: \"Custom Style\",\n    styleCode: `color: #209CEE;`,\n  },\n];\n"
  },
  {
    "path": "src/config/url.js",
    "content": "import { APP_LCNAME } from \"./app\";\n\nexport const URL_CACHE_TRAN = `https://${APP_LCNAME}/translate`;\nexport const URL_CACHE_SUBTITLE = `https://${APP_LCNAME}/subtitle`;\nexport const URL_CACHE_DELANG = `https://${APP_LCNAME}/detectlang`;\nexport const URL_CACHE_BINGDICT = `https://${APP_LCNAME}/bingdict`;\n\nexport const URL_KISS_WORKER = \"https://github.com/fishjar/kiss-worker\";\nexport const URL_KISS_PROXY = \"https://github.com/fishjar/kiss-proxy\";\nexport const URL_KISS_RULES = \"https://github.com/fishjar/kiss-rules\";\nexport const URL_KISS_RULES_NEW_ISSUE =\n  \"https://github.com/fishjar/kiss-rules/issues/new\";\nexport const URL_RAW_PREFIX =\n  \"https://raw.githubusercontent.com/fishjar/kiss-translator/master\";\n"
  },
  {
    "path": "src/content.js",
    "content": "import { run } from \"./common\";\n\nglobalThis.__KISS_CONTEXT__ = \"content\";\n\nrun();\n"
  },
  {
    "path": "src/hooks/Alert.js",
    "content": "import {\n  createContext,\n  useContext,\n  useState,\n  forwardRef,\n  useCallback,\n  useMemo,\n} from \"react\";\nimport Snackbar from \"@mui/material/Snackbar\";\nimport MuiAlert from \"@mui/material/Alert\";\n\nconst Alert = forwardRef(function Alert(props, ref) {\n  return <MuiAlert elevation={6} ref={ref} variant=\"filled\" {...props} />;\n});\n\nconst AlertContext = createContext(null);\n\n/**\n * 左下角提示，注入context后，方便全局调用\n * @param {*} param0\n * @returns\n */\nexport function AlertProvider({ children }) {\n  const vertical = \"top\";\n  const horizontal = \"center\";\n  const [open, setOpen] = useState(false);\n  const [severity, setSeverity] = useState(\"info\");\n  const [message, setMessage] = useState(null);\n\n  const showAlert = useCallback((msg, type) => {\n    // 先关闭当前的alert，然后再打开新的\n    // 这样可以重置autoHideDuration计时器\n    setOpen(false);\n    // 使用setTimeout确保状态更新完成后再打开新的alert\n    setTimeout(() => {\n      setMessage(msg);\n      setSeverity(type);\n      setOpen(true);\n    }, 0);\n  }, []);\n\n  const handleClose = useCallback((_, reason) => {\n    if (reason === \"clickaway\") {\n      return;\n    }\n    setOpen(false);\n  }, []);\n\n  const value = useMemo(\n    () => ({\n      error: (msg) => showAlert(msg, \"error\"),\n      warning: (msg) => showAlert(msg, \"warning\"),\n      info: (msg) => showAlert(msg, \"info\"),\n      success: (msg) => showAlert(msg, \"success\"),\n    }),\n    [showAlert]\n  );\n\n  return (\n    <AlertContext.Provider value={value}>\n      {children}\n      <Snackbar\n        open={open}\n        autoHideDuration={5000}\n        onClose={handleClose}\n        anchorOrigin={{ vertical, horizontal }}\n      >\n        <Alert\n          onClose={handleClose}\n          severity={severity}\n          sx={{\n            minWidth: 300,\n            maxWidth: \"80vw\",\n            wordBreak: \"break-word\",\n            overflowWrap: \"anywhere\",\n          }}\n        >\n          {message}\n        </Alert>\n      </Snackbar>\n    </AlertContext.Provider>\n  );\n}\n\nexport function useAlert() {\n  return useContext(AlertContext);\n}\n"
  },
  {
    "path": "src/hooks/Api.js",
    "content": "import { useCallback, useEffect, useMemo } from \"react\";\nimport { DEFAULT_API_LIST, API_SPE_TYPES } from \"../config\";\nimport { useSetting } from \"./Setting\";\n\nfunction useApiState() {\n  const { setting, updateSetting } = useSetting();\n  // 统一排序，所有使用transApis的地方都是排序好的\n  const transApis = useMemo(\n    () =>\n      [...(setting?.transApis || [])].sort(\n        (a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)\n      ),\n    [setting?.transApis]\n  );\n\n  return { transApis, updateSetting };\n}\n\nexport function useApiList() {\n  const { transApis, updateSetting } = useApiState();\n\n  useEffect(() => {\n    const curSlugs = new Set(transApis.map((api) => api.apiSlug));\n    const missApis = DEFAULT_API_LIST.filter(\n      (api) => !curSlugs.has(api.apiSlug)\n    );\n    if (missApis.length > 0) {\n      updateSetting((prev) => ({\n        ...prev,\n        transApis: [...(prev?.transApis || []), ...missApis],\n      }));\n    }\n  }, [transApis, updateSetting]);\n\n  const userApis = useMemo(\n    () =>\n      transApis\n        .filter((api) => !API_SPE_TYPES.builtin.has(api.apiSlug))\n        .sort((a, b) => a.apiSlug.localeCompare(b.apiSlug)),\n    [transApis]\n  );\n\n  const builtinApis = useMemo(\n    () => transApis.filter((api) => API_SPE_TYPES.builtin.has(api.apiSlug)),\n    [transApis]\n  );\n\n  const enabledApis = useMemo(\n    () => transApis.filter((api) => !api.isDisabled),\n    [transApis]\n  );\n\n  const aiEnabledApis = useMemo(\n    () => enabledApis.filter((api) => API_SPE_TYPES.ai.has(api.apiType)),\n    [enabledApis]\n  );\n\n  const addApi = useCallback(\n    (apiType) => {\n      const defaultApiOpt =\n        DEFAULT_API_LIST.find((da) => da.apiType === apiType) || {};\n      const uuid = crypto.randomUUID();\n      const apiSlug = `${apiType}_${crypto.randomUUID()}`;\n      const apiName = `${apiType}_${uuid.slice(0, 8)}`;\n      const newApi = {\n        ...defaultApiOpt,\n        apiSlug,\n        apiName,\n        apiType,\n      };\n      updateSetting((prev) => ({\n        ...prev,\n        transApis: [...(prev?.transApis || []), newApi],\n      }));\n    },\n    [updateSetting]\n  );\n\n  const copyApi = useCallback(\n    (sourceApi) => {\n      const uuid = crypto.randomUUID();\n      const apiSlug = `${sourceApi.apiType}_${uuid}`;\n      const apiName = `${sourceApi.apiName} - copy`;\n      const newApi = {\n        ...sourceApi,\n        apiSlug,\n        apiName,\n      };\n      updateSetting((prev) => ({\n        ...prev,\n        transApis: [...(prev?.transApis || []), newApi],\n      }));\n    },\n    [updateSetting]\n  );\n\n  const deleteApi = useCallback(\n    (apiSlug) => {\n      updateSetting((prev) => ({\n        ...prev,\n        transApis: (prev?.transApis || []).filter(\n          (api) => api.apiSlug !== apiSlug\n        ),\n      }));\n    },\n    [updateSetting]\n  );\n\n  return {\n    transApis,\n    userApis,\n    builtinApis,\n    enabledApis,\n    aiEnabledApis,\n    addApi,\n    copyApi,\n    deleteApi,\n  };\n}\n\nexport function useApiItem(apiSlug) {\n  const { transApis, updateSetting } = useApiState();\n\n  const api = useMemo(\n    () => transApis.find((a) => a.apiSlug === apiSlug),\n    [transApis, apiSlug]\n  );\n\n  const update = useCallback(\n    (updateData) => {\n      updateSetting((prev) => ({\n        ...prev,\n        transApis: (prev?.transApis || []).map((item) =>\n          item.apiSlug === apiSlug ? { ...item, ...updateData, apiSlug } : item\n        ),\n      }));\n    },\n    [apiSlug, updateSetting]\n  );\n\n  const reset = useCallback(() => {\n    updateSetting((prev) => ({\n      ...prev,\n      transApis: (prev?.transApis || []).map((item) => {\n        if (item.apiSlug === apiSlug) {\n          const defaultApiOpt =\n            DEFAULT_API_LIST.find((da) => da.apiType === item.apiType) || {};\n          return {\n            ...defaultApiOpt,\n            apiSlug: item.apiSlug,\n            apiName: item.apiName,\n            apiType: item.apiType,\n            key: item.key,\n          };\n        }\n        return item;\n      }),\n    }));\n  }, [apiSlug, updateSetting]);\n\n  return { api, update, reset };\n}\n"
  },
  {
    "path": "src/hooks/Audio.js",
    "content": "import { useCallback, useEffect, useRef, useState } from \"react\";\nimport { logger } from \"../libs/log\";\nimport { fetchData } from \"../libs/fetch\";\n\n/**\n * 声音播放hook\n * @param {*} src\n * @returns\n */\nexport function useAudio(src) {\n  const audioRef = useRef(null);\n  const [error, setError] = useState(null);\n  const [ready, setReady] = useState(false);\n  const [playing, setPlaying] = useState(false);\n  const [loading, setLoading] = useState(false);\n\n  const onPlay = useCallback(async () => {\n    if (!audioRef.current) return;\n    try {\n      await audioRef.current.play();\n    } catch (err) {\n      logger.info(\"Playback failed:\", err);\n      setPlaying(false);\n    }\n  }, []);\n\n  const onPause = useCallback(() => {\n    audioRef.current?.pause();\n  }, []);\n\n  useEffect(() => {\n    if (!src) return;\n\n    let ignore = false;\n    let objectUrl = null;\n\n    setReady(false);\n    setError(null);\n    setPlaying(false);\n    setLoading(true);\n\n    const audio = new Audio();\n    audioRef.current = audio;\n\n    const handleCanPlay = () => setReady(true);\n    const handlePlay = () => setPlaying(true);\n    const handlePause = () => setPlaying(false);\n    const handleEnded = () => setPlaying(false);\n    const handleError = (e) => {\n      if (!ignore) {\n        setError(audio.error || e);\n        setReady(false);\n        setLoading(false);\n      }\n    };\n\n    audio.addEventListener(\"canplaythrough\", handleCanPlay);\n    audio.addEventListener(\"play\", handlePlay);\n    audio.addEventListener(\"pause\", handlePause);\n    audio.addEventListener(\"ended\", handleEnded);\n    audio.addEventListener(\"error\", handleError);\n\n    const loadAudio = async () => {\n      try {\n        const data = await fetchData(src, {}, { expect: \"audio\" });\n        if (ignore) return;\n\n        audio.src = data;\n\n        setLoading(false);\n      } catch (err) {\n        if (!ignore) {\n          logger.info(\"Audio fetch failed:\", err);\n          setError(err);\n          setLoading(false);\n        }\n      }\n    };\n\n    loadAudio();\n\n    return () => {\n      ignore = true;\n\n      audio.pause();\n      audio.removeAttribute(\"src\");\n\n      if (objectUrl) {\n        URL.revokeObjectURL(objectUrl);\n      }\n\n      audio.removeEventListener(\"canplaythrough\", handleCanPlay);\n      audio.removeEventListener(\"play\", handlePlay);\n      audio.removeEventListener(\"pause\", handlePause);\n      audio.removeEventListener(\"ended\", handleEnded);\n      audio.removeEventListener(\"error\", handleError);\n    };\n  }, [src]);\n\n  return {\n    loading,\n    error,\n    ready,\n    playing,\n    onPlay,\n    onPause,\n  };\n}\n"
  },
  {
    "path": "src/hooks/ColorMode.js",
    "content": "import { useCallback } from \"react\";\nimport { useSetting } from \"./Setting\";\n\n/**\n * 深色模式hook\n * @returns\n */\nexport function useDarkMode() {\n  const {\n    setting: { darkMode },\n    updateSetting,\n  } = useSetting();\n\n  const toggleDarkMode = useCallback(() => {\n    const nextMode = {\n      light: \"dark\",\n      dark: \"auto\",\n      auto: \"light\",\n    };\n    updateSetting({ darkMode: nextMode[darkMode] || \"light\" });\n  }, [darkMode, updateSetting]);\n\n  return { darkMode, toggleDarkMode };\n}\n"
  },
  {
    "path": "src/hooks/Confirm.js",
    "content": "import {\n  useState,\n  useContext,\n  createContext,\n  useCallback,\n  useRef,\n  useMemo,\n} from \"react\";\nimport Dialog from \"@mui/material/Dialog\";\nimport DialogActions from \"@mui/material/DialogActions\";\nimport DialogContent from \"@mui/material/DialogContent\";\nimport DialogContentText from \"@mui/material/DialogContentText\";\nimport DialogTitle from \"@mui/material/DialogTitle\";\nimport Button from \"@mui/material/Button\";\nimport { useI18n } from \"./I18n\";\n\nconst ConfirmContext = createContext(null);\n\nexport function ConfirmProvider({ children }) {\n  const [dialogConfig, setDialogConfig] = useState(null);\n  const resolveRef = useRef(null);\n  const i18n = useI18n();\n\n  const translatedDefaults = useMemo(\n    () => ({\n      title: i18n(\"confirm_title\", \"Confirm\"),\n      message: i18n(\"confirm_message\", \"Are you sure you want to proceed?\"),\n      confirmText: i18n(\"confirm_action\", \"Confirm\"),\n      cancelText: i18n(\"cancel_action\", \"Cancel\"),\n    }),\n    [i18n]\n  );\n\n  const confirm = useCallback(\n    (config) => {\n      return new Promise((resolve) => {\n        setDialogConfig({ ...translatedDefaults, ...config });\n        resolveRef.current = resolve;\n      });\n    },\n    [translatedDefaults]\n  );\n\n  const handleClose = () => {\n    if (resolveRef.current) {\n      resolveRef.current(false);\n    }\n    setDialogConfig(null);\n  };\n\n  const handleConfirm = () => {\n    if (resolveRef.current) {\n      resolveRef.current(true);\n    }\n    setDialogConfig(null);\n  };\n\n  return (\n    <ConfirmContext.Provider value={confirm}>\n      {children}\n\n      <Dialog\n        open={!!dialogConfig}\n        onClose={handleClose}\n        aria-labelledby=\"confirm-dialog-title\"\n        aria-describedby=\"confirm-dialog-description\"\n      >\n        {dialogConfig && (\n          <>\n            <DialogTitle id=\"confirm-dialog-title\">\n              {dialogConfig.title}\n            </DialogTitle>\n            <DialogContent>\n              <DialogContentText id=\"confirm-dialog-description\">\n                {dialogConfig.message}\n              </DialogContentText>\n            </DialogContent>\n            <DialogActions>\n              <Button onClick={handleClose}>{dialogConfig.cancelText}</Button>\n              <Button onClick={handleConfirm} color=\"primary\" autoFocus>\n                {dialogConfig.confirmText}\n              </Button>\n            </DialogActions>\n          </>\n        )}\n      </Dialog>\n    </ConfirmContext.Provider>\n  );\n}\n\nexport function useConfirm() {\n  const context = useContext(ConfirmContext);\n  if (!context) {\n    throw new Error(\"useConfirm must be used within a ConfirmProvider\");\n  }\n  return context;\n}\n"
  },
  {
    "path": "src/hooks/CustomStyles.js",
    "content": "import { useCallback, useMemo } from \"react\";\nimport { useSetting } from \"./Setting\";\nimport { DEFAULT_CUSTOM_STYLES, OPT_STYLE_ALL } from \"../config/styles\";\nimport { builtinStylesMap } from \"../libs/style\";\nimport { useI18n } from \"./I18n\";\n\nfunction useStyleState() {\n  const { setting, updateSetting } = useSetting();\n  const customStyles = setting?.customStyles || [];\n\n  return { customStyles, updateSetting };\n}\n\nexport function useStyleList() {\n  const { customStyles, updateSetting } = useStyleState();\n\n  const addStyle = useCallback(() => {\n    const defaultStyle = DEFAULT_CUSTOM_STYLES[0];\n    const uuid = crypto.randomUUID();\n    const styleSlug = `custom_${crypto.randomUUID()}`;\n    const styleName = `Style_${uuid.slice(0, 8)}`;\n    const newStyle = {\n      ...defaultStyle,\n      styleSlug,\n      styleName,\n    };\n    updateSetting((prev) => ({\n      ...prev,\n      customStyles: [...(prev?.customStyles || []), newStyle],\n    }));\n  }, [updateSetting]);\n\n  const deleteStyle = useCallback(\n    (styleSlug) => {\n      updateSetting((prev) => ({\n        ...prev,\n        customStyles: (prev?.customStyles || []).filter(\n          (item) => item.styleSlug !== styleSlug\n        ),\n      }));\n    },\n    [updateSetting]\n  );\n\n  const updateStyle = useCallback(\n    (styleSlug, updateData) => {\n      updateSetting((prev) => ({\n        ...prev,\n        customStyles: (prev?.customStyles || []).map((item) =>\n          item.styleSlug === styleSlug ? { ...item, ...updateData } : item\n        ),\n      }));\n    },\n    [updateSetting]\n  );\n\n  return {\n    customStyles,\n    addStyle,\n    deleteStyle,\n    updateStyle,\n  };\n}\n\nexport function useAllTextStyles() {\n  const { customStyles } = useStyleList();\n  const i18n = useI18n();\n\n  const builtinStyles = useMemo(\n    () =>\n      OPT_STYLE_ALL.map((styleSlug) => ({\n        styleSlug,\n        styleName: i18n(styleSlug),\n        styleCode: builtinStylesMap[styleSlug] || \"\",\n      })),\n    [i18n]\n  );\n\n  const allTextStyles = useMemo(() => {\n    return [...builtinStyles, ...customStyles];\n  }, [builtinStyles, customStyles]);\n\n  return { builtinStyles, customStyles, allTextStyles };\n}\n"
  },
  {
    "path": "src/hooks/DebouncedCallback.js",
    "content": "import { useMemo, useEffect, useRef } from \"react\";\nimport { debounce } from \"../libs/utils\";\n\nexport function useDebouncedCallback(callback, delay) {\n  const callbackRef = useRef(callback);\n\n  useEffect(() => {\n    callbackRef.current = callback;\n  }, [callback]);\n\n  const debouncedCallback = useMemo(\n    () => debounce((...args) => callbackRef.current(...args), delay),\n    [delay]\n  );\n\n  useEffect(() => {\n    return () => {\n      debouncedCallback.cancel();\n    };\n  }, [debouncedCallback]);\n\n  return debouncedCallback;\n}\n"
  },
  {
    "path": "src/hooks/Fab.js",
    "content": "import { STOKEY_FAB } from \"../config\";\nimport { useStorage } from \"./Storage\";\n\nconst DEFAULT_FAB = {};\n\n/**\n * fab hook\n * @returns\n */\nexport function useFab() {\n  const { data, update } = useStorage(STOKEY_FAB, DEFAULT_FAB);\n  return { fab: data, updateFab: update };\n}\n"
  },
  {
    "path": "src/hooks/FavWords.js",
    "content": "import { STOKEY_WORDS, KV_WORDS_KEY } from \"../config\";\nimport { useCallback, useMemo } from \"react\";\nimport { useStorage } from \"./Storage\";\nimport { debounceSyncMeta } from \"../libs/storage\";\n\nconst DEFAULT_FAVWORDS = {};\n\nexport function useFavWords() {\n  const { data: favWords, save: saveWords } = useStorage(\n    STOKEY_WORDS,\n    DEFAULT_FAVWORDS,\n    KV_WORDS_KEY\n  );\n\n  const save = useCallback(\n    (objOrFn) => {\n      saveWords(objOrFn);\n      debounceSyncMeta(KV_WORDS_KEY);\n    },\n    [saveWords]\n  );\n\n  const toggleFav = useCallback(\n    (word, timestamp = null, phonetic = \"\", definition = \"\", examples = []) => {\n      save((prev) => {\n        if (!prev[word]) {\n          // todo: 除 word 外，其他属性暂无传入\n          const wordData = {\n            createdAt: Date.now(),\n            timestamp,\n            phonetic,\n            definition,\n            examples,\n          };\n          // 清理空值属性\n          Object.keys(wordData).forEach((key) => {\n            if (\n              wordData[key] === null ||\n              wordData[key] === undefined ||\n              (Array.isArray(wordData[key]) && wordData[key].length === 0) ||\n              (typeof wordData[key] === \"string\" && wordData[key].length === 0)\n            ) {\n              delete wordData[key];\n            }\n          });\n          return { ...prev, [word]: wordData };\n        }\n\n        const favs = { ...prev };\n        delete favs[word];\n        return favs;\n      });\n    },\n    [save]\n  );\n\n  const mergeWords = useCallback(\n    (words) => {\n      save((prev) => ({\n        ...words.reduce((acc, key) => {\n          acc[key] = { createdAt: Date.now() };\n          return acc;\n        }, {}),\n        ...prev,\n      }));\n    },\n    [save]\n  );\n\n  const clearWords = useCallback(() => {\n    save({});\n  }, [save]);\n\n  const favList = useMemo(\n    () =>\n      Object.entries(favWords || {}).sort((a, b) => a[0].localeCompare(b[0])),\n    [favWords]\n  );\n\n  const wordList = useMemo(() => favList.map(([word]) => word), [favList]);\n\n  return { favWords, favList, wordList, toggleFav, mergeWords, clearWords };\n}\n"
  },
  {
    "path": "src/hooks/Fetch.js",
    "content": "import { useEffect, useState, useCallback } from \"react\";\n\nexport const useAsync = () => {\n  const [data, setData] = useState(null);\n  const [loading, setLoading] = useState(false);\n  const [error, setError] = useState(null);\n\n  const execute = useCallback(async (fn, ...args) => {\n    if (!fn) {\n      return;\n    }\n\n    setLoading(true);\n    setError(null);\n\n    try {\n      const res = await fn(...args);\n      setData(res);\n      setLoading(false);\n      return res;\n    } catch (err) {\n      setError(err?.message || \"An unknown error occurred\");\n      setLoading(false);\n      // throw err;\n    }\n  }, []);\n\n  const reset = useCallback(() => {\n    setData(null);\n    setLoading(false);\n    setError(null);\n  }, []);\n\n  return { data, loading, error, execute, reset };\n};\n\nexport const useAsyncNow = (fn, arg) => {\n  const { execute, ...asyncState } = useAsync();\n\n  useEffect(() => {\n    if (fn) {\n      execute(fn, arg);\n    }\n  }, [execute, fn, arg]);\n\n  return { ...asyncState };\n};\n\nexport const useFetch = () => {\n  const { execute, ...asyncState } = useAsync();\n\n  const requester = useCallback(async (url, options) => {\n    const response = await fetch(url, options);\n    if (!response.ok) {\n      const errorInfo = await response.text();\n      throw new Error(\n        `Request failed: ${response.status} ${response.statusText} - ${errorInfo}`\n      );\n    }\n    if (response.status === 204) {\n      return null;\n    }\n\n    if (response.headers.get(\"Content-Type\")?.includes(\"json\")) {\n      return response.json();\n    }\n\n    return response.text();\n  }, []);\n\n  const get = useCallback(\n    async (url, options = {}) => {\n      try {\n        const result = await execute(requester, url, {\n          ...options,\n          method: \"GET\",\n        });\n        return result;\n      } catch (err) {\n        return null;\n      }\n    },\n    [execute, requester]\n  );\n\n  const post = useCallback(\n    async (url, body, options = {}) => {\n      try {\n        const result = await execute(requester, url, {\n          ...options,\n          method: \"POST\",\n          headers: { \"Content-Type\": \"application/json\", ...options.headers },\n          body: JSON.stringify(body),\n        });\n        return result;\n      } catch (err) {\n        return null;\n      }\n    },\n    [execute, requester]\n  );\n\n  const put = useCallback(\n    async (url, body, options = {}) => {\n      try {\n        const result = await execute(requester, url, {\n          ...options,\n          method: \"PUT\",\n          headers: { \"Content-Type\": \"application/json\", ...options.headers },\n          body: JSON.stringify(body),\n        });\n        return result;\n      } catch (err) {\n        return null;\n      }\n    },\n    [execute, requester]\n  );\n\n  const del = useCallback(\n    async (url, options = {}) => {\n      try {\n        const result = await execute(requester, url, {\n          ...options,\n          method: \"DELETE\",\n        });\n        return result;\n      } catch (err) {\n        return null;\n      }\n    },\n    [execute, requester]\n  );\n\n  return {\n    ...asyncState,\n    get,\n    post,\n    put,\n    del,\n  };\n};\n\nexport const useGet = (url) => {\n  const { get, ...fetchState } = useFetch();\n\n  useEffect(() => {\n    if (url) get(url);\n  }, [url, get]);\n\n  return { ...fetchState };\n};\n"
  },
  {
    "path": "src/hooks/I18n.js",
    "content": "import { useSetting } from \"./Setting\";\nimport { I18N, URL_RAW_PREFIX } from \"../config\";\nimport { useGet } from \"./Fetch\";\n\nexport const getI18n = (uiLang, key, defaultText = \"\") => {\n  return I18N?.[key]?.[uiLang] ?? defaultText;\n};\n\nexport const useLangMap = (uiLang) => {\n  return (key, defaultText = \"\") => getI18n(uiLang, key, defaultText);\n};\n\n/**\n * 多语言 hook\n * @returns\n */\nexport const useI18n = () => {\n  const {\n    setting: { uiLang },\n  } = useSetting();\n  return useLangMap(uiLang);\n};\n\nexport const useI18nMd = (key) => {\n  const i18n = useI18n();\n  const fileName = i18n(key);\n  const url = fileName ? `${URL_RAW_PREFIX}/${fileName}` : \"\";\n  return useGet(url);\n};\n"
  },
  {
    "path": "src/hooks/InputRule.js",
    "content": "import { DEFAULT_INPUT_RULE } from \"../config\";\nimport { useSetting } from \"./Setting\";\n\nexport function useInputRule() {\n  const { setting, updateChild } = useSetting();\n  const inputRule = setting?.inputRule || DEFAULT_INPUT_RULE;\n  const updateInputRule = updateChild(\"inputRule\");\n\n  return { inputRule, updateInputRule };\n}\n"
  },
  {
    "path": "src/hooks/Loading.js",
    "content": "import CircularProgress from \"@mui/material/CircularProgress\";\nimport Link from \"@mui/material/Link\";\nimport Divider from \"@mui/material/Divider\";\n\nexport default function Loading() {\n  return (\n    <center>\n      <Divider>\n        <Link\n          href={process.env.REACT_APP_HOMEPAGE}\n        >{`KISS Translator v${process.env.REACT_APP_VERSION}`}</Link>\n      </Divider>\n      <CircularProgress />\n    </center>\n  );\n}\n"
  },
  {
    "path": "src/hooks/MouseHover.js",
    "content": "import { DEFAULT_MOUSE_HOVER_SETTING } from \"../config\";\nimport { useSetting } from \"./Setting\";\n\nexport function useMouseHoverSetting() {\n  const { setting, updateChild } = useSetting();\n  const mouseHoverSetting =\n    setting?.mouseHoverSetting || DEFAULT_MOUSE_HOVER_SETTING;\n  const updateMouseHoverSetting = updateChild(\"mouseHoverSetting\");\n\n  return { mouseHoverSetting, updateMouseHoverSetting };\n}\n"
  },
  {
    "path": "src/hooks/Rules.js",
    "content": "import { STOKEY_RULES, DEFAULT_RULES, KV_RULES_KEY } from \"../config\";\nimport { useStorage } from \"./Storage\";\nimport { checkRules } from \"../libs/rules\";\nimport { useCallback } from \"react\";\nimport { debounceSyncMeta } from \"../libs/storage\";\n\n/**\n * 规则 hook\n * @returns\n */\nexport function useRules() {\n  const { data: list = [], save: saveRules } = useStorage(\n    STOKEY_RULES,\n    DEFAULT_RULES,\n    KV_RULES_KEY\n  );\n\n  const save = useCallback(\n    (objOrFn) => {\n      saveRules(objOrFn);\n      debounceSyncMeta(KV_RULES_KEY);\n    },\n    [saveRules]\n  );\n\n  const add = useCallback(\n    (rule) => {\n      save((prev) => {\n        if (\n          rule.pattern === \"*\" ||\n          prev.some((item) => item.pattern === rule.pattern)\n        ) {\n          return prev;\n        }\n        return [rule, ...prev];\n      });\n    },\n    [save]\n  );\n\n  const del = useCallback(\n    (pattern) => {\n      save((prev) => {\n        if (pattern === \"*\") {\n          return prev;\n        }\n        return prev.filter((item) => item.pattern !== pattern);\n      });\n    },\n    [save]\n  );\n\n  const clear = useCallback(() => {\n    save((prev) => prev.filter((item) => item.pattern === \"*\"));\n  }, [save]);\n\n  const put = useCallback(\n    (pattern, obj) => {\n      save((prev) => {\n        // if (pattern !== obj.pattern) {\n        //   return prev;\n        // }\n        return prev.map((item) =>\n          item.pattern === pattern ? { ...item, ...obj } : item\n        );\n      });\n    },\n    [save]\n  );\n\n  const merge = useCallback(\n    (rules) => {\n      save((prev) => {\n        const adds = checkRules(rules);\n        if (adds.length === 0) {\n          return prev;\n        }\n\n        // const map = new Map();\n        // // 不进行深度合并\n        // // [...prev, ...adds].forEach((item) => {\n        // //   const k = item.pattern;\n        // //   map.set(k, { ...(map.get(k) || {}), ...item });\n        // // });\n        // prev.forEach((item) => map.set(item.pattern, item));\n        // adds.forEach((item) => map.set(item.pattern, item));\n        // return [...map.values()];\n\n        const addsMap = new Map(adds.map((item) => [item.pattern, item]));\n        const prevPatterns = new Set(prev.map((item) => item.pattern));\n        const updatedPrev = prev.map(\n          (prevItem) => addsMap.get(prevItem.pattern) || prevItem\n        );\n        const newItems = adds.filter(\n          (addItem) => !prevPatterns.has(addItem.pattern)\n        );\n\n        return [...newItems, ...updatedPrev];\n      });\n    },\n    [save]\n  );\n\n  return { list, add, del, clear, put, merge };\n}\n"
  },
  {
    "path": "src/hooks/Setting.js",
    "content": "import {\n  createContext,\n  useCallback,\n  useContext,\n  useMemo,\n  useEffect,\n} from \"react\";\nimport Alert from \"@mui/material/Alert\";\nimport {\n  STOKEY_SETTING,\n  DEFAULT_SETTING,\n  KV_SETTING_KEY,\n  MSG_SET_LOGLEVEL,\n} from \"../config\";\nimport { useStorage } from \"./Storage\";\nimport { debounceSyncMeta } from \"../libs/storage\";\nimport Loading from \"./Loading\";\nimport { logger } from \"../libs/log\";\nimport { sendBgMsg } from \"../libs/msg\";\nimport { isExt } from \"../libs/client\";\n\nconst SettingContext = createContext({\n  setting: DEFAULT_SETTING,\n  updateSetting: () => {},\n  reloadSetting: () => {},\n});\n\nexport function SettingProvider({ children, context }) {\n  const isOptionsPage = useMemo(() => context === \"options\", [context]);\n\n  const {\n    data: setting,\n    isLoading,\n    update,\n    reload,\n  } = useStorage(STOKEY_SETTING, DEFAULT_SETTING, KV_SETTING_KEY);\n\n  useEffect(() => {\n    if (typeof setting?.darkMode === \"boolean\") {\n      update((currentSetting) => ({\n        ...currentSetting,\n        darkMode: currentSetting.darkMode ? \"dark\" : \"light\",\n      }));\n    }\n  }, [setting?.darkMode, update]);\n\n  useEffect(() => {\n    if (!isOptionsPage) return;\n\n    (async () => {\n      try {\n        logger.setLevel(setting?.logLevel);\n        if (isExt) {\n          await sendBgMsg(MSG_SET_LOGLEVEL, setting?.logLevel);\n        }\n      } catch (error) {\n        logger.error(\"Failed to fetch log level, using default.\", error);\n      }\n    })();\n  }, [isOptionsPage, setting?.logLevel]);\n\n  const updateSetting = useCallback(\n    (objOrFn) => {\n      update(objOrFn);\n      debounceSyncMeta(KV_SETTING_KEY);\n    },\n    [update]\n  );\n\n  const updateChild = useCallback(\n    (key) => async (obj) => {\n      updateSetting((prev) => ({\n        ...prev,\n        [key]: { ...(prev?.[key] || {}), ...obj },\n      }));\n    },\n    [updateSetting]\n  );\n\n  const value = useMemo(\n    () => ({\n      context,\n      setting,\n      updateSetting,\n      updateChild,\n      reloadSetting: reload,\n    }),\n    [context, setting, updateSetting, updateChild, reload]\n  );\n\n  if (isLoading) {\n    return isOptionsPage ? <Loading /> : null;\n  }\n\n  if (!setting) {\n    return isOptionsPage ? (\n      <center>\n        <Alert severity=\"error\" sx={{ maxWidth: 600, margin: \"60px auto\" }}>\n          <p>数据加载出错，请刷新页面或卸载后重新安装。</p>\n          <p>\n            Data loading error, please refresh the page or uninstall and\n            reinstall.\n          </p>\n        </Alert>\n      </center>\n    ) : null;\n  }\n\n  return (\n    <SettingContext.Provider value={value}>{children}</SettingContext.Provider>\n  );\n}\n\n/**\n * 设置 hook\n * @returns\n */\nexport function useSetting() {\n  return useContext(SettingContext);\n}\n"
  },
  {
    "path": "src/hooks/Shortcut.js",
    "content": "import { useCallback } from \"react\";\nimport { DEFAULT_SHORTCUTS } from \"../config\";\nimport { useSetting } from \"./Setting\";\n\nexport function useShortcut(action) {\n  const { setting, updateSetting } = useSetting();\n  const shortcuts = setting?.shortcuts || DEFAULT_SHORTCUTS;\n  const shortcut = shortcuts[action] || [];\n  const setShortcut = useCallback(\n    (val) => {\n      updateSetting((prev) => ({\n        ...prev,\n        shortcuts: { ...(prev?.shortcuts || {}), [action]: val },\n      }));\n    },\n    [action, updateSetting]\n  );\n\n  return { shortcut, setShortcut };\n}\n"
  },
  {
    "path": "src/hooks/Storage.js",
    "content": "import { useCallback, useEffect, useState } from \"react\";\nimport { storage } from \"../libs/storage\";\nimport { kissLog } from \"../libs/log\";\nimport { syncData } from \"../libs/sync\";\nimport { useDebouncedCallback } from \"./DebouncedCallback\";\nimport { isOptions } from \"../libs/browser\";\n\n/**\n * 用于将组件状态与 Storage 同步\n *\n * @param {string} key 用于在 Storage 中存取值的键\n * @param {*} defaultVal 默认值。建议在组件外定义为常量。\n * @param {string} [syncKey=\"\"] 用于远端同步的可选键名\n * @returns {{\n * data: *,\n * save: (valueOrFn: any | ((prevData: any) => any)) => void,\n * update: (partialDataOrFn: object | ((prevData: object) => object)) => void,\n * remove: () => Promise<void>,\n * reload: () => Promise<void>\n * }}\n */\nexport function useStorage(key, defaultVal = null, syncKey = \"\") {\n  const [isLoading, setIsLoading] = useState(true);\n  const [data, setData] = useState(defaultVal);\n\n  // 首次加载数据\n  useEffect(() => {\n    let isMounted = true;\n\n    const loadInitialData = async () => {\n      try {\n        const storedVal = await storage.getObj(key);\n        if (storedVal === undefined || storedVal === null) {\n          await storage.setObj(key, defaultVal);\n        } else if (isMounted) {\n          setData(storedVal);\n        }\n      } catch (err) {\n        kissLog(`storage load error for key: ${key}`, err);\n      } finally {\n        if (isMounted) {\n          setIsLoading(false);\n        }\n      }\n    };\n\n    loadInitialData();\n\n    return () => {\n      isMounted = false;\n    };\n  }, [key, defaultVal]);\n\n  // 远端同步\n  const runSync = useCallback(async (keyToSync, valueToSync) => {\n    try {\n      const res = await syncData(keyToSync, valueToSync);\n      if (res?.isNew) {\n        setData(res.value);\n      }\n    } catch (error) {\n      kissLog(\"Sync failed\", keyToSync);\n    }\n  }, []);\n\n  const debouncedSync = useDebouncedCallback(runSync, 3000);\n\n  // 持久化\n  useEffect(() => {\n    if (isLoading) {\n      return;\n    }\n\n    if (data === null) {\n      return;\n    }\n\n    storage.setObj(key, data).catch((err) => {\n      kissLog(`storage save error for key: ${key}`, err);\n    });\n\n    // 触发远端同步\n    if (syncKey && isOptions()) {\n      debouncedSync(syncKey, data);\n    }\n  }, [key, syncKey, isLoading, data, debouncedSync]);\n\n  /**\n   * 全量替换状态值\n   * @param {any | ((prevData: any) => any)} valueOrFn 新的值或一个返回新值的函数。\n   */\n  const save = useCallback((valueOrFn) => {\n    // kissLog(\"save storage:\", valueOrFn);\n    setData((prevData) =>\n      typeof valueOrFn === \"function\" ? valueOrFn(prevData) : valueOrFn\n    );\n  }, []);\n\n  /**\n   * 合并对象到当前状态（假设状态是一个对象）。\n   * @param {object | ((prevData: object) => object)} partialDataOrFn 要合并的对象或一个返回该对象的函数。\n   */\n  const update = useCallback((partialDataOrFn) => {\n    // kissLog(\"update storage:\", partialDataOrFn);\n    setData((prevData) => {\n      const partialData =\n        typeof partialDataOrFn === \"function\"\n          ? partialDataOrFn(prevData)\n          : partialDataOrFn;\n      // 确保 preData 是一个对象，避免展开 null 或 undefined\n      const baseObj =\n        typeof prevData === \"object\" && prevData !== null ? prevData : {};\n      return { ...baseObj, ...partialData };\n    });\n  }, []);\n\n  /**\n   * 从 Storage 中删除该值，并将状态重置为 null。\n   */\n  const remove = useCallback(async () => {\n    // kissLog(\"remove storage:\");\n    try {\n      await storage.del(key);\n      setData(null);\n    } catch (err) {\n      kissLog(`storage remove error for key: ${key}`, err);\n    }\n  }, [key]);\n\n  /**\n   * 从 Storage 重新加载数据以覆盖当前状态。\n   */\n  const reload = useCallback(async () => {\n    // kissLog(\"reload storage:\");\n    try {\n      const storedVal = await storage.getObj(key);\n      setData(storedVal ?? defaultVal);\n    } catch (err) {\n      kissLog(`storage reload error for key: ${key}`, err);\n      // setData(defaultVal);\n    }\n  }, [key, defaultVal]);\n\n  return { data, save, update, remove, reload, isLoading };\n}\n"
  },
  {
    "path": "src/hooks/SubRules.js",
    "content": "import { DEFAULT_SUBRULES_LIST } from \"../config\";\nimport { useSetting } from \"./Setting\";\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { loadOrFetchSubRules } from \"../libs/subRules\";\nimport { kissLog } from \"../libs/log\";\n\n/**\n * 订阅规则\n * @returns\n */\nexport function useSubRules() {\n  const [loading, setLoading] = useState(false);\n  const [selectedRules, setSelectedRules] = useState([]);\n  const { setting, updateSetting } = useSetting();\n  const list = setting?.subrulesList || DEFAULT_SUBRULES_LIST;\n\n  const selectedSub = useMemo(() => list.find((item) => item.selected), [list]);\n  const selectedUrl = selectedSub.url;\n\n  const selectSub = useCallback(\n    (url) => {\n      updateSetting((prev) => ({\n        ...prev,\n        subrulesList: prev.subrulesList.map((item) => ({\n          ...item,\n          selected: item.url === url,\n        })),\n      }));\n    },\n    [updateSetting]\n  );\n\n  const addSub = useCallback(\n    (url) => {\n      updateSetting((prev) => ({\n        ...prev,\n        subrulesList: [...prev.subrulesList, { url, selected: false }],\n      }));\n    },\n    [updateSetting]\n  );\n\n  const delSub = useCallback(\n    (url) => {\n      updateSetting((prev) => ({\n        ...prev,\n        subrulesList: prev.subrulesList.filter((item) => item.url !== url),\n      }));\n    },\n    [updateSetting]\n  );\n\n  useEffect(() => {\n    (async () => {\n      if (selectedUrl) {\n        try {\n          setLoading(true);\n          const rules = await loadOrFetchSubRules(selectedUrl);\n          setSelectedRules(rules);\n        } catch (err) {\n          kissLog(\"loadOrFetchSubRules\", err);\n        } finally {\n          setLoading(false);\n        }\n      }\n    })();\n  }, [selectedUrl]);\n\n  return {\n    subList: list,\n    selectSub,\n    addSub,\n    delSub,\n    selectedSub,\n    selectedUrl,\n    selectedRules,\n    setSelectedRules,\n    loading,\n  };\n}\n"
  },
  {
    "path": "src/hooks/Subtitle.js",
    "content": "import { DEFAULT_SUBTITLE_SETTING } from \"../config\";\nimport { useSetting } from \"./Setting\";\n\nexport function useSubtitle() {\n  const { setting, updateChild } = useSetting();\n  const subtitleSetting = setting?.subtitleSetting || DEFAULT_SUBTITLE_SETTING;\n  const updateSubtitle = updateChild(\"subtitleSetting\");\n\n  return { subtitleSetting, updateSubtitle };\n}\n"
  },
  {
    "path": "src/hooks/Sync.js",
    "content": "import { useCallback, useMemo } from \"react\";\nimport { STOKEY_SYNC, DEFAULT_SYNC } from \"../config\";\nimport { useStorage } from \"./Storage\";\n\n/**\n * sync hook\n * @returns\n */\nexport function useSync() {\n  const { data, update, reload } = useStorage(STOKEY_SYNC, DEFAULT_SYNC);\n  return { sync: data, updateSync: update, reloadSync: reload };\n}\n\n/**\n * update syncmeta hook\n * @returns\n */\nexport function useSyncMeta() {\n  const { updateSync } = useSync();\n\n  const updateSyncMeta = useCallback(\n    (key) => {\n      updateSync((prevSync) => {\n        const newSyncMeta = {\n          ...(prevSync?.syncMeta || {}),\n          [key]: {\n            ...(prevSync?.syncMeta?.[key] || {}),\n            updateAt: Date.now(),\n          },\n        };\n        return { syncMeta: newSyncMeta };\n      });\n    },\n    [updateSync]\n  );\n\n  return { updateSyncMeta };\n}\n\n/**\n * caches sync hook\n * @param {*} url\n * @returns\n */\nexport function useSyncCaches() {\n  const { sync, updateSync, reloadSync } = useSync();\n\n  const updateDataCache = useCallback(\n    (url) => {\n      updateSync((prevSync) => ({\n        dataCaches: {\n          ...(prevSync?.dataCaches || {}),\n          [url]: Date.now(),\n        },\n      }));\n    },\n    [updateSync]\n  );\n\n  const deleteDataCache = useCallback(\n    (url) => {\n      updateSync((prevSync) => {\n        const newDataCaches = { ...(prevSync?.dataCaches || {}) };\n        delete newDataCaches[url];\n        return { dataCaches: newDataCaches };\n      });\n    },\n    [updateSync]\n  );\n\n  const dataCaches = useMemo(() => sync?.dataCaches || {}, [sync?.dataCaches]);\n\n  return {\n    dataCaches,\n    updateDataCache,\n    deleteDataCache,\n    reloadSync,\n  };\n}\n"
  },
  {
    "path": "src/hooks/Theme.js",
    "content": "import { useEffect, useMemo, useState } from \"react\";\nimport { ThemeProvider, createTheme } from \"@mui/material/styles\";\nimport { CssBaseline, GlobalStyles } from \"@mui/material\";\nimport { useDarkMode } from \"./ColorMode\";\nimport { THEME_DARK, THEME_LIGHT } from \"../config\";\n\n/**\n * mui 主题配置\n * @param {*} param0\n * @returns\n */\nexport default function Theme({ children, options = {}, styles = {} }) {\n  const { darkMode } = useDarkMode();\n  const [systemMode, setSystemMode] = useState(THEME_LIGHT);\n\n  useEffect(() => {\n    if (typeof window.matchMedia !== \"function\") {\n      return;\n    }\n    const mediaQuery = window.matchMedia(\"(prefers-color-scheme: dark)\");\n    const handleChange = () => {\n      setSystemMode(mediaQuery.matches ? THEME_DARK : THEME_LIGHT);\n    };\n    handleChange(); // Set initial value\n    mediaQuery.addEventListener(\"change\", handleChange);\n    return () => mediaQuery.removeEventListener(\"change\", handleChange);\n  }, []);\n\n  const theme = useMemo(() => {\n    let htmlFontSize = 16;\n    try {\n      const s = window.getComputedStyle(document.documentElement).fontSize;\n      htmlFontSize = parseInt(s.replace(\"px\", \"\"));\n    } catch (err) {\n      //\n    }\n\n    const isDarkMode =\n      darkMode === \"dark\" || (darkMode === \"auto\" && systemMode === THEME_DARK);\n\n    return createTheme({\n      palette: {\n        mode: isDarkMode ? THEME_DARK : THEME_LIGHT,\n      },\n      typography: {\n        htmlFontSize,\n      },\n      ...options,\n    });\n  }, [darkMode, options, systemMode]);\n\n  return (\n    <ThemeProvider theme={theme}>\n      {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}\n      <CssBaseline />\n      <GlobalStyles styles={styles} />\n      {children}\n    </ThemeProvider>\n  );\n}\n"
  },
  {
    "path": "src/hooks/Tranbox.js",
    "content": "import { DEFAULT_TRANBOX_SETTING } from \"../config\";\nimport { useSetting } from \"./Setting\";\n\nexport function useTranbox() {\n  const { setting, updateChild } = useSetting();\n  const tranboxSetting = setting?.tranboxSetting || DEFAULT_TRANBOX_SETTING;\n  const updateTranbox = updateChild(\"tranboxSetting\");\n\n  return { tranboxSetting, updateTranbox };\n}\n"
  },
  {
    "path": "src/hooks/ValidationInput.js",
    "content": "import { useState, useEffect } from \"react\";\nimport TextField from \"@mui/material/TextField\";\nimport { limitNumber, limitFloat } from \"../libs/utils\";\n\nfunction ValidationInput({\n  value,\n  onChange,\n  name,\n  min,\n  max,\n  isFloat = false,\n  ...props\n}) {\n  const [localValue, setLocalValue] = useState(value);\n\n  useEffect(() => {\n    setLocalValue(value);\n  }, [value]);\n\n  const handleLocalChange = (e) => {\n    setLocalValue(e.target.value);\n  };\n\n  const handleBlur = () => {\n    const numValue = Number(localValue);\n\n    if (isNaN(numValue)) {\n      setLocalValue(value);\n      return;\n    }\n\n    const validatedValue = isFloat\n      ? limitFloat(numValue, min, max)\n      : limitNumber(numValue, min, max);\n\n    if (validatedValue !== numValue) {\n      setLocalValue(validatedValue);\n    }\n\n    onChange({\n      target: {\n        name: name,\n        value: validatedValue,\n      },\n      preventDefault: () => {},\n    });\n  };\n\n  return (\n    <TextField\n      {...props}\n      type=\"number\"\n      name={name}\n      value={localValue}\n      onChange={handleLocalChange}\n      onBlur={handleBlur}\n    />\n  );\n}\n\nexport default ValidationInput;\n"
  },
  {
    "path": "src/hooks/WindowSize.js",
    "content": "import { useState, useEffect } from \"react\";\nimport { useDebouncedCallback } from \"./DebouncedCallback\";\n\nfunction useWindowSize() {\n  const [windowSize, setWindowSize] = useState({\n    w: window.innerWidth,\n    h: window.innerHeight,\n  });\n\n  const debounceWindowResize = useDebouncedCallback(() => {\n    setWindowSize({\n      w: window.innerWidth,\n      h: window.innerHeight,\n    });\n  }, 200);\n\n  useEffect(() => {\n    debounceWindowResize();\n\n    window.addEventListener(\"resize\", debounceWindowResize);\n    return () => {\n      window.removeEventListener(\"resize\", debounceWindowResize);\n    };\n  }, [debounceWindowResize]);\n\n  return windowSize;\n}\n\nexport default useWindowSize;\n"
  },
  {
    "path": "src/hooks/useAutoHideTranBtn.js",
    "content": "import { useEffect, useRef } from \"react\";\n\nexport default function useAutoHideTranBtn(\n  showBtn,\n  setShowBtn,\n  position,\n  options = {}\n) {\n  const { delay = 5000, distance = 100 } = options;\n\n  const timerRef = useRef(null);\n  const originRef = useRef({ x: 0, y: 0 });\n\n  useEffect(() => {\n    if (!showBtn) return;\n\n    originRef.current = position;\n\n    /*等待5 秒自动隐藏翻译按钮*/\n    timerRef.current = setTimeout(() => {\n      setShowBtn(false);\n    }, delay);\n\n    /*鼠标移出 100px 自动隐藏翻译按钮*/\n    const handleMouseMove = (e) => {\n      const { x, y } = originRef.current;\n      const dx = e.clientX - x;\n      const dy = e.clientY - y;\n      if (dx * dx + dy * dy > distance * distance) {\n        setShowBtn(false);\n      }\n    };\n\n    /*点击右键,隐藏翻译按钮*/\n    const handleMouseDown = (e) => {\n      if (e.button === 2) {\n        setShowBtn(false);\n      }\n    };\n\n    window.addEventListener(\"mousemove\", handleMouseMove);\n\n    window.addEventListener(\"mousedown\", handleMouseDown, true);\n\n    return () => {\n      clearTimeout(timerRef.current);\n      window.removeEventListener(\"mousemove\", handleMouseMove);\n      window.removeEventListener(\"mousedown\", handleMouseDown, true);\n    };\n  }, [showBtn, position, delay, distance, setShowBtn]);\n}\n"
  },
  {
    "path": "src/hooks/useSelectionController.js",
    "content": "import { useState, useCallback, useMemo, useEffect } from \"react\";\nimport { sleep, limitNumber } from \"../libs/utils\";\nimport { isMobile } from \"../libs/mobile\";\nimport useAutoHideTranBtn from \"./useAutoHideTranBtn\";\nimport {\n  OPT_TRANBOX_TRIGGER_HOVER,\n  OPT_TRANBOX_TRIGGER_SELECT,\n} from \"../config\";\n\nexport default function useSelectionController({\n  tranboxSetting,\n  followSelection,\n  boxOffsetX,\n  boxOffsetY,\n  boxSize,\n  setBoxPosition,\n  hideClickAway,\n}) {\n  const { hideTranBtn = false, triggerMode } = tranboxSetting;\n\n  const [showBox, setShowBox] = useState(false);\n  const [showBtn, setShowBtn] = useState(false);\n  const [selectedText, setSelText] = useState(\"\"); // 当前选中的文本\n  const [text, setText] = useState(\"\"); // 翻译框中的文本\n  const [position, setPosition] = useState({ x: 0, y: 0 }); // 划词按钮位置\n\n  // 划词按钮自动隐藏\n  useAutoHideTranBtn(showBtn, setShowBtn, position);\n\n  // 打开翻译框\n  const handleOpenTranbox = useCallback(\n    (inputText) => {\n      setShowBtn(false);\n      setText(inputText || selectedText);\n      setShowBox(true);\n    },\n    [selectedText]\n  );\n\n  // 切换翻译框显示状态\n  const handleToggleTranbox = useCallback(() => {\n    setShowBtn(false);\n\n    const selection = window.getSelection();\n    const currentSelectedText = selection?.toString()?.trim() || \"\";\n    if (!currentSelectedText) {\n      setShowBox((pre) => !pre);\n      return;\n    }\n\n    const rect = selection?.getRangeAt(0)?.getBoundingClientRect();\n    // 如果跟随选中文字，重新设置翻译框位置\n    if (rect && followSelection) {\n      const x = (rect.left + rect.right) / 2 + boxOffsetX;\n      const y = rect.bottom + boxOffsetY;\n      setBoxPosition({\n        x: limitNumber(x, 0, window.innerWidth - boxSize.w),\n        y: limitNumber(y, 0, window.innerHeight - 50),\n      });\n    }\n\n    setSelText(currentSelectedText);\n    setText(currentSelectedText);\n    setShowBox(true);\n  }, [followSelection, boxOffsetX, boxOffsetY, setBoxPosition, boxSize]);\n\n  // 翻译按钮绑定事件名称\n  const btnEvent = useMemo(() => {\n    if (isMobile) {\n      return \"onTouchEnd\";\n    } else if (triggerMode === OPT_TRANBOX_TRIGGER_HOVER) {\n      return \"onMouseOver\";\n    }\n    return \"onMouseUp\";\n  }, [triggerMode]);\n\n  // 监听划词事件\n  useEffect(() => {\n    const eventName = isMobile ? \"touchend\" : \"mouseup\";\n\n    async function handleMouseup(e) {\n      // e.stopPropagation();\n      if (e.button === 2) return;\n\n      await sleep(200);\n\n      const selection = window.getSelection();\n      const currentSelectedText = selection?.toString()?.trim() || \"\";\n      setSelText(currentSelectedText);\n      if (!currentSelectedText) {\n        setShowBtn(false);\n        return;\n      }\n\n      const rect = selection?.getRangeAt(0)?.getBoundingClientRect();\n      if (rect && followSelection) {\n        const x = (rect.left + rect.right) / 2 + boxOffsetX;\n        const y = rect.bottom + boxOffsetY;\n        setBoxPosition({\n          x: limitNumber(x, 0, window.innerWidth - boxSize.w),\n          y: limitNumber(y, 0, window.innerHeight - 50),\n        });\n      }\n\n      // 如果触发模式是划词即翻译，直接打开翻译框\n      if (triggerMode === OPT_TRANBOX_TRIGGER_SELECT) {\n        handleOpenTranbox(currentSelectedText);\n        return;\n      }\n\n      const { clientX, clientY } = isMobile ? e.changedTouches[0] : e;\n      setShowBtn(!hideTranBtn);\n      setPosition({ x: clientX, y: clientY });\n    }\n\n    // window.addEventListener(\"mouseup\", handleMouseup);\n    window.addEventListener(eventName, handleMouseup);\n    return () => {\n      window.removeEventListener(eventName, handleMouseup);\n    };\n  }, [\n    hideTranBtn,\n    triggerMode,\n    followSelection,\n    boxOffsetX,\n    boxOffsetY,\n    handleOpenTranbox,\n    boxSize,\n    setBoxPosition,\n  ]);\n\n  // 点击空白处隐藏翻译框\n  useEffect(() => {\n    if (hideClickAway) {\n      const handleHideBox = () => {\n        setShowBox(false);\n      };\n      window.addEventListener(\"click\", handleHideBox);\n      return () => {\n        window.removeEventListener(\"click\", handleHideBox);\n      };\n    }\n  }, [hideClickAway]);\n\n  return {\n    showBox,\n    setShowBox,\n    showBtn,\n    setShowBtn,\n    selectedText,\n    setSelText,\n    text,\n    setText,\n    position,\n    setPosition,\n    handleOpenTranbox,\n    handleToggleTranbox,\n    btnEvent,\n  };\n}\n"
  },
  {
    "path": "src/hooks/useTranBoxState.js",
    "content": "import { useState, useEffect } from \"react\";\nimport { limitNumber } from \"../libs/utils\";\nimport { isMobile } from \"../libs/mobile\";\nimport { debouncePutTranBox, getTranBox } from \"../libs/storage\";\nimport { isIframe } from \"../libs/iframe\";\n\nexport default function useTranBoxState(tranboxSetting) {\n  const {\n    simpleStyle: initSimpleStyle = false,\n    hideClickAway: initHideClickAway = false,\n    followSelection: initFollowMouse = false,\n    boxOffsetX = 0,\n    boxOffsetY = 10,\n  } = tranboxSetting;\n\n  const boxWidth =\n    isMobile || initSimpleStyle\n      ? 400\n      : limitNumber(window.innerWidth, 400, 800);\n  const boxHeight =\n    isMobile || initSimpleStyle\n      ? 200\n      : limitNumber(window.innerHeight, 200, 600);\n\n  const [boxSize, setBoxSize] = useState({\n    w: boxWidth,\n    h: boxHeight,\n  });\n\n  const [boxPosition, setBoxPosition] = useState({\n    x: (window.innerWidth - boxWidth) / 2,\n    y: (window.innerHeight - boxHeight) / 2,\n  });\n\n  const [simpleStyle, setSimpleStyle] = useState(initSimpleStyle);\n  const [hideClickAway, setHideClickAway] = useState(initHideClickAway);\n  const [followSelection, setFollowSelection] = useState(initFollowMouse);\n\n  // 从 storage 恢复位置和大小状态\n  useEffect(() => {\n    (async () => {\n      try {\n        const { w, h, x, y } = (await getTranBox()) || {};\n        if (w !== undefined && h !== undefined) {\n          setBoxSize({\n            w: Math.min(w, window.innerWidth),\n            h: Math.min(h, window.innerHeight),\n          });\n        }\n        if (x !== undefined && y !== undefined) {\n          setBoxPosition({\n            x: limitNumber(x, 0, window.innerWidth - w),\n            y: limitNumber(y, 0, window.innerHeight - 50),\n          });\n        }\n      } catch (err) {\n        //\n      }\n    })();\n  }, []);\n\n  // debounce 存储位置和大小状态到 storage\n  useEffect(() => {\n    // 如果是在iframe中，则不执行\n    if (!isIframe && boxSize.w > 0 && boxSize.h > 0) {\n      debouncePutTranBox({\n        ...boxSize,\n        ...boxPosition,\n      });\n    }\n  }, [boxSize, boxPosition]);\n\n  return {\n    boxSize,\n    setBoxSize,\n    boxPosition,\n    setBoxPosition,\n    simpleStyle,\n    setSimpleStyle,\n    hideClickAway,\n    setHideClickAway,\n    followSelection,\n    setFollowSelection,\n    boxOffsetX,\n    boxOffsetY,\n  };\n}\n"
  },
  {
    "path": "src/hooks/useTranboxShortcuts.js",
    "content": "import { useEffect, useCallback } from \"react\";\nimport { shortcutRegister } from \"../libs/shortcut\";\nimport { isGm, isExt } from \"../libs/client\";\nimport { kissLog } from \"../libs/log\";\nimport { useLangMap } from \"./I18n\";\nimport {\n  MSG_OPEN_TRANBOX,\n  EVENT_KISS_INNER,\n  DEFAULT_TRANBOX_SHORTCUT,\n} from \"../config\";\n\nexport default function useTranboxShortcuts({\n  tranboxSetting,\n  showBox,\n  setShowBox,\n  handleToggleTranbox,\n  contextMenuType,\n  uiLang,\n}) {\n  const { tranboxShortcut = DEFAULT_TRANBOX_SHORTCUT } = tranboxSetting;\n  const langMap = useLangMap(uiLang);\n\n  const handleToggle = useCallback(() => {\n    if (showBox) {\n      setShowBox(false);\n    } else {\n      handleToggleTranbox();\n    }\n  }, [showBox, handleToggleTranbox, setShowBox]);\n\n  // 注册油猴脚本快捷键\n  useEffect(() => {\n    if (isExt) {\n      return;\n    }\n    const clearShortcut = shortcutRegister(tranboxShortcut, handleToggle);\n    return () => {\n      clearShortcut();\n    };\n  }, [tranboxShortcut, handleToggle]);\n\n  // 监听打开翻译框的事件\n  useEffect(() => {\n    const handleStatusUpdate = (event) => {\n      if (event.detail?.action === MSG_OPEN_TRANBOX) {\n        handleToggle();\n      }\n    };\n\n    document.addEventListener(EVENT_KISS_INNER, handleStatusUpdate);\n    return () => {\n      document.removeEventListener(EVENT_KISS_INNER, handleStatusUpdate);\n    };\n  }, [handleToggle]);\n\n  // 注册油猴脚本菜单\n  useEffect(() => {\n    if (!isGm) {\n      return;\n    }\n\n    // 注册菜单\n    try {\n      const menuCommandIds = [];\n      contextMenuType !== 0 &&\n        menuCommandIds.push(\n          GM.registerMenuCommand?.(\n            langMap(\"translate_selected_text\"),\n            (event) => {\n              handleToggleTranbox();\n            },\n            \"S\"\n          )\n        );\n\n      return () => {\n        menuCommandIds.forEach((id) => {\n          GM.unregisterMenuCommand?.(id);\n        });\n      };\n    } catch (err) {\n      kissLog(\"registerMenuCommand\", err);\n    }\n  }, [handleToggleTranbox, contextMenuType, langMap]);\n}\n"
  },
  {
    "path": "src/index.js",
    "content": "import React, { useState } from \"react\";\nimport ReactDOM from \"react-dom/client\";\nimport CircularProgress from \"@mui/material/CircularProgress\";\nimport Divider from \"@mui/material/Divider\";\nimport ReactMarkdown from \"react-markdown\";\nimport Paper from \"@mui/material/Paper\";\nimport Stack from \"@mui/material/Stack\";\nimport Button from \"@mui/material/Button\";\nimport Link from \"@mui/material/Link\";\nimport { useGet } from \"./hooks/Fetch\";\nimport { I18N, URL_RAW_PREFIX } from \"./config\";\n\nfunction App() {\n  const [lang, setLang] = useState(\"zh\");\n  const { data, loading, error } = useGet(\n    `${URL_RAW_PREFIX}/${I18N?.[\"about_md\"]?.[lang]}`\n  );\n\n  return (\n    <Paper sx={{ padding: 2, margin: 2 }}>\n      <Stack spacing={2} direction=\"row\" justifyContent=\"flex-end\">\n        <Button\n          variant=\"text\"\n          onClick={() => {\n            setLang((pre) => (pre === \"zh\" ? \"en\" : \"zh\"));\n          }}\n        >\n          {lang === \"zh\" ? \"ENGLISH\" : \"中文\"}\n        </Button>\n      </Stack>\n      <Divider>\n        <Link\n          href={process.env.REACT_APP_HOMEPAGE}\n        >{`KISS Translator v${process.env.REACT_APP_VERSION}`}</Link>\n      </Divider>\n      <Stack spacing={2}>\n        <Link href={process.env.REACT_APP_USERSCRIPT_DOWNLOADURL}>\n          Install/Update Userscript for Tampermonkey/Violentmonkey\n        </Link>\n        <Link href={process.env.REACT_APP_USERSCRIPT_IOS_DOWNLOADURL}>\n          Install/Update Userscript for iOS Safari\n        </Link>\n        <Link href={process.env.REACT_APP_OPTIONSPAGE}>Open Options Page</Link>\n      </Stack>\n\n      {loading ? (\n        <center>\n          <CircularProgress />\n        </center>\n      ) : (\n        <ReactMarkdown children={error || data} />\n      )}\n    </Paper>\n  );\n}\n\nconst root = ReactDOM.createRoot(document.getElementById(\"root\"));\nroot.render(\n  <React.StrictMode>\n    <App />\n  </React.StrictMode>\n);\n"
  },
  {
    "path": "src/injector-shadowroot.js",
    "content": "import { shadowRootInjector } from \"./injectors/shadowroot\";\n\nshadowRootInjector();\n"
  },
  {
    "path": "src/injector-subtitle.js",
    "content": "import { XMLHttpRequestInjector } from \"./injectors/xmlhttp\";\n\nXMLHttpRequestInjector();\n"
  },
  {
    "path": "src/injectors/index.js",
    "content": "import { browser } from \"../libs/browser\";\nimport { isExt } from \"../libs/client\";\nimport { injectExternalJs, injectInlineJs } from \"../libs/injector\";\nimport { shadowRootInjector } from \"./shadowroot\";\nimport { XMLHttpRequestInjector } from \"./xmlhttp\";\n\nexport const INJECTOR = {\n  subtitle: \"injector-subtitle.js\",\n  shadowroot: \"injector-shadowroot.js\",\n};\n\nconst injectorMap = {\n  [INJECTOR.subtitle]: XMLHttpRequestInjector,\n  [INJECTOR.shadowroot]: shadowRootInjector,\n};\n\nexport function injectJs(name, id = \"kiss-translator-inject-js\") {\n  const injector = injectorMap[name];\n  if (!injector) return;\n\n  if (isExt) {\n    const src = browser.runtime.getURL(name);\n    injectExternalJs(src, id);\n  } else {\n    injectInlineJs(`(${injector})()`, id);\n  }\n}\n"
  },
  {
    "path": "src/injectors/shadowroot.js",
    "content": "export const shadowRootInjector = () => {\n  try {\n    const orig = Element.prototype.attachShadow;\n    Element.prototype.attachShadow = function (...args) {\n      const root = orig.apply(this, args);\n      window.postMessage({ type: \"KISS_SHADOW_ROOT_CREATED\" }, \"*\");\n      return root;\n    };\n  } catch (err) {\n    console.log(\"shadowRootInjector\", err);\n  }\n};\n"
  },
  {
    "path": "src/injectors/xmlhttp.js",
    "content": "export const XMLHttpRequestInjector = () => {\n  try {\n    const originalOpen = XMLHttpRequest.prototype.open;\n    XMLHttpRequest.prototype.open = function (...args) {\n      const url = args[1];\n      if (typeof url === \"string\" && url.includes(\"timedtext\")) {\n        this.addEventListener(\"load\", function () {\n          window.postMessage(\n            {\n              type: \"KISS_XHR_DATA_YOUTUBE\",\n              url: this.responseURL,\n              response: this.responseText,\n            },\n            window.location.origin\n          );\n        });\n      }\n      return originalOpen.apply(this, args);\n    };\n  } catch (err) {\n    console.log(\"XMLHttpRequestInjector\", err);\n  }\n};\n"
  },
  {
    "path": "src/libs/auth.js",
    "content": "import { getMsauth, setMsauth } from \"./storage\";\nimport { kissLog } from \"./log\";\nimport { apiMsAuth } from \"../apis\";\n\nconst parseMSToken = (token) => {\n  try {\n    return JSON.parse(atob(token.split(\".\")[1])).exp;\n  } catch (err) {\n    kissLog(\"parseMSToken\", err);\n  }\n  return 0;\n};\n\n/**\n * 闭包缓存token，减少对storage查询\n * @returns\n */\nconst _msAuth = () => {\n  let tokenPromise = null;\n  const EXPIRATION_MS = 1000;\n\n  const fetchNewToken = async () => {\n    try {\n      const now = Date.now();\n\n      // 1. 查询storage缓存\n      const storageToken = await getMsauth();\n      if (storageToken) {\n        const storageExp = parseMSToken(storageToken);\n        const storageExpiresAt = storageExp * 1000;\n        if (storageExpiresAt > now + EXPIRATION_MS) {\n          return { token: storageToken, expiresAt: storageExpiresAt };\n        }\n      }\n\n      // 2. 缓存没有或失效，查询接口\n      const apiToken = await apiMsAuth();\n      if (!apiToken) {\n        throw new Error(\"Failed to fetch ms token\");\n      }\n\n      const apiExp = parseMSToken(apiToken);\n      const apiExpiresAt = apiExp * 1000;\n      await setMsauth(apiToken);\n      return { token: apiToken, expiresAt: apiExpiresAt };\n    } catch (error) {\n      kissLog(\"get msauth failed\", error);\n      throw error;\n    }\n  };\n\n  return async () => {\n    // 检查是否有缓存的 Promise\n    if (tokenPromise) {\n      try {\n        const cachedResult = await tokenPromise;\n        if (cachedResult.expiresAt > Date.now() + EXPIRATION_MS) {\n          return cachedResult.token;\n        }\n      } catch (error) {\n        //\n      }\n    }\n\n    tokenPromise = fetchNewToken();\n    const result = await tokenPromise;\n    return result.token;\n  };\n};\n\nexport const msAuth = _msAuth();\n"
  },
  {
    "path": "src/libs/batchQueue.js",
    "content": "import {\n  DEFAULT_BATCH_INTERVAL,\n  DEFAULT_BATCH_SIZE,\n  DEFAULT_BATCH_LENGTH,\n} from \"../config\";\n\n/**\n * 批处理队列\n * 支持生成器模式：taskFn 可以是异步生成器，yield {id, result} 逐个返回结果\n * @param {*} taskFn\n * @param {*} options\n * @returns\n */\nconst BatchQueue = (\n  taskFn,\n  {\n    batchInterval = DEFAULT_BATCH_INTERVAL,\n    batchSize = DEFAULT_BATCH_SIZE,\n    batchLength = DEFAULT_BATCH_LENGTH,\n  } = {}\n) => {\n  const queue = [];\n  let isProcessing = false;\n  let timer = null;\n\n  const processQueue = async () => {\n    if (timer) {\n      clearTimeout(timer);\n      timer = null;\n    }\n\n    if (queue.length === 0 || isProcessing) {\n      return;\n    }\n\n    isProcessing = true;\n\n    let tasksToProcess = [];\n    let currentBatchLength = 0;\n    let endIndex = 0;\n\n    for (const task of queue) {\n      const textLength = task.payload?.length || 0;\n      if (\n        endIndex >= batchSize ||\n        (currentBatchLength + textLength > batchLength && endIndex > 0)\n      ) {\n        break;\n      }\n      currentBatchLength += textLength;\n      endIndex++;\n    }\n\n    if (endIndex > 0) {\n      tasksToProcess = queue.splice(0, endIndex);\n    }\n\n    if (tasksToProcess.length === 0) {\n      isProcessing = false;\n      return;\n    }\n\n    try {\n      const payloads = tasksToProcess.map((item) => item.payload);\n      const batchArgs = tasksToProcess[0].args;\n\n      const generator = taskFn(payloads, batchArgs);\n\n      // 检查是否是异步生成器\n      if (generator && typeof generator[Symbol.asyncIterator] === \"function\") {\n        for await (const { id, result } of generator) {\n          const taskItem = tasksToProcess[id];\n          if (taskItem && !taskItem.resolved) {\n            taskItem.resolved = true;\n            taskItem.resolve(result);\n          }\n        }\n\n        // 处理没有收到结果的 task\n        tasksToProcess.forEach((taskItem, index) => {\n          if (!taskItem.resolved) {\n            taskItem.reject(\n              new Error(`No response for item at index ${index}`)\n            );\n          }\n        });\n      } else {\n        // 非生成器模式（兼容旧的 Promise 模式）\n        const responses = await generator;\n        if (!Array.isArray(responses)) {\n          throw new Error(\"responses format error\");\n        }\n\n        tasksToProcess.forEach((taskItem, index) => {\n          const response = responses[index];\n          if (response) {\n            taskItem.resolve(response);\n          } else {\n            taskItem.reject(\n              new Error(`No response for item at index ${index}`)\n            );\n          }\n        });\n      }\n    } catch (error) {\n      tasksToProcess.forEach((taskItem) => {\n        if (!taskItem.resolved) {\n          taskItem.reject(error);\n        }\n      });\n    } finally {\n      isProcessing = false;\n      if (queue.length > 0) {\n        if (queue.length >= batchSize) {\n          setTimeout(processQueue, 0);\n        } else {\n          scheduleProcessing();\n        }\n      }\n    }\n  };\n\n  const scheduleProcessing = () => {\n    if (!isProcessing && !timer && queue.length > 0) {\n      timer = setTimeout(processQueue, batchInterval);\n    }\n  };\n\n  const addTask = (data, args) => {\n    return new Promise((resolve, reject) => {\n      const payload = data;\n      queue.push({ payload, resolve, reject, args });\n\n      if (queue.length >= batchSize) {\n        processQueue();\n      } else {\n        scheduleProcessing();\n      }\n    });\n  };\n\n  const destroy = () => {\n    if (timer) {\n      clearTimeout(timer);\n      timer = null;\n    }\n    queue.forEach((task) =>\n      task.reject(new Error(\"Queue instance was destroyed.\"))\n    );\n    queue.length = 0;\n  };\n\n  return { addTask, destroy };\n};\n\n// 实例字典\nconst queueMap = new Map();\n\n/**\n * 获取批处理实例\n */\nexport const getBatchQueue = (key, taskFn, options) => {\n  if (queueMap.has(key)) {\n    return queueMap.get(key);\n  }\n\n  const queue = BatchQueue(taskFn, options);\n  queueMap.set(key, queue);\n  return queue;\n};\n\n/**\n * 清除所有任务\n */\nexport const clearAllBatchQueue = () => {\n  for (const queue of queueMap.values()) {\n    queue.destroy();\n  }\n};\n"
  },
  {
    "path": "src/libs/blacklist.js",
    "content": "import { isMatch } from \"./utils\";\n\n/**\n * 检查是否在黑名单中\n * @param {*} href\n * @param {*} param1\n * @returns\n */\nexport const isInBlacklist = (href, { blacklist }) =>\n  blacklist.split(/\\n|,/).some((url) => isMatch(href, url.trim()));\n"
  },
  {
    "path": "src/libs/browser.js",
    "content": "// import { CLIENT_EXTS, CLIENT_USERSCRIPT, CLIENT_WEB } from \"../config\";\n\n/**\n * 浏览器兼容插件，另可用于判断是插件模式还是网页模式，方便开发\n * @returns\n */\nfunction _browser() {\n  try {\n    return require(\"webextension-polyfill\");\n  } catch (err) {\n    // kissLog(\"browser\", err);\n  }\n}\n\nexport const browser = _browser();\n\nexport const getContext = () => {\n  const context = globalThis.__KISS_CONTEXT__;\n  if (context) return context;\n\n  // if (typeof window === \"undefined\" || typeof document === \"undefined\") {\n  //   return \"background\";\n  // }\n\n  // const extensionOrigin = browser.runtime.getURL(\"\");\n  // if (!window.location.href.startsWith(extensionOrigin)) {\n  //   return \"content\";\n  // }\n\n  // const pathname = window.location.pathname;\n  // if (pathname.includes(\"popup\")) return \"popup\";\n  // if (pathname.includes(\"options\")) return \"options\";\n  // if (pathname.includes(\"sidepanel\")) return \"sidepanel\";\n  // if (pathname.includes(\"background\")) return \"background\";\n\n  return \"undefined\";\n};\n\nexport const isBg = () => getContext() === \"background\";\nexport const isOptions = () => getContext() === \"options\";\n\nexport const isBuiltinAIAvailable =\n  \"LanguageDetector\" in globalThis && \"Translator\" in globalThis;\n"
  },
  {
    "path": "src/libs/builtinAI.js",
    "content": "import { kissLog, logger } from \"./log\";\n\n/**\n * Chrome 浏览器内置翻译\n */\nclass ChromeTranslator {\n  #translatorMap = new Map();\n  #detectorPromise = null;\n\n  constructor(options = {}) {\n    this.onProgress = options.onProgress || this.#defaultProgressHandler;\n  }\n\n  #defaultProgressHandler(type, progress) {\n    kissLog(`Downloading ${type} model: ${progress}%`);\n  }\n\n  #getDetectorPromise() {\n    if (!this.#detectorPromise) {\n      this.#detectorPromise = (async () => {\n        try {\n          const availability = await LanguageDetector.availability();\n          if (availability === \"unavailable\") {\n            throw new Error(\"LanguageDetector unavailable\");\n          }\n\n          return await LanguageDetector.create({\n            monitor: (m) => this._monitorProgress(m, \"detector\"),\n          });\n        } catch (error) {\n          this.#detectorPromise = null;\n          throw error;\n        }\n      })();\n    }\n\n    return this.#detectorPromise;\n  }\n\n  #createTranslator(sourceLanguage, targetLanguage) {\n    const key = `${sourceLanguage}_${targetLanguage}`;\n    if (this.#translatorMap.has(key)) {\n      return this.#translatorMap.get(key);\n    }\n\n    const translatorPromise = (async () => {\n      try {\n        const avail = await Translator.availability({\n          sourceLanguage,\n          targetLanguage,\n        });\n        if (avail === \"unavailable\") {\n          throw new Error(\n            `Translator ${sourceLanguage}_${targetLanguage} unavailable`\n          );\n        }\n\n        const translator = await Translator.create({\n          sourceLanguage,\n          targetLanguage,\n          monitor: (m) => this._monitorProgress(m, `translator (${key})`),\n        });\n        this.#translatorMap.set(key, translator);\n\n        return translator;\n      } catch (error) {\n        this.#translatorMap.delete(key);\n        throw error;\n      }\n    })();\n\n    this.#translatorMap.set(key, translatorPromise);\n    return translatorPromise;\n  }\n\n  _monitorProgress(monitorable, type) {\n    monitorable.addEventListener(\"downloadprogress\", (e) => {\n      const progress = e.total > 0 ? Math.round((e.loaded / e.total) * 100) : 0;\n      this.onProgress(type, progress);\n    });\n  }\n\n  async detectLanguage(text, confidenceThreshold = 0.4) {\n    if (!text) {\n      return [\"\", \"Input text cannot be empty.\"];\n    }\n\n    try {\n      const detector = await this.#getDetectorPromise();\n      const results = await detector.detect(text);\n\n      if (!results || results.length === 0) {\n        return [\"\", \"No language could be detected.\"];\n      }\n\n      const { detectedLanguage, confidence } = results[0];\n      if (confidence < confidenceThreshold) {\n        return [\n          \"\",\n          `Confidence of test results (${detectedLanguage} ${confidence.toFixed(\n            2\n          )}) below the set threshold ${confidenceThreshold}。`,\n        ];\n      }\n\n      return [detectedLanguage, \"\"];\n    } catch (error) {\n      kissLog(\"detectLanguage\", error, `(${text})`);\n      return [\"\", error.message];\n    }\n  }\n\n  async translateText(text, targetLanguage, sourceLanguage = \"auto\") {\n    if (!text || !targetLanguage || typeof text !== \"string\") {\n      return [\"\", sourceLanguage, \"Input text cannot be empty.\"];\n    }\n\n    try {\n      let finalSourceLanguage = sourceLanguage;\n      if (sourceLanguage === \"auto\") {\n        const [detectedLanguage, detectionError] =\n          await this.detectLanguage(text);\n        if (detectionError || !detectedLanguage) {\n          const reason =\n            detectionError || \"Unable to determine source language.\";\n          return [\n            \"\",\n            finalSourceLanguage,\n            `Automatic detection of source language failed: ${reason}`,\n          ];\n        }\n        finalSourceLanguage = detectedLanguage;\n      }\n\n      if (finalSourceLanguage === targetLanguage) {\n        return [\"\", finalSourceLanguage, \"Same lang\"];\n      }\n\n      const translator = await this.#createTranslator(\n        finalSourceLanguage,\n        targetLanguage\n      );\n      const translatedText = await translator.translate(text);\n\n      return [translatedText, finalSourceLanguage, \"\"];\n    } catch (error) {\n      kissLog(\"translateText\", error, `(${text})`);\n\n      if (\n        error &&\n        error.message &&\n        error.message.includes(\"Other generic failures occurred\")\n      ) {\n        logger.info(\"Generic failure detected, resetting translator cache.\");\n        this.#translatorMap.clear();\n      }\n\n      return [\"\", sourceLanguage, error.message];\n    }\n  }\n}\n\nconst chromeTranslator = new ChromeTranslator();\n\nexport const chromeDetect = (args) =>\n  chromeTranslator.detectLanguage(args.text);\nexport const chromeTranslate = (args) =>\n  chromeTranslator.translateText(args.text, args.to, args.from);\n"
  },
  {
    "path": "src/libs/cache.js",
    "content": "import {\n  CACHE_NAME,\n  DEFAULT_CACHE_TIMEOUT,\n  MSG_CLEAR_CACHES,\n  MSG_GET_HTTPCACHE,\n  MSG_PUT_HTTPCACHE,\n} from \"../config\";\nimport { kissLog } from \"./log\";\nimport { isExt } from \"./client\";\nimport { isBg } from \"./browser\";\nimport { sendBgMsg } from \"./msg\";\nimport { blobToBase64 } from \"./utils\";\n\n/**\n * 清除缓存数据\n */\nexport const tryClearCaches = async () => {\n  try {\n    if (isExt && !isBg()) {\n      await sendBgMsg(MSG_CLEAR_CACHES);\n    } else {\n      await caches.delete(CACHE_NAME);\n    }\n  } catch (err) {\n    kissLog(\"clean caches\", err);\n  }\n};\n\n/**\n * 构造缓存 request\n * @param {*} input\n * @param {*} init\n * @returns\n */\nconst newCacheReq = async (input, init) => {\n  let request = new Request(input, init);\n  if (request.method !== \"GET\") {\n    const body = await request.text();\n    const cacheUrl = new URL(request.url);\n    cacheUrl.pathname += body;\n    request = new Request(cacheUrl.toString(), { method: \"GET\" });\n  }\n\n  return request;\n};\n\n/**\n * 查询 caches\n * @param {*} input\n * @param {*} init\n * @returns\n */\nexport const getHttpCache = async ({ input, init, expect }) => {\n  try {\n    const request = await newCacheReq(input, init);\n    const cache = await caches.open(CACHE_NAME);\n    const response = await cache.match(request);\n    if (response) {\n      const res = await parseResponse(response, expect);\n      return res;\n    }\n  } catch (err) {\n    kissLog(\"get cache\", err);\n  }\n  return null;\n};\n\n/**\n * 插入 caches\n * @param {*} input\n * @param {*} init\n * @param {*} data\n */\nexport const putHttpCache = async ({\n  input,\n  init,\n  data,\n  maxAge = DEFAULT_CACHE_TIMEOUT, // todo: 从设置里面读取最大缓存时间\n}) => {\n  try {\n    const req = await newCacheReq(input, init);\n    const cache = await caches.open(CACHE_NAME);\n    const res = new Response(JSON.stringify(data), {\n      status: 200,\n      headers: {\n        \"Content-Type\": \"application/json\",\n        \"Cache-Control\": `max-age=${maxAge}`,\n      },\n    });\n    // res.headers.set(\"Cache-Control\", `max-age=${maxAge}`);\n    await cache.put(req, res);\n  } catch (err) {\n    kissLog(\"put cache\", err);\n  }\n};\n\n/**\n * 解析 response\n * @param {*} res\n * @returns\n */\nexport const parseResponse = async (res, expect = null) => {\n  if (!res) {\n    throw new Error(\"Response object does not exist\");\n  }\n\n  if (!res.ok) {\n    const msg = {\n      url: res.url,\n      status: res.status,\n      statusText: res.statusText,\n    };\n\n    try {\n      const errorText = await res.clone().text();\n      try {\n        msg.response = JSON.parse(errorText);\n      } catch {\n        msg.response = errorText;\n      }\n    } catch (e) {\n      msg.response = \"Unable to read error body\";\n    }\n\n    throw new Error(JSON.stringify(msg));\n  }\n\n  const contentType = res.headers.get(\"Content-Type\") || \"\";\n  if (expect === \"blob\") return res.blob();\n  if (expect === \"text\") return res.text();\n  if (expect === \"json\") return res.json();\n  if (\n    expect === \"audio\" ||\n    contentType.includes(\"audio\") ||\n    contentType.includes(\"image\") ||\n    contentType.includes(\"video\")\n  ) {\n    const blob = await res.blob();\n    return blobToBase64(blob);\n  }\n\n  const text = await res.text();\n  if (!text) return null;\n\n  try {\n    return JSON.parse(text);\n  } catch (err) {\n    return text;\n  }\n};\n\n/**\n * getHttpCache 兼容性封装\n * @param {*} input\n * @param {*} init\n * @returns\n */\nexport const getHttpCachePolyfill = (input, init) => {\n  // 插件\n  if (isExt && !isBg()) {\n    return sendBgMsg(MSG_GET_HTTPCACHE, { input, init });\n  }\n\n  // 油猴/网页/BackgroundPage\n  return getHttpCache({ input, init });\n};\n\n/**\n * putHttpCache 兼容性封装\n * @param {*} input\n * @param {*} init\n * @param {*} data\n * @returns\n */\nexport const putHttpCachePolyfill = (input, init, data) => {\n  // 插件\n  if (isExt && !isBg()) {\n    return sendBgMsg(MSG_PUT_HTTPCACHE, { input, init, data });\n  }\n\n  // 油猴/网页/BackgroundPage\n  return putHttpCache({ input, init, data });\n};\n"
  },
  {
    "path": "src/libs/client.js",
    "content": "import {\n  CLIENT_EXTS,\n  CLIENT_USERSCRIPT,\n  CLIENT_WEB,\n  CLIENT_FIREFOX,\n} from \"../config\";\n\nexport const client = process.env.REACT_APP_CLIENT;\nexport const isExt = CLIENT_EXTS.includes(client);\nexport const isGm = client === CLIENT_USERSCRIPT;\nexport const isWeb = client === CLIENT_WEB;\nexport const isFirefox = client === CLIENT_FIREFOX;\n"
  },
  {
    "path": "src/libs/detect.js",
    "content": "import {\n  OPT_TRANS_GOOGLE,\n  OPT_TRANS_MICROSOFT,\n  OPT_TRANS_BAIDU,\n  OPT_TRANS_TENCENT,\n  OPT_LANGS_TO_CODE,\n  OPT_LANGS_MAP,\n  OPT_TRANS_BUILTINAI,\n  OPT_LANGDETECTOR_MAP,\n} from \"../config\";\nimport { browser } from \"./browser\";\nimport {\n  apiGoogleLangdetect,\n  apiMicrosoftLangdetect,\n  apiBaiduLangdetect,\n  apiTencentLangdetect,\n  apiBuiltinAIDetect,\n} from \"../apis\";\nimport { kissLog } from \"./log\";\n\nconst langdetectFns = {\n  [OPT_TRANS_GOOGLE]: apiGoogleLangdetect,\n  [OPT_TRANS_MICROSOFT]: apiMicrosoftLangdetect,\n  [OPT_TRANS_BAIDU]: apiBaiduLangdetect,\n  [OPT_TRANS_TENCENT]: apiTencentLangdetect,\n  [OPT_TRANS_BUILTINAI]: apiBuiltinAIDetect,\n};\n\n/**\n * 语言识别\n * @param {*} text\n * @returns\n */\nexport const tryDetectLang = async (text, langDetector = \"-\") => {\n  let deLang = \"\";\n\n  // 内置AI/远程识别\n  if (OPT_LANGDETECTOR_MAP.has(langDetector)) {\n    try {\n      const lang = await langdetectFns[langDetector](text);\n      if (lang) {\n        deLang = OPT_LANGS_TO_CODE[langDetector].get(lang) || \"\";\n      }\n    } catch (err) {\n      kissLog(\"detect lang remote\", err);\n    }\n  }\n\n  // 本地识别\n  if (!deLang) {\n    try {\n      const res = await browser?.i18n?.detectLanguage(text);\n      const lang = res?.languages?.[0]?.language;\n      if (res.isReliable && lang && OPT_LANGS_MAP.has(lang)) {\n        deLang = lang;\n      } else if (lang?.startsWith(\"zh\")) {\n        deLang = \"zh-CN\";\n      }\n    } catch (err) {\n      kissLog(\"detect lang local\", err);\n    }\n  }\n\n  return deLang;\n};\n"
  },
  {
    "path": "src/libs/docInfo.js",
    "content": "import { truncateWords } from \"./utils\";\n\n// 清洗文本，移除换行符\nconst cleanText = (text) => {\n  if (!text) return \"\";\n  return text.trim().replace(/\\s+/g, \" \");\n};\n\nconst getTitle = () => {\n  try {\n    return truncateWords(cleanText(document.title));\n  } catch (err) {\n    return \"\";\n  }\n};\n\nconst getDescription = () => {\n  try {\n    const meta = document.querySelector('meta[name=\"description\"]');\n    const description = meta?.getAttribute(\"content\") || \"\";\n    return truncateWords(cleanText(description));\n  } catch (err) {\n    return \"\";\n  }\n};\n\nconst getSummary = () => {\n  // todo: 利用AI总结\n  let summary = \"\";\n\n  try {\n    const href = document?.location?.href || \"\";\n    const youtubeUrl = \"https://www.youtube.com\";\n    if (href.startsWith(youtubeUrl)) {\n      // YouTube specific logic\n      const $el =\n        document.querySelector(\"#collapsed-title\") ||\n        document.querySelector(\"#description-inline-expander\"); // 尝试更多可能的选择器\n      if ($el) {\n        summary = $el.textContent;\n      }\n    }\n\n    // 尝试获取通用 Meta 信息作为兜底\n    if (!summary) {\n      summary =\n        document\n          .querySelector('meta[property=\"og:description\"]')\n          ?.getAttribute(\"content\") || \"\";\n    }\n    if (!summary) {\n      summary =\n        document\n          .querySelector('meta[name=\"keywords\"]')\n          ?.getAttribute(\"content\") || \"\";\n    }\n  } catch (err) {\n    // ignore\n  }\n\n  return truncateWords(cleanText(summary));\n};\n\nexport const getDocInfo = () => {\n  const title = getTitle();\n  const description = getDescription();\n  const summary = getSummary();\n\n  const info = { title, description, summary };\n\n  return info;\n};\n"
  },
  {
    "path": "src/libs/domManager.js",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom/client\";\nimport { CacheProvider } from \"@emotion/react\";\nimport createCache from \"@emotion/cache\";\nimport { logger } from \"./log\";\n\n/**\n * 普通 DOM 管理器，用于管理 React 组件的挂载、更新和销毁\n * 与 ShadowDomManager 不同，此管理器直接将组件挂载到普通 DOM 节点，不使用 Shadow DOM\n * 这样可以让组件直接访问外部数据，无需通过事件通信\n */\nexport default class DomManager {\n  #hostElement = null;\n  #reactRoot = null;\n  #isVisible = false;\n  #isProcessing = false;\n\n  _id;\n  _className;\n  _ReactComponent;\n  _props;\n  _rootElement;\n\n  constructor({\n    id,\n    className = \"\",\n    reactComponent,\n    props = {},\n    rootElement = document.body,\n  }) {\n    if (!id || !reactComponent) {\n      throw new Error(\"ID and a React Component must be provided.\");\n    }\n    this._id = id;\n    this._className = className;\n    this._ReactComponent = reactComponent;\n    this._props = props;\n    this._rootElement = rootElement;\n  }\n\n  get isVisible() {\n    return this.#isVisible;\n  }\n\n  /**\n   * 显示组件\n   * @param {Object} props - 可选的新 props，如果不提供则使用构造函数中的 props\n   */\n  show(props) {\n    if (this.#isVisible || this.#isProcessing) {\n      return;\n    }\n\n    if (!this.#hostElement) {\n      this.#isProcessing = true;\n      try {\n        this.#mount(props || this._props);\n      } catch (error) {\n        logger.warn(`Failed to mount component with id \"${this._id}\":`, error);\n        this.#isProcessing = false;\n        return;\n      } finally {\n        this.#isProcessing = false;\n      }\n    }\n\n    this.#hostElement.style.display = \"\";\n    this.#isVisible = true;\n  }\n\n  /**\n   * 隐藏组件（不销毁）\n   */\n  hide() {\n    if (!this.#isVisible || !this.#hostElement) {\n      return;\n    }\n    this.#hostElement.style.display = \"none\";\n    this.#isVisible = false;\n  }\n\n  /**\n   * 销毁组件并移除 DOM 节点\n   */\n  destroy() {\n    if (!this.#hostElement) {\n      return;\n    }\n    this.#isProcessing = true;\n\n    if (this.#reactRoot) {\n      this.#reactRoot.unmount();\n    }\n\n    this.#hostElement.remove();\n\n    this.#hostElement = null;\n    this.#reactRoot = null;\n    this.#isVisible = false;\n    this.#isProcessing = false;\n    logger.info(`Component with id \"${this._id}\" has been destroyed.`);\n  }\n\n  /**\n   * 切换组件显示/隐藏状态\n   * @param {Object} props - 可选的新 props\n   */\n  toggle(props) {\n    if (this.#isVisible) {\n      this.hide();\n    } else {\n      this.show(props || this._props);\n    }\n  }\n\n  /**\n   * 更新组件 props（仅在组件已挂载时有效）\n   * @param {Object} newProps - 新的 props\n   */\n  updateProps(newProps) {\n    if (this.#reactRoot && this.#hostElement) {\n      const ComponentToRender = this._ReactComponent;\n      const cache = createCache({\n        key: this._id,\n        prepend: true,\n      });\n      this.#reactRoot.render(\n        <React.StrictMode>\n          <CacheProvider value={cache}>\n            <ComponentToRender {...newProps} />\n          </CacheProvider>\n        </React.StrictMode>\n      );\n    }\n  }\n\n  /**\n   * 挂载组件到 DOM\n   * @private\n   */\n  #mount(props) {\n    const host = document.createElement(\"div\");\n    host.id = this._id;\n    if (this._className) {\n      host.className = this._className;\n    }\n\n    this._rootElement.appendChild(host);\n    this.#hostElement = host;\n\n    const cache = createCache({\n      key: this._id,\n      prepend: true,\n    });\n\n    const enhancedProps = {\n      ...props,\n      onClose: this.hide.bind(this),\n    };\n\n    const ComponentToRender = this._ReactComponent;\n    this.#reactRoot = ReactDOM.createRoot(host);\n    this.#reactRoot.render(\n      <React.StrictMode>\n        <CacheProvider value={cache}>\n          <ComponentToRender {...enhancedProps} />\n        </CacheProvider>\n      </React.StrictMode>\n    );\n  }\n}\n"
  },
  {
    "path": "src/libs/fabManager.js",
    "content": "import ShadowDomManager from \"./shadowDomManager\";\nimport { APP_CONSTS } from \"../config\";\nimport ContentFab from \"../views/Action/ContentFab\";\n\nexport class FabManager extends ShadowDomManager {\n  constructor({ processActions, fabConfig }) {\n    super({\n      id: APP_CONSTS.fabID,\n      className: \"notranslate\",\n      reactComponent: ContentFab,\n      props: { processActions, fabConfig },\n    });\n\n    if (!fabConfig?.isHide) {\n      this.show();\n    }\n  }\n}\n"
  },
  {
    "path": "src/libs/fetch.js",
    "content": "import { isExt, isGm } from \"./client\";\nimport { sendBgMsg } from \"./msg\";\nimport { getSettingWithDefault } from \"./storage\";\nimport { MSG_FETCH, DEFAULT_HTTP_TIMEOUT, PORT_STREAM_FETCH } from \"../config\";\nimport { isBg } from \"./browser\";\nimport { kissLog } from \"./log\";\nimport { getFetchPool } from \"./pool\";\nimport { getHttpCachePolyfill, parseResponse } from \"./cache\";\nimport { createSSEParser, createAsyncQueue } from \"./stream\";\nimport browser from \"webextension-polyfill\";\n\n/**\n * 油猴脚本的请求封装\n * @param {*} input\n * @param {*} init\n * @returns\n */\nexport const fetchGM = async (\n  input,\n  { method = \"GET\", headers, body, timeout } = {}\n) =>\n  new Promise((resolve, reject) => {\n    GM.xmlHttpRequest({\n      method,\n      url: input,\n      headers,\n      data: body,\n      // withCredentials: true,\n      timeout,\n      onload: ({ response, responseHeaders, status, statusText }) => {\n        const headers = {};\n        responseHeaders.split(\"\\n\").forEach((line) => {\n          const [name, value] = line.split(\":\").map((item) => item.trim());\n          if (name && value) {\n            headers[name] = value;\n          }\n        });\n        resolve({\n          body: response,\n          headers,\n          status,\n          statusText,\n        });\n      },\n      onerror: reject,\n      onabort: () => {\n        reject(new Error(\"GM request onabort.\"));\n      },\n      ontimeout: () => {\n        reject(new Error(\"GM request timeout.\"));\n      },\n    });\n  });\n\n/**\n * 发起请求\n * @param {*} input\n * @param {*} init\n * @param {*} opts\n * @returns\n */\nexport const fetchPatcher = async (input, init = {}, opts) => {\n  let timeout = opts?.httpTimeout;\n  if (!timeout) {\n    try {\n      timeout = (await getSettingWithDefault()).httpTimeout;\n    } catch (err) {\n      kissLog(\"getSettingWithDefault\", err);\n    }\n  }\n  if (!timeout) {\n    timeout = DEFAULT_HTTP_TIMEOUT;\n  }\n\n  if (isGm) {\n    // todo: 自定义接口 init 可能包含了 signal\n    Object.assign(init, { timeout });\n\n    const { body, headers, status, statusText } = window.KISS_GM\n      ? await window.KISS_GM.fetch(input, init)\n      : await fetchGM(input, init);\n\n    return new Response(body, {\n      headers: new Headers(headers),\n      status,\n      statusText,\n    });\n  }\n\n  if (AbortSignal?.timeout && !init.signal) {\n    Object.assign(init, { signal: AbortSignal.timeout(timeout) });\n  }\n\n  return fetch(input, init);\n};\n\n/**\n * 处理请求\n * @param {*} param0\n * @returns\n */\nexport const fetchHandle = async ({ input, init, opts }) => {\n  const res = await fetchPatcher(input, init, opts);\n  return parseResponse(res, opts.expect);\n};\n\n/**\n * fetch 兼容性封装\n * @param {*} args\n * @returns\n */\nexport const fnPolyfill = ({ fn, msg = MSG_FETCH, ...args }) => {\n  // 插件\n  if (isExt && !isBg()) {\n    return sendBgMsg(msg, { ...args });\n  }\n\n  // 油猴/网页/BackgroundPage\n  return fn({ ...args });\n};\n\n/**\n * 数据请求\n * @param {*} input\n * @param {*} init\n * @param {*} param1\n * @returns\n */\nexport const fetchData = async (\n  input,\n  init,\n  { useCache, usePool, fetchInterval, fetchLimit, ...opts } = {}\n) => {\n  if (!input?.trim()) {\n    throw new Error(\"URL is empty\");\n  }\n\n  // 使用缓存数据\n  if (useCache) {\n    const resCache = await getHttpCachePolyfill(input, init);\n    if (resCache) {\n      return resCache;\n    }\n  }\n\n  // 通过任务池发送请求\n  if (usePool) {\n    const fetchPool = getFetchPool(fetchInterval, fetchLimit);\n    return fetchPool.push(fnPolyfill, { fn: fetchHandle, input, init, opts });\n  }\n\n  // 直接请求\n  return fnPolyfill({ fn: fetchHandle, input, init, opts });\n};\n\n/**\n * 油猴脚本流式请求（带 SSE 处理）\n * @param {*} input\n * @param {*} init\n * @returns {AsyncGenerator<string>}\n */\nasync function* fetchStreamGM(\n  input,\n  { method = \"GET\", headers, body, timeout } = {}\n) {\n  const asyncQueue = createAsyncQueue();\n  const parseSSE = createSSEParser();\n\n  const gmRequest = window.KISS_GM?.xmlHttpRequest || GM.xmlHttpRequest;\n  const requestHandle = gmRequest({\n    method,\n    url: input,\n    headers,\n    data: body,\n    timeout,\n    responseType: \"stream\",\n    onloadstart: async ({ response }) => {\n      try {\n        const reader = response.getReader();\n        const decoder = new TextDecoder();\n        while (true) {\n          const { done: readerDone, value } = await reader.read();\n          if (readerDone) break;\n          for (const data of parseSSE(\n            decoder.decode(value, { stream: true })\n          )) {\n            asyncQueue.push(data);\n          }\n        }\n      } catch (e) {\n        asyncQueue.error(e);\n        return;\n      }\n      asyncQueue.finish();\n    },\n    onerror: (e) => asyncQueue.error(e),\n    onabort: () => asyncQueue.error(new Error(\"GM stream request aborted\")),\n    ontimeout: () => asyncQueue.error(new Error(\"GM stream request timeout\")),\n  });\n\n  try {\n    yield* asyncQueue.iterate();\n  } finally {\n    requestHandle?.abort?.();\n  }\n}\n\n/**\n * 原生 fetch 流式请求（带 SSE 处理）\n * @param {string} input\n * @param {Object} init\n * @param {number} timeout\n * @returns {AsyncGenerator<string>}\n */\nexport async function* fetchStreamNative(input, init, timeout) {\n  const signal = AbortSignal?.timeout?.(timeout);\n  const response = await fetch(input, { ...init, signal });\n\n  if (!response.ok) {\n    throw new Error(`HTTP error! status: ${response.status}`);\n  }\n\n  const reader = response.body.getReader();\n  const decoder = new TextDecoder();\n  const parseSSE = createSSEParser();\n\n  while (true) {\n    const { done, value } = await reader.read();\n    if (done) break;\n\n    for (const data of parseSSE(decoder.decode(value, { stream: true }))) {\n      yield data;\n    }\n  }\n}\n\n/**\n * 通过端口连接 background 的流式请求\n * @param {string} input\n * @param {Object} init\n * @param {Object} opts\n * @returns {AsyncGenerator<string>}\n */\nasync function* fetchStreamViaPort(input, init, opts) {\n  const asyncQueue = createAsyncQueue();\n\n  let port;\n  try {\n    port = browser.runtime.connect({ name: PORT_STREAM_FETCH });\n  } catch (e) {\n    throw new Error(\"Failed to connect to background: \" + e.message);\n  }\n\n  port.onMessage.addListener((message) => {\n    switch (message.type) {\n      case \"delta\":\n        asyncQueue.push(message.data);\n        break;\n      case \"done\":\n        asyncQueue.finish();\n        break;\n      case \"error\":\n        asyncQueue.error(new Error(message.error));\n        break;\n      default:\n        break;\n    }\n  });\n\n  port.onDisconnect.addListener(() => {\n    const lastError = browser.runtime.lastError;\n    if (lastError) {\n      asyncQueue.error(new Error(lastError.message || \"Port disconnected\"));\n    }\n  });\n\n  port.postMessage({\n    action: \"start\",\n    args: { input, init, opts },\n  });\n\n  try {\n    yield* asyncQueue.iterate();\n  } finally {\n    port.disconnect();\n  }\n}\n\n/**\n * 流式请求处理（油猴/BackgroundPage/Web）\n * @param {string} input\n * @param {Object} init\n * @param {Object} opts\n * @returns {AsyncGenerator<string>}\n */\nasync function* fnPolyfillStream(input, init, opts) {\n  opts = {\n    ...opts,\n    httpTimeout: opts?.httpTimeout || DEFAULT_HTTP_TIMEOUT,\n  };\n\n  // 插件 content script，通过端口连接 background\n  if (isExt && !isBg()) {\n    yield* fetchStreamViaPort(input, init, opts);\n    return;\n  }\n\n  // 油猴脚本环境\n  if (isGm) {\n    yield* fetchStreamGM(input, { ...init, timeout: opts?.httpTimeout });\n    return;\n  }\n\n  // 扩展 background 或 Web 环境，使用原生 fetch\n  yield* fetchStreamNative(input, init, opts?.httpTimeout);\n}\n\n/**\n * 流式请求统一封装\n * @param {string} input 请求 URL\n * @param {Object} init 请求配置\n * @param {Object} options 选项（与 fetchData 一致）\n * @yields {string} SSE 数据片段\n */\nexport async function* fetchStream(\n  input,\n  init,\n  { useCache, usePool, fetchInterval, fetchLimit, ...opts } = {}\n) {\n  if (!input?.trim()) {\n    throw new Error(\"URL is empty\");\n  }\n\n  // 使用缓存数据\n  if (useCache) {\n    const resCache = await getHttpCachePolyfill(input, init);\n    if (resCache) {\n      yield resCache;\n      return;\n    }\n  }\n\n  // 通过任务池发送请求\n  if (usePool) {\n    const fetchPool = getFetchPool(fetchInterval, fetchLimit);\n    const asyncQueue = createAsyncQueue();\n\n    const streamPromise = fetchPool.push(async () => {\n      try {\n        for await (const chunk of fnPolyfillStream(input, init, opts)) {\n          asyncQueue.push(chunk);\n        }\n        asyncQueue.finish();\n      } catch (e) {\n        asyncQueue.error(e);\n      }\n      return null;\n    });\n\n    yield* asyncQueue.iterate();\n    await streamPromise;\n    return;\n  }\n\n  // 直接请求\n  yield* fnPolyfillStream(input, init, opts);\n}\n"
  },
  {
    "path": "src/libs/gm.js",
    "content": "import { fetchGM } from \"./fetch\";\nimport { genEventName } from \"./utils\";\n\nconst MSG_GM_xmlHttpRequest = \"xmlHttpRequest\";\nconst MSG_GM_setValue = \"setValue\";\nconst MSG_GM_getValue = \"getValue\";\nconst MSG_GM_deleteValue = \"deleteValue\";\nconst MSG_GM_info = \"info\";\n\n/**\n * 注入页面的脚本，请求并接受GM接口信息\n * @param {*} param0\n */\nexport const injectScript = (ping) => {\n  window.APP_INFO = {\n    name: process.env.REACT_APP_NAME,\n    version: process.env.REACT_APP_VERSION,\n    eventName: ping,\n  };\n};\n\n/**\n * 适配GM脚本\n */\nexport const adaptScript = (ping) => {\n  const promiseGM = (action, args, timeout = 5000) =>\n    new Promise((resolve, reject) => {\n      const pong = genEventName();\n      const handleEvent = (e) => {\n        window.removeEventListener(pong, handleEvent);\n        const { data, error } = e.detail;\n        if (error) {\n          reject(new Error(error));\n        } else {\n          resolve(data);\n        }\n      };\n\n      window.addEventListener(pong, handleEvent);\n      window.dispatchEvent(\n        new CustomEvent(ping, { detail: { action, args, pong } })\n      );\n\n      setTimeout(() => {\n        window.removeEventListener(pong, handleEvent);\n        reject(new Error(\"timeout\"));\n      }, timeout);\n    });\n\n  window.KISS_GM = {\n    fetch: (input, init) => promiseGM(MSG_GM_xmlHttpRequest, { input, init }),\n    setValue: (key, val) => promiseGM(MSG_GM_setValue, { key, val }),\n    getValue: (key) => promiseGM(MSG_GM_getValue, { key }),\n    deleteValue: (key) => promiseGM(MSG_GM_deleteValue, { key }),\n    getInfo: async () => {\n      if (!window.GM_info) {\n        window.GM_info = await promiseGM(MSG_GM_info);\n      }\n      return window.GM_info;\n    },\n  };\n};\n\n/**\n * 监听并回应页面对GM接口的请求\n * @param {*} param0\n */\nexport const handlePing = async (e) => {\n  const { action, args, pong } = e.detail;\n  let res;\n  try {\n    switch (action) {\n      case MSG_GM_xmlHttpRequest:\n        const { input, init } = args;\n        res = await fetchGM(input, init);\n        break;\n      case MSG_GM_setValue:\n        const { key, val } = args;\n        await GM.setValue(key, val);\n        res = val;\n        break;\n      case MSG_GM_getValue:\n        res = await GM.getValue(args.key);\n        break;\n      case MSG_GM_deleteValue:\n        await GM.deleteValue(args.key);\n        res = \"ok\";\n        break;\n      case MSG_GM_info:\n        res = GM.info;\n        break;\n      default:\n        throw new Error(`message action is unavailable: ${action}`);\n    }\n\n    window.dispatchEvent(new CustomEvent(pong, { detail: { data: res } }));\n  } catch (err) {\n    window.dispatchEvent(\n      new CustomEvent(pong, { detail: { error: err.message } })\n    );\n  }\n};\n"
  },
  {
    "path": "src/libs/iframe.js",
    "content": "export const isIframe = window.self !== window.top;\n\nexport const sendIframeMsg = (action, args) => {\n  document.querySelectorAll(\"iframe\").forEach((iframe) => {\n    iframe.contentWindow.postMessage({ action, args }, \"*\");\n  });\n};\n\nexport const sendParentMsg = (action, args) => {\n  window.parent.postMessage({ action, args }, \"*\");\n};\n"
  },
  {
    "path": "src/libs/injector.js",
    "content": "import { trustedTypesHelper } from \"./trustedTypes\";\n\n// Function to inject inline JavaScript code\nexport const injectInlineJs = (code, id = \"kiss-translator-inline-js\") => {\n  if (document.getElementById(id)) {\n    return;\n  }\n\n  const el = document.createElement(\"script\");\n  el.setAttribute(\"data-source\", \"kiss-inject injectInlineJs\");\n  el.type = \"text/javascript\";\n  el.id = id;\n  el.textContent = trustedTypesHelper.createScript(code);\n  (document.head || document.documentElement).appendChild(el);\n};\n\nexport const injectInlineJsBg = (code, id = \"kiss-translator-inline-js\") => {\n  if (document.getElementById(id)) {\n    return;\n  }\n\n  const el = document.createElement(\"script\");\n  el.setAttribute(\"data-source\", \"kiss-inject injectInlineJsBg\");\n  el.type = \"text/javascript\";\n  el.id = id;\n  el.textContent = code;\n  (document.head || document.documentElement).appendChild(el);\n};\n\n// Function to inject external JavaScript file\nexport const injectExternalJs = (src, id = \"kiss-translator-external-js\") => {\n  if (document.getElementById(id)) {\n    return;\n  }\n\n  const el = document.createElement(\"script\");\n  el.setAttribute(\"data-source\", \"kiss-inject injectExternalJs\");\n  el.type = \"text/javascript\";\n  el.id = id;\n  el.src = trustedTypesHelper.createScriptURL(src);\n  (document.head || document.documentElement).appendChild(el);\n};\n\n// Function to inject internal CSS code\nexport const injectInternalCss = (styles) => {\n  const el = document.createElement(\"style\");\n  el.setAttribute(\"data-source\", \"kiss-inject injectInternalCss\");\n  el.textContent = styles;\n  document.head?.appendChild(el);\n};\n\n// Function to inject external CSS file\nexport const injectExternalCss = (href) => {\n  const el = document.createElement(\"link\");\n  el.setAttribute(\"data-source\", \"kiss-inject injectExternalCss\");\n  el.setAttribute(\"rel\", \"stylesheet\");\n  el.setAttribute(\"type\", \"text/css\");\n  el.setAttribute(\"href\", href);\n  document.head?.appendChild(el);\n};\n"
  },
  {
    "path": "src/libs/inputTranslate.js",
    "content": "import {\n  DEFAULT_INPUT_RULE,\n  DEFAULT_INPUT_SHORTCUT,\n  OPT_LANGS_LIST,\n  DEFAULT_API_SETTING,\n  OPT_INPUT_DOT_DISABLE,\n  OPT_INPUT_DOT_MOBILE,\n} from \"../config\";\nimport { isMobile } from \"./mobile\";\nimport { genEventName, removeEndchar, matchInputStr, sleep } from \"./utils\";\nimport { stepShortcutRegister } from \"./shortcut\";\nimport { apiTranslate } from \"../apis\";\nimport { createLoadingSVG } from \"./svg\";\nimport { logger } from \"./log\";\n\n// ==========================================\n// 核心工具函数：DOM 查找与状态判断\n// ==========================================\n\n/**\n * 递归查找 Shadow DOM 深处的当前焦点元素\n * 解决 Gemini、Discord 等使用 Custom Elements 的网站无法识别焦点的问题\n */\nfunction getDeepActiveElement() {\n  let element = document.activeElement;\n  while (element && element.shadowRoot && element.shadowRoot.activeElement) {\n    element = element.shadowRoot.activeElement;\n  }\n  return element;\n}\n\n/**\n * 判断是否为可编辑区域\n * 兼容 input, textarea, 以及 contenteditable=\"true\" 的 div/span\n */\nfunction isEditableTarget(node) {\n  if (!node) return false;\n\n  // 1. 标准输入框\n  const nodeName = node.nodeName?.toUpperCase();\n  if (nodeName === \"INPUT\" || nodeName === \"TEXTAREA\") {\n    return true;\n  }\n\n  // 2. 检查 contenteditable 属性 (HTML 属性或 DOM 属性)\n  if (\n    node.isContentEditable ||\n    node.getAttribute(\"contenteditable\") === \"true\"\n  ) {\n    return true;\n  }\n  return false;\n}\n\n/**\n * 获取节点文本\n */\nfunction getNodeText(node) {\n  const nodeName = node.nodeName?.toUpperCase();\n  if (nodeName === \"INPUT\" || nodeName === \"TEXTAREA\") {\n    return node.value || \"\";\n  }\n  // 对于 contenteditable，优先取 innerText (也就是视觉可见的文本)\n  return node.innerText || node.textContent || \"\";\n}\n\n/**\n * 针对 React 等框架的特殊赋值\n * React 重写了 input 的 value setter，直接 .value = xx 经常无效\n * 需要调用原生原型链上的 setter\n */\nfunction setNativeValue(element, value) {\n  const valueSetter = Object.getOwnPropertyDescriptor(element, \"value\")?.set;\n  const prototype = Object.getPrototypeOf(element);\n  const prototypeValueSetter = Object.getOwnPropertyDescriptor(\n    prototype,\n    \"value\"\n  )?.set;\n\n  if (prototypeValueSetter && valueSetter !== prototypeValueSetter) {\n    prototypeValueSetter.call(element, value);\n  } else {\n    element.value = value;\n  }\n\n  element.dispatchEvent(new Event(\"input\", { bubbles: true }));\n}\n\n// ==========================================\n// 核心逻辑：智能替换文本\n// ==========================================\nasync function smartReplaceText(node, newText) {\n  node.focus();\n  await sleep(10);\n\n  // 判断是否为富文本编辑器 (X.com, Discord, Slack 等通常是 contenteditable 的 div/span)\n  const isRichEditor =\n    node.isContentEditable || node.getAttribute(\"contenteditable\") === \"true\";\n\n  // ------------------------------------------------\n  // 步骤 1: 全选内容\n  // ------------------------------------------------\n  const performSelectAll = () => {\n    if (typeof node.select === \"function\") {\n      node.select();\n      return;\n    }\n    try {\n      document.execCommand(\"selectAll\", false, null);\n    } catch (e) {\n      const selection = window.getSelection();\n      selection.removeAllRanges();\n      const range = document.createRange();\n      range.selectNodeContents(node);\n      selection.addRange(range);\n    }\n  };\n  performSelectAll();\n  await sleep(50);\n\n  // ------------------------------------------------\n  // 步骤 2: 针对富文本编辑器的优先策略 (Clipboard Paste)\n  // ------------------------------------------------\n  if (isRichEditor) {\n    try {\n      logger.debug(\"Rich Editor detected: Priority Strategy (Clipboard Paste)\");\n      const dt = new DataTransfer();\n      dt.setData(\"text/plain\", newText);\n      const pasteEvt = new ClipboardEvent(\"paste\", {\n        clipboardData: dt,\n        bubbles: true,\n        cancelable: true,\n        composed: true,\n        view: window,\n      });\n      node.dispatchEvent(pasteEvt);\n\n      // 给 React 一点时间去处理 Paste 事件\n      await sleep(100);\n\n      if (checkSuccess(node, newText)) return true;\n    } catch (e) {\n      logger.debug(\"Strategy Paste failed\", e);\n    }\n  }\n\n  // ------------------------------------------------\n  // 步骤 3: 原有的 execCommand 策略 (降级 / 普通输入框)\n  // ------------------------------------------------\n  try {\n    const success = document.execCommand(\"insertText\", false, newText);\n    if (success) {\n      await sleep(20);\n      if (checkSuccess(node, newText)) return true;\n    }\n  } catch (e) {\n    logger.debug(\"Strategy 1 (insertText) failed\", e);\n  }\n\n  // === 策略 2: 标准 Input 处理 (React/Vue 兼容) ===\n  if (node.nodeName === \"INPUT\" || node.nodeName === \"TEXTAREA\") {\n    try {\n      setNativeValue(node, newText);\n      return true;\n    } catch (e) {\n      logger.debug(\"Strategy 2 (Input Value) failed\", e);\n    }\n  }\n\n  return false;\n}\n\n// 辅助验证函数\nfunction checkSuccess(node, targetText) {\n  const currentText = getNodeText(node);\n  return currentText.includes(targetText.trim());\n}\n\n// ==========================================\n// UI 辅助函数\n// ==========================================\n\nfunction addLoading(node, loadingId) {\n  const rect = node.getBoundingClientRect();\n  // 如果元素不可见或太小，简单容错\n  if (rect.width === 0 || rect.height === 0) {\n    // pass\n  }\n\n  const div = document.createElement(\"div\");\n  div.id = loadingId;\n  div.appendChild(createLoadingSVG());\n\n  div.style.cssText = `\n        position: fixed;\n        left: ${rect.left}px;\n        top: ${rect.top}px;\n        min-width: 20px;\n        width: ${rect.width || 100}px;\n        height: ${rect.height || 30}px;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        z-index: 2147483647;\n        pointer-events: none;\n        background: transparent;\n    `;\n  document.body.appendChild(div);\n}\n\nfunction removeLoading(loadingId) {\n  const div = document.getElementById(loadingId);\n  if (div) div.remove();\n}\n\n// ==========================================\n// 主类：InputTranslator\n// ==========================================\n\nexport class InputTranslator {\n  #config;\n  #unregisterShortcut = null;\n  #isEnabled = false;\n  #triggerShortcut;\n\n  // 状态管理\n  #activeInput = null; // 当前获得焦点的输入框\n  #floatBtn = null; // 悬浮按钮 DOM\n  #resizeObserver = null; // 监听输入框尺寸变化\n  #blurTimer = null; // 存储失焦隐藏的定时器 ID\n\n  // 绑定的事件处理函数\n  #boundFocusIn;\n  #boundFocusOut;\n  #boundUpdatePos;\n\n  constructor({ inputRule = DEFAULT_INPUT_RULE, transApis = [] } = {}) {\n    this.#config = { inputRule, transApis };\n\n    const { triggerShortcut: initialTriggerShortcut } = this.#config.inputRule;\n    this.#triggerShortcut =\n      initialTriggerShortcut && initialTriggerShortcut.length > 0\n        ? initialTriggerShortcut\n        : DEFAULT_INPUT_SHORTCUT;\n\n    this.#boundFocusIn = this.handleFocusIn.bind(this);\n    this.#boundFocusOut = this.handleFocusOut.bind(this);\n    this.#boundUpdatePos = this.updateBtnPosition.bind(this);\n\n    if (this.#config.inputRule.transOpen) {\n      this.enable();\n    }\n  }\n\n  enable() {\n    if (this.#isEnabled) return; // 避免重复开启\n\n    // 1. 注册快捷键\n    const { triggerCount, triggerTime } = this.#config.inputRule;\n    this.#unregisterShortcut = stepShortcutRegister(\n      this.#triggerShortcut,\n      this.handleTranslate.bind(this),\n      triggerCount,\n      triggerTime\n    );\n\n    // 2. 注册 DOM 监听\n    document.addEventListener(\"focusin\", this.#boundFocusIn);\n    document.addEventListener(\"focusout\", this.#boundFocusOut);\n    window.addEventListener(\"scroll\", this.#boundUpdatePos, true);\n    window.addEventListener(\"resize\", this.#boundUpdatePos);\n\n    if (window.visualViewport) {\n      window.visualViewport.addEventListener(\"resize\", this.#boundUpdatePos);\n      window.visualViewport.addEventListener(\"scroll\", this.#boundUpdatePos);\n    }\n\n    this.#isEnabled = true;\n\n    // [修复问题2-B]：开启时，如果当前焦点已经在输入框内，立即触发逻辑\n    const currentFocus = getDeepActiveElement();\n    if (isEditableTarget(currentFocus)) {\n      this.handleFocusIn();\n    }\n\n    logger.info(\"Input Translator enabled.\");\n  }\n\n  disable() {\n    if (!this.#isEnabled) return;\n\n    // 1. 移除快捷键\n    if (this.#unregisterShortcut) {\n      this.#unregisterShortcut();\n      this.#unregisterShortcut = null;\n    }\n\n    // 2. 移除 DOM 监听\n    document.removeEventListener(\"focusin\", this.#boundFocusIn);\n    document.removeEventListener(\"focusout\", this.#boundFocusOut);\n    window.removeEventListener(\"scroll\", this.#boundUpdatePos, true);\n    window.removeEventListener(\"resize\", this.#boundUpdatePos);\n\n    if (window.visualViewport) {\n      window.visualViewport.removeEventListener(\"resize\", this.#boundUpdatePos);\n      window.visualViewport.removeEventListener(\"scroll\", this.#boundUpdatePos);\n    }\n\n    // 3. 清理 UI 和 观察器\n    // [修复问题2-A]：彻底销毁 DOM，防止僵尸状态\n    this.removeFloatButton();\n\n    if (this.#resizeObserver) {\n      this.#resizeObserver.disconnect();\n      this.#resizeObserver = null;\n    }\n    this.#activeInput = null;\n\n    this.#isEnabled = false;\n    logger.info(\"Input Translator disabled.\");\n  }\n\n  toggle() {\n    this.#isEnabled ? this.disable() : this.enable();\n  }\n\n  // ============================\n  // UI 交互事件处理\n  // ============================\n\n  handleFocusIn() {\n    // [修复问题2-C]：如果刚刚触发了 blur 延时还没执行，立刻清除它，防止按钮闪现后消失\n    if (this.#blurTimer) {\n      clearTimeout(this.#blurTimer);\n      this.#blurTimer = null;\n    }\n\n    const target = getDeepActiveElement();\n    if (isEditableTarget(target)) {\n      this.#activeInput = target;\n\n      if (this.#resizeObserver) this.#resizeObserver.disconnect();\n      this.#resizeObserver = new ResizeObserver(() => this.updateBtnPosition());\n      this.#resizeObserver.observe(target);\n\n      this.showFloatButton(target);\n    }\n  }\n\n  handleFocusOut() {\n    // 延时处理，因为点击按钮时会短暂触发 blur\n    this.#blurTimer = setTimeout(() => {\n      const newFocus = getDeepActiveElement();\n      // 如果焦点转移到了我们的按钮上，或者还在原输入框（某些特殊情况），则不隐藏\n      if (\n        newFocus !== this.#activeInput &&\n        !this.#floatBtn?.contains(newFocus)\n      ) {\n        this.hideFloatButton();\n        this.#activeInput = null;\n        if (this.#resizeObserver) {\n          this.#resizeObserver.disconnect();\n          this.#resizeObserver = null;\n        }\n      }\n    }, 150);\n  }\n\n  // [修复问题1]：使用参数 inputNode 确保逻辑闭环\n  showFloatButton(inputNode) {\n    if (!this.#isEnabled) return;\n\n    const showDot = this.#config.inputRule.showDot || OPT_INPUT_DOT_MOBILE;\n    if (showDot === OPT_INPUT_DOT_DISABLE) return;\n    if (showDot === OPT_INPUT_DOT_MOBILE) {\n      const isTouch = isMobile || navigator.maxTouchPoints > 0;\n      if (!isTouch) return;\n    }\n\n    // 确保 activeInput 与传入的节点一致\n    this.#activeInput = inputNode;\n\n    // 创建按钮 DOM (如果不存在)\n    if (!this.#floatBtn) {\n      this.createFloatButtonDOM();\n    }\n\n    this.#floatBtn.style.display = \"flex\";\n    this.updateBtnPosition();\n  }\n\n  // 将创建逻辑抽离，保持代码整洁\n  createFloatButtonDOM() {\n    this.#floatBtn = document.createElement(\"div\");\n    // ... 样式代码保持不变 ...\n    const isTouch = isMobile || navigator.maxTouchPoints > 0;\n    const size = isTouch ? \"36px\" : \"30px\";\n\n    this.#floatBtn.style.cssText = `\n        position: fixed;\n        width: ${size}; height: ${size};\n        background: #209CEE;\n        border-radius: 50%;\n        z-index: 2147483647;\n        cursor: pointer;\n        display: flex; align-items: center; justify-content: center;\n        box-shadow: 0 2px 5px rgba(0,0,0,0.2);\n        transition: opacity 0.2s;\n        font-size: 13px; color: white;\n        user-select: none; -webkit-user-select: none;\n      `;\n    this.#floatBtn.innerText = \"译\";\n\n    const preventFocusLoss = (e) => {\n      e.preventDefault();\n      e.stopPropagation();\n    };\n    // 这里的监听器随 DOM 销毁而销毁，无需手动 removeEventListener\n    this.#floatBtn.addEventListener(\"mousedown\", preventFocusLoss);\n    this.#floatBtn.addEventListener(\"touchstart\", preventFocusLoss, {\n      passive: false,\n    });\n\n    const handleTrigger = (e) => {\n      e.preventDefault();\n      e.stopPropagation();\n      if (this.#activeInput) this.#activeInput.focus();\n      this.handleTranslate({ isBtnTrigger: true });\n    };\n\n    this.#floatBtn.addEventListener(\"click\", handleTrigger);\n    this.#floatBtn.addEventListener(\"touchend\", handleTrigger);\n\n    document.body.appendChild(this.#floatBtn);\n  }\n\n  // 仅仅隐藏（失焦时用）\n  hideFloatButton() {\n    if (this.#floatBtn) {\n      this.#floatBtn.style.display = \"none\";\n    }\n  }\n\n  // 彻底移除（禁用时用）\n  removeFloatButton() {\n    if (this.#floatBtn) {\n      this.#floatBtn.remove(); // 从 DOM 删除\n      this.#floatBtn = null; // 清空引用\n    }\n  }\n\n  updateBtnPosition() {\n    // 增加对 activeInput 是否还在文档中的检查\n    if (\n      !this.#activeInput ||\n      !this.#activeInput.isConnected || // 检查元素是否已被移除\n      !this.#floatBtn ||\n      this.#floatBtn.style.display === \"none\"\n    ) {\n      // 如果输入框都不在了，直接隐藏按钮\n      if (this.#floatBtn) this.hideFloatButton();\n      return;\n    }\n\n    const rect = this.#activeInput.getBoundingClientRect();\n    // ... 位置计算逻辑保持不变 ...\n    const isTouch = isMobile || navigator.maxTouchPoints > 0;\n    const btnSize = isTouch ? 36 : 30;\n    const padding = 5;\n    let top = rect.bottom - btnSize - padding;\n    let left = rect.right - btnSize - padding;\n\n    if (rect.height < 60) top = rect.top - btnSize - 2;\n    // 确保按钮不超出屏幕范围\n    left = Math.max(0, Math.min(left, window.innerWidth - btnSize - 2));\n    top = Math.max(0, Math.min(top, window.innerHeight - btnSize - 2));\n\n    this.#floatBtn.style.top = `${top}px`;\n    this.#floatBtn.style.left = `${left}px`;\n  }\n\n  // ============================\n  // 核心业务：翻译处理\n  // ============================\n\n  /**\n   * 执行翻译逻辑\n   * @param {Object} options\n   * @param {boolean} options.isBtnTrigger 是否由悬浮按钮触发\n   */\n  async handleTranslate({ isBtnTrigger = false } = {}) {\n    logger.debug(\"handle input translate\");\n\n    // 1. 获取真正的焦点元素\n    const node = getDeepActiveElement();\n\n    // 2. 检查节点是否支持\n    if (!node || !isEditableTarget(node)) {\n      logger.debug(\"Active node is not editable\");\n      return;\n    }\n\n    const { apiSlug, transSign, triggerCount } = this.#config.inputRule;\n    let { fromLang, toLang } = this.#config.inputRule;\n\n    // 3. 获取文本\n    let initText = getNodeText(node);\n\n    // 4. 处理触发字符逻辑\n    // 修改：仅当非按钮触发（即键盘快捷键触发）时，才移除末尾的触发符\n    if (\n      !isBtnTrigger &&\n      this.#triggerShortcut.length === 1 &&\n      this.#triggerShortcut[0].length === 1\n    ) {\n      initText = removeEndchar(\n        initText,\n        this.#triggerShortcut[0],\n        triggerCount\n      );\n    }\n\n    if (!initText.trim()) return;\n\n    // 5. 解析语言指令 (例如 \"en:你好\")\n    let text = initText;\n    if (transSign) {\n      const res = matchInputStr(text, transSign);\n      if (res) {\n        let lang = res[1];\n        // 简写映射\n        const langMap = {\n          zh: \"zh-CN\",\n          cn: \"zh-CN\",\n          tw: \"zh-TW\",\n          hk: \"zh-TW\",\n          jp: \"ja\",\n          kr: \"ko\",\n        };\n        if (langMap[lang.toLowerCase()]) lang = langMap[lang.toLowerCase()];\n\n        if (lang && OPT_LANGS_LIST.includes(lang)) {\n          toLang = lang;\n        }\n        text = res[2];\n      }\n    }\n\n    const apiSetting =\n      this.#config.transApis.find((api) => api.apiSlug === apiSlug) ||\n      DEFAULT_API_SETTING;\n\n    const loadingId = \"kiss-loading-\" + genEventName();\n\n    try {\n      addLoading(node, loadingId);\n      this.hideFloatButton(); // 翻译期间隐藏按钮\n\n      // 调用翻译 API\n      const { trText, isSame } = await apiTranslate({\n        text,\n        fromLang,\n        toLang,\n        apiSetting,\n      });\n\n      const newText = trText?.trim() || \"\";\n      if (!newText || isSame) return;\n\n      // 6. 执行替换 (使用新的智能替换函数)\n      const success = await smartReplaceText(node, newText);\n      if (!success) {\n        logger.warn(\"Text replacement failed after all strategies.\");\n      }\n    } catch (err) {\n      logger.error(\"Translate input error:\", err);\n    } finally {\n      removeLoading(loadingId);\n      // 恢复显示按钮\n      if (this.#activeInput === node) {\n        this.showFloatButton(node);\n      }\n    }\n  }\n\n  updateConfig({ inputRule, transApis }) {\n    const wasEnabled = this.#isEnabled;\n    if (wasEnabled) this.disable();\n\n    if (inputRule) this.#config.inputRule = inputRule;\n    if (transApis) this.#config.transApis = transApis;\n\n    const { triggerShortcut } = this.#config.inputRule;\n    this.#triggerShortcut =\n      triggerShortcut && triggerShortcut.length > 0\n        ? triggerShortcut\n        : DEFAULT_INPUT_SHORTCUT;\n\n    if (wasEnabled) this.enable();\n  }\n}\n"
  },
  {
    "path": "src/libs/interpreter.js",
    "content": "import Sval from \"sval\";\n\nexport const interpreter = new Sval({\n  // ECMA Version of the code\n  // 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15\n  // or 2015 | 2016 | 2017 | 2018 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024\n  // or \"latest\"\n  ecmaVer: \"latest\",\n  // Code source type\n  // \"script\" or \"module\"\n  sourceType: \"script\",\n  // Whether the code runs in a sandbox\n  sandBox: true,\n});\n"
  },
  {
    "path": "src/libs/log.js",
    "content": "// 定义日志级别\nexport const LogLevel = {\n  DEBUG: { value: 0, name: \"DEBUG\", color: \"#6495ED\" }, // 宝蓝色\n  INFO: { value: 1, name: \"INFO\", color: \"#4CAF50\" }, // 绿色\n  WARN: { value: 2, name: \"WARN\", color: \"#FFC107\" }, // 琥珀色\n  ERROR: { value: 3, name: \"ERROR\", color: \"#F44336\" }, // 红色\n  SILENT: { value: 4, name: \"SILENT\" }, // 特殊级别，用于关闭所有日志\n};\n\nfunction findLogLevelByValue(value) {\n  return Object.values(LogLevel).find((level) => level.value === value);\n}\n\nfunction findLogLevelByName(name) {\n  if (typeof name !== \"string\" || name.length === 0) return undefined;\n  const upperCaseName = name.toUpperCase();\n  return Object.values(LogLevel).find((level) => level.name === upperCaseName);\n}\n\nclass Logger {\n  /**\n   * @param {object} [options={}] 配置选项\n   * @param {LogLevel} [options.level=LogLevel.INFO]  要显示的最低日志级别\n   * @param {string}   [options.prefix='App']         日志前缀，用于区分模块\n   */\n  constructor(options = {}) {\n    this.config = {\n      level: options.level || LogLevel.INFO,\n      prefix: options.prefix || \"KISS-Translator\",\n    };\n  }\n\n  /**\n   * 动态设置日志级别\n   * @param {LogLevel} level - 新的日志级别\n   */\n  setLevel(level) {\n    let newLevelObject;\n\n    if (typeof level === \"string\") {\n      newLevelObject = findLogLevelByName(level);\n      if (!newLevelObject) {\n        this.warn(\n          `Invalid log level name provided: \"${level}\". Keeping current level.`\n        );\n        return;\n      }\n    } else if (typeof level === \"number\") {\n      newLevelObject = findLogLevelByValue(level);\n      if (!newLevelObject) {\n        this.warn(\n          `Invalid log level value provided: ${level}. Keeping current level.`\n        );\n        return;\n      }\n    } else if (level && typeof level.value === \"number\") {\n      newLevelObject = level;\n    } else {\n      this.warn(\n        \"Invalid argument passed to setLevel. Must be a LogLevel object, number, or string.\"\n      );\n      return;\n    }\n\n    if (this.config.level.value !== newLevelObject.value) {\n      this.config.level = newLevelObject;\n      console.log(\n        `[${this.config.prefix}] Log level dynamically set to ${this.config.level.name}`\n      );\n    }\n  }\n\n  /**\n   * 核心日志记录方法\n   * @private\n   * @param {LogLevel} level - 当前消息的日志级别\n   * @param {...any} args - 要记录的多个参数，可以是任何类型\n   */\n  _log(level, ...args) {\n    // 如果当前级别低于配置的最低级别，则不打印\n    if (level.value < this.config.level.value) {\n      return;\n    }\n\n    const timestamp = new Date().toISOString();\n    const prefixStr = `[${this.config.prefix}]`;\n    const levelStr = `[${level.name}]`;\n\n    // 判断是否在浏览器环境并且浏览器支持 console 样式\n    const isBrowser =\n      typeof window !== \"undefined\" && typeof window.document !== \"undefined\";\n\n    if (isBrowser) {\n      // 在浏览器中使用颜色高亮\n      const consoleMethod = this._getConsoleMethod(level);\n      consoleMethod(\n        `%c${timestamp} %c${prefixStr} %c${levelStr}`,\n        \"color: gray; font-weight: lighter;\", // 时间戳样式\n        \"color: #7c57e0; font-weight: bold;\", // 前缀样式 (紫色)\n        `color: ${level.color}; font-weight: bold;`, // 日志级别样式\n        ...args\n      );\n    } else {\n      // 在 Node.js 或不支持样式的环境中，输出纯文本\n      const consoleMethod = this._getConsoleMethod(level);\n      consoleMethod(timestamp, prefixStr, levelStr, ...args);\n    }\n  }\n\n  /**\n   * 根据日志级别获取对应的 console 方法\n   * @private\n   */\n  _getConsoleMethod(level) {\n    switch (level) {\n      case LogLevel.ERROR:\n        return console.error;\n      case LogLevel.WARN:\n        return console.warn;\n      case LogLevel.INFO:\n        return console.info;\n      default:\n        return console.log;\n    }\n  }\n\n  /**\n   * 记录 DEBUG 级别的日志\n   * @param {...any} args\n   */\n  debug(...args) {\n    this._log(LogLevel.DEBUG, ...args);\n  }\n\n  /**\n   * 记录 INFO 级别的日志\n   * @param {...any} args\n   */\n  info(...args) {\n    this._log(LogLevel.INFO, ...args);\n  }\n\n  /**\n   * 记录 WARN 级别的日志\n   * @param {...any} args\n   */\n  warn(...args) {\n    this._log(LogLevel.WARN, ...args);\n  }\n\n  /**\n   * 记录 ERROR 级别的日志\n   * @param {...any} args\n   */\n  error(...args) {\n    this._log(LogLevel.ERROR, ...args);\n  }\n}\n\nexport const logger = new Logger();\nexport const kissLog = logger.info.bind(logger);\n\n// todo：debug日志埋点\n"
  },
  {
    "path": "src/libs/mobile.js",
    "content": "export const isMobile = (() => {\n  try {\n    if (typeof navigator === \"undefined\") return false;\n    const ua = navigator.userAgent;\n    const isAndroid = /Android/i.test(ua);\n    const isiOS = /iPhone|iPad|iPod/i.test(ua);\n    // iPadOS 13+ requests desktop site by default\n    const isiPadDesktop = /Macintosh/i.test(ua) && navigator.maxTouchPoints > 1;\n    const isMobileDevice = isAndroid || isiOS || isiPadDesktop;\n\n    return isMobileDevice;\n  } catch (error) {\n    return false;\n  }\n})();\n"
  },
  {
    "path": "src/libs/msg.js",
    "content": "import { browser } from \"./browser\";\n\n/**\n * 获取当前tab信息\n * @returns\n */\nexport const getCurTab = async () => {\n  const [tab] = await browser.tabs.query({\n    active: true,\n    lastFocusedWindow: true,\n  });\n  return tab;\n};\n\nexport const getCurTabId = async () => {\n  const tab = await getCurTab();\n  return tab?.id;\n};\n\n/**\n * 发送消息给background\n * @param {*} action\n * @param {*} args\n * @returns\n */\nexport const sendBgMsg = (action, args) =>\n  browser?.runtime.sendMessage({ action, args });\n\n/**\n * 发送消息给当前页面\n * @param {*} action\n * @param {*} args\n * @returns\n */\nexport const sendTabMsg = async (action, args) => {\n  const tabId = await getCurTabId();\n  if (!tabId) return;\n  return browser.tabs.sendMessage(tabId, { action, args }).catch((err) => {\n    if (\n      err?.message?.includes(\"Could not establish connection\") ||\n      err?.message?.includes(\"Receiving end does not exist\")\n    ) {\n      console.warn(\"sendTabMsg warning: \", err?.message);\n    } else {\n      throw err;\n    }\n  });\n};\n"
  },
  {
    "path": "src/libs/pool.js",
    "content": "import { DEFAULT_FETCH_INTERVAL, DEFAULT_FETCH_LIMIT } from \"../config\";\nimport { kissLog } from \"./log\";\n\n/**\n * 任务池\n */\nclass TaskPool {\n  #pool = [];\n\n  #maxRetry = 2; // 最大重试次数\n  #retryInterval = 1000; // 重试间隔时间\n  #limit; // 最大并发数\n  #interval; // 任务最小启动间隔\n\n  #currentConcurrent = 0; // 当前正在执行的任务数\n  #lastExecutionTime = 0; // 上一个任务的启动时间\n  #schedulerTimer = null; // 用于调度下一个任务的定时器\n\n  constructor(\n    interval = DEFAULT_FETCH_INTERVAL,\n    limit = DEFAULT_FETCH_LIMIT,\n    retryInterval = 1000\n  ) {\n    this.#interval = interval;\n    this.#limit = limit;\n    this.#retryInterval = retryInterval;\n  }\n\n  /**\n   * 调度器\n   */\n  #scheduleNext() {\n    if (this.#schedulerTimer) {\n      return;\n    }\n\n    if (this.#currentConcurrent >= this.#limit || this.#pool.length === 0) {\n      return;\n    }\n\n    const now = Date.now();\n    const timeSinceLast = now - this.#lastExecutionTime;\n    const delay = Math.max(0, this.#interval - timeSinceLast);\n\n    this.#schedulerTimer = setTimeout(() => {\n      this.#schedulerTimer = null;\n      if (this.#currentConcurrent < this.#limit && this.#pool.length > 0) {\n        const task = this.#pool.shift();\n        if (task) {\n          this.#lastExecutionTime = Date.now();\n          this.#execute(task);\n        }\n      }\n\n      if (this.#pool.length > 0) {\n        this.#scheduleNext();\n      }\n    }, delay);\n  }\n\n  /**\n   * 执行单个任务\n   * @param {object} task - 任务对象\n   */\n  async #execute(task) {\n    this.#currentConcurrent++;\n    const { fn, args, resolve, reject, retry } = task;\n\n    try {\n      const res = await fn(args);\n      resolve(res);\n    } catch (err) {\n      kissLog(\"task pool\", err);\n      if (retry < this.#maxRetry) {\n        setTimeout(() => {\n          this.#pool.unshift({ ...task, retry: retry + 1 }); // unshift 保证重试任务优先\n          this.#scheduleNext();\n        }, this.#retryInterval);\n      } else {\n        reject(err);\n      }\n    } finally {\n      this.#currentConcurrent--;\n      this.#scheduleNext();\n    }\n  }\n\n  /**\n   * 向任务池中添加一个新任务\n   * @param {Function} fn - 要执行的异步函数\n   * @param {*} args - 函数的参数\n   * @returns {Promise}\n   */\n  push(fn, args) {\n    return new Promise((resolve, reject) => {\n      this.#pool.push({ fn, args, resolve, reject, retry: 0 });\n      this.#scheduleNext();\n    });\n  }\n\n  /**\n   * 更新任务池的配置\n   * @param {number} interval - 新的最小任务间隔\n   * @param {number} limit - 新的最大并发数\n   */\n  update(interval, limit) {\n    if (interval >= 0) {\n      this.#interval = interval;\n    }\n    if (limit >= 1) {\n      this.#limit = limit;\n    }\n\n    this.#scheduleNext();\n  }\n\n  /**\n   * 清空任务池\n   */\n  clear() {\n    for (const task of this.#pool) {\n      task.reject(\"the task pool was cleared\");\n    }\n\n    this.#pool.length = 0;\n    if (this.#schedulerTimer) {\n      clearTimeout(this.#schedulerTimer);\n      this.#schedulerTimer = null;\n    }\n  }\n}\n\n/**\n * 请求池实例\n */\nlet fetchPool;\n\n/**\n * 获取请求池实例\n * @param interval\n * @param limit\n * @returns\n */\nexport const getFetchPool = (interval, limit) => {\n  if (!fetchPool) {\n    fetchPool = new TaskPool(\n      interval ?? DEFAULT_FETCH_INTERVAL,\n      limit ?? DEFAULT_FETCH_LIMIT\n    );\n  } else if (interval && limit) {\n    updateFetchPool(interval, limit);\n  }\n  return fetchPool;\n};\n\n/**\n * 更新请求池参数\n * @param {*} interval\n * @param {*} limit\n */\nexport const updateFetchPool = (interval, limit) => {\n  fetchPool?.update(interval, limit);\n};\n\n/**\n * 清空请求池\n */\nexport const clearFetchPool = () => {\n  fetchPool?.clear();\n};\n"
  },
  {
    "path": "src/libs/popupManager.js",
    "content": "import ShadowDomManager from \"./shadowDomManager\";\nimport { APP_CONSTS, EVENT_KISS_INNER, MSG_POPUP_TOGGLE } from \"../config\";\nimport Action from \"../views/Action\";\n\nexport class PopupManager extends ShadowDomManager {\n  constructor({ translator, processActions }) {\n    super({\n      id: APP_CONSTS.popupID,\n      className: \"notranslate\",\n      reactComponent: Action,\n      props: { translator, processActions },\n    });\n  }\n\n  toggle(props) {\n    if (this.isVisible) {\n      document.dispatchEvent(\n        new CustomEvent(EVENT_KISS_INNER, {\n          detail: { action: MSG_POPUP_TOGGLE },\n        })\n      );\n    } else {\n      this.show(props || this._props);\n    }\n  }\n}\n"
  },
  {
    "path": "src/libs/rules.js",
    "content": "import { matchValue, type, isMatch } from \"./utils\";\nimport {\n  GLOBAL_KEY,\n  OPT_LANGS_FROM,\n  OPT_LANGS_TO,\n  DEFAULT_RULE,\n  GLOBLA_RULE,\n  OPT_SPLIT_PARAGRAPH_ALL,\n  OPT_HIGHLIGHT_WORDS_ALL,\n} from \"../config\";\nimport { loadOrFetchSubRules } from \"./subRules\";\nimport { getRulesWithDefault, setRules } from \"./storage\";\nimport { trySyncRules } from \"./sync\";\nimport { kissLog } from \"./log\";\n\nfunction mergeSelectors(defaultStr, userStr) {\n  if (!userStr || !userStr.trim()) {\n    return defaultStr;\n  }\n\n  const defaultList = defaultStr\n    .split(\",\")\n    .map((s) => s.trim())\n    .filter(Boolean);\n  const userList = userStr\n    .split(\",\")\n    .map((s) => s.trim())\n    .filter(Boolean);\n\n  const isPatchMode = userList.some(\n    (s) => s.startsWith(\"+\") || s.startsWith(\"-\")\n  );\n\n  if (!isPatchMode) {\n    return [...new Set(userList)].join(\", \");\n  }\n\n  let finalSet = new Set(defaultList);\n  let currentMode = \"add\";\n  userList.forEach((item) => {\n    let selector = item;\n\n    if (item.startsWith(\"+\")) {\n      currentMode = \"add\";\n      selector = item.slice(1).trim();\n    } else if (item.startsWith(\"-\")) {\n      currentMode = \"remove\";\n      selector = item.slice(1).trim();\n    }\n\n    if (!selector) return;\n\n    if (currentMode === \"remove\") {\n      finalSet.delete(selector);\n    } else {\n      finalSet.add(selector);\n    }\n  });\n\n  return [...finalSet].join(\", \");\n}\n\n/**\n * 在规则列表中查找匹配的规则\n * @param {Array} rules 规则列表\n * @param {string} href 当前页面URL\n * @returns {Object|null} 匹配的规则或null\n */\nconst findMatchingRule = (rules, href) => {\n  return rules.find(\n    (r) =>\n      r.pattern !== GLOBAL_KEY &&\n      r.pattern.split(/\\n|,/).some((p) => isMatch(href, p.trim()))\n  );\n};\n\n/**\n * 合并规则，应用优先级\n * 对于选择器类型的属性，使用mergeSelectors合并\n * 对于其他属性，高优先级规则覆盖低优先级规则\n * @param {Object} baseRule 基准规则（低优先级）\n * @param {Object} overrideRule 覆盖规则（高优先级）\n * @returns {Object} 合并后的规则\n */\nconst mergeRules = (baseRule, overrideRule) => {\n  if (!overrideRule) return { ...baseRule };\n  if (!baseRule) return { ...overrideRule };\n\n  const merged = { ...baseRule };\n\n  // 选择器类型的属性需要使用mergeSelectors合并\n  [\"selector\", \"keepSelector\", \"rootsSelector\", \"ignoreSelector\"].forEach(\n    (key) => {\n      merged[key] = mergeSelectors(\n        baseRule[key] || \"\",\n        overrideRule[key] || \"\"\n      );\n    }\n  );\n\n  // 字符串类型的属性，非空则覆盖\n  [\n    \"terms\",\n    \"aiTerms\",\n    \"termsStyle\",\n    \"highlightStyle\",\n    \"textExtStyle\",\n    \"selectStyle\",\n    \"parentStyle\",\n    \"grandStyle\",\n    \"injectJs\",\n    \"injectCss\",\n    \"transStartHook\",\n    \"transEndHook\",\n    // \"transRemoveHook\",\n  ].forEach((key) => {\n    if (overrideRule[key]?.trim()) {\n      merged[key] = overrideRule[key];\n    }\n  });\n\n  // 枚举类型的属性，非全局值则覆盖\n  [\n    \"apiSlug\",\n    \"fromLang\",\n    \"toLang\",\n    \"transOpen\",\n    \"transOnly\",\n    \"autoScan\",\n    \"hasRichText\",\n    \"hasShadowroot\",\n    \"scanAll\",\n    \"transTag\",\n    \"transTitle\",\n    \"splitParagraph\",\n    \"highlightWords\",\n    \"textStyle\",\n  ].forEach((key) => {\n    if (overrideRule[key] && overrideRule[key] !== GLOBAL_KEY) {\n      merged[key] = overrideRule[key];\n    }\n  });\n\n  // 数字类型的属性\n  [\"splitLength\"].forEach((key) => {\n    if (overrideRule[key]) {\n      merged[key] = overrideRule[key];\n    }\n  });\n\n  // pattern使用高优先级规则的pattern\n  if (overrideRule.pattern) {\n    merged.pattern = overrideRule.pattern;\n  }\n\n  return merged;\n};\n\n/**\n * 根据href匹配规则\n * 合并匹配到的个人规则、订阅规则、全局规则\n * 优先级：个人规则 > 订阅规则 > 全局规则\n * @param {*} rules\n * @param {string} href\n * @returns\n */\nexport const matchRule = async (href, { injectRules, subrulesList }) => {\n  // 获取个人规则\n  const personalRules = await getRulesWithDefault();\n\n  // 获取全局规则\n  const globalRule = {\n    ...GLOBLA_RULE,\n    ...(personalRules.find((r) => r.pattern === GLOBAL_KEY) || {}),\n  };\n\n  // 查找匹配的个人规则（排除全局规则）\n  const matchedPersonalRule = findMatchingRule(personalRules, href);\n\n  // 获取订阅规则并查找匹配\n  let matchedSubRule = null;\n  if (injectRules) {\n    try {\n      const selectedSub = subrulesList.find((item) => item.selected);\n      if (selectedSub?.url) {\n        const subRules = await loadOrFetchSubRules(selectedSub.url);\n        matchedSubRule = findMatchingRule(subRules, href);\n      }\n    } catch (err) {\n      kissLog(\"load injectRules\", err);\n    }\n  }\n\n  // 如果没有匹配到任何规则，返回全局规则\n  if (!matchedPersonalRule && !matchedSubRule) {\n    return globalRule;\n  }\n\n  // 合并规则：全局规则 <- 订阅规则 <- 个人规则\n  // 优先级：个人规则 > 订阅规则 > 全局规则\n  let finalRule = { ...globalRule };\n  finalRule = mergeRules(finalRule, matchedSubRule);\n  finalRule = mergeRules(finalRule, matchedPersonalRule);\n\n  return finalRule;\n};\n\n/**\n * 检查过滤rules\n * @param {*} rules\n * @returns\n */\nexport const checkRules = (rules) => {\n  if (type(rules) === \"string\") {\n    rules = JSON.parse(rules);\n  }\n  if (type(rules) !== \"array\") {\n    throw new Error(\"data error\");\n  }\n\n  const fromLangs = OPT_LANGS_FROM.map((item) => item[0]);\n  const toLangs = OPT_LANGS_TO.map((item) => item[0]);\n  const patternSet = new Set();\n  rules = rules\n    .filter((rule) => type(rule) === \"object\")\n    .filter(({ pattern }) => {\n      if (type(pattern) !== \"string\" || patternSet.has(pattern.trim())) {\n        return false;\n      }\n      patternSet.add(pattern.trim());\n      return true;\n    })\n    .map(\n      ({\n        pattern,\n        selector,\n        keepSelector,\n        rootsSelector,\n        ignoreSelector,\n        terms,\n        aiTerms,\n        termsStyle,\n        highlightStyle,\n        textExtStyle,\n        selectStyle,\n        parentStyle,\n        grandStyle,\n        injectJs,\n        injectCss,\n        apiSlug,\n        fromLang,\n        toLang,\n        textStyle,\n        transOpen,\n        transOnly,\n        autoScan,\n        hasRichText,\n        hasShadowroot,\n        scanAll,\n        transTag,\n        transTitle,\n        transStartHook,\n        transEndHook,\n        // transRemoveHook,\n        splitParagraph,\n        splitLength,\n        highlightWords,\n      }) => ({\n        pattern: pattern.trim(),\n        selector: type(selector) === \"string\" ? selector : \"\",\n        keepSelector: type(keepSelector) === \"string\" ? keepSelector : \"\",\n        rootsSelector: type(rootsSelector) === \"string\" ? rootsSelector : \"\",\n        ignoreSelector: type(ignoreSelector) === \"string\" ? ignoreSelector : \"\",\n        terms: type(terms) === \"string\" ? terms : \"\",\n        aiTerms: type(aiTerms) === \"string\" ? aiTerms : \"\",\n        termsStyle: type(termsStyle) === \"string\" ? termsStyle : \"\",\n        highlightStyle: type(highlightStyle) === \"string\" ? highlightStyle : \"\",\n        textExtStyle: type(textExtStyle) === \"string\" ? textExtStyle : \"\",\n        selectStyle: type(selectStyle) === \"string\" ? selectStyle : \"\",\n        parentStyle: type(parentStyle) === \"string\" ? parentStyle : \"\",\n        grandStyle: type(grandStyle) === \"string\" ? grandStyle : \"\",\n        injectJs: type(injectJs) === \"string\" ? injectJs : \"\",\n        injectCss: type(injectCss) === \"string\" ? injectCss : \"\",\n        apiSlug:\n          type(apiSlug) === \"string\" && apiSlug.trim() !== \"\"\n            ? apiSlug.trim()\n            : GLOBAL_KEY,\n        fromLang: matchValue([GLOBAL_KEY, ...fromLangs], fromLang),\n        toLang: matchValue([GLOBAL_KEY, ...toLangs], toLang),\n        // textStyle: matchValue([GLOBAL_KEY, ...OPT_STYLE_ALL], textStyle),\n        textStyle:\n          type(textStyle) === \"string\" && textStyle.trim() !== \"\"\n            ? textStyle.trim()\n            : GLOBAL_KEY,\n        transOpen: matchValue([GLOBAL_KEY, \"true\", \"false\"], transOpen),\n        transOnly: matchValue([GLOBAL_KEY, \"true\", \"false\"], transOnly),\n        autoScan: matchValue([GLOBAL_KEY, \"true\", \"false\"], autoScan),\n        hasRichText: matchValue([GLOBAL_KEY, \"true\", \"false\"], hasRichText),\n        hasShadowroot: matchValue([GLOBAL_KEY, \"true\", \"false\"], hasShadowroot),\n        scanAll: matchValue([GLOBAL_KEY, \"true\", \"false\"], scanAll),\n        transTag: matchValue([GLOBAL_KEY, \"span\", \"font\"], transTag),\n        transTitle: matchValue([GLOBAL_KEY, \"true\", \"false\"], transTitle),\n        transStartHook: type(transStartHook) === \"string\" ? transStartHook : \"\",\n        transEndHook: type(transEndHook) === \"string\" ? transEndHook : \"\",\n        // transRemoveHook:\n        //   type(transRemoveHook) === \"string\" ? transRemoveHook : \"\",\n        splitParagraph: matchValue(\n          [GLOBAL_KEY, ...OPT_SPLIT_PARAGRAPH_ALL],\n          splitParagraph\n        ),\n        splitLength: Number.isInteger(splitLength) ? splitLength : 0,\n        highlightWords: matchValue(\n          [GLOBAL_KEY, ...OPT_HIGHLIGHT_WORDS_ALL],\n          highlightWords\n        ),\n      })\n    );\n\n  return rules;\n};\n\n/**\n * 保存或更新rule\n * @param {*} curRule\n */\nexport const saveRule = async (curRule) => {\n  const rules = await getRulesWithDefault();\n\n  const index = rules.findIndex(\n    (item) =>\n      item.pattern !== GLOBAL_KEY && isMatch(curRule.pattern, item.pattern)\n  );\n  if (index !== -1) {\n    const rule = rules.splice(index, 1)[0];\n    curRule = {\n      ...rule,\n      ...curRule,\n      pattern: rule.pattern,\n      selector: rule.selector,\n      keepSelector: rule.keepSelector,\n      rootsSelector: rule.rootsSelector,\n      ignoreSelector: rule.ignoreSelector,\n    };\n  }\n\n  const newRule = {};\n  const globalRule = {\n    ...GLOBLA_RULE,\n    ...(rules.find((r) => r.pattern === GLOBAL_KEY) || {}),\n  };\n  Object.keys(GLOBLA_RULE).forEach((key) => {\n    newRule[key] =\n      !curRule[key] || curRule[key] === globalRule[key]\n        ? DEFAULT_RULE[key]\n        : curRule[key];\n  });\n\n  rules.unshift(newRule);\n  await setRules(rules);\n\n  trySyncRules();\n};\n"
  },
  {
    "path": "src/libs/shadowDomManager.js",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom/client\";\nimport { CacheProvider } from \"@emotion/react\";\nimport createCache from \"@emotion/cache\";\nimport { logger } from \"./log\";\n\nexport default class ShadowDomManager {\n  #hostElement = null;\n  #reactRoot = null;\n  #isVisible = false;\n  #isProcessing = false;\n\n  _id;\n  _className;\n  _ReactComponent;\n  _props;\n\n  constructor({\n    id,\n    className = \"\",\n    reactComponent,\n    props = {},\n    rootElement = document.body,\n  }) {\n    if (!id || !reactComponent) {\n      throw new Error(\"ID and a React Component must be provided.\");\n    }\n    this._id = id;\n    this._className = className;\n    this._ReactComponent = reactComponent;\n    this._props = props;\n    this._rootElement = rootElement;\n  }\n\n  get isVisible() {\n    return this.#isVisible;\n  }\n\n  show(props) {\n    if (this.#isVisible || this.#isProcessing) {\n      return;\n    }\n\n    if (!this.#hostElement) {\n      this.#isProcessing = true;\n      try {\n        this.#mount(props || this._props);\n      } catch (error) {\n        logger.warn(`Failed to mount component with id \"${this._id}\":`, error);\n        this.#isProcessing = false;\n        return;\n      } finally {\n        this.#isProcessing = false;\n      }\n    }\n\n    this.#hostElement.style.display = \"\";\n    this.#isVisible = true;\n  }\n\n  hide() {\n    if (!this.#isVisible || !this.#hostElement) {\n      return;\n    }\n    this.#hostElement.style.display = \"none\";\n    this.#isVisible = false;\n  }\n\n  destroy() {\n    if (!this.#hostElement) {\n      return;\n    }\n    this.#isProcessing = true;\n\n    if (this.#reactRoot) {\n      this.#reactRoot.unmount();\n    }\n\n    this.#hostElement.remove();\n\n    this.#hostElement = null;\n    this.#reactRoot = null;\n    this.#isVisible = false;\n    this.#isProcessing = false;\n    logger.info(`Component with id \"${this._id}\" has been destroyed.`);\n  }\n\n  toggle(props) {\n    if (this.#isVisible) {\n      this.hide();\n    } else {\n      this.show(props || this._props);\n    }\n  }\n\n  #mount(props) {\n    const host = document.createElement(\"div\");\n    host.id = this._id;\n    if (this._className) {\n      host.className = this._className;\n    }\n\n    this._rootElement.appendChild(host);\n    this.#hostElement = host;\n    const shadowContainer = host.attachShadow({ mode: \"open\" });\n    const appRoot = document.createElement(\"div\");\n    appRoot.className = `${this._id}_wrapper notranslate`;\n    shadowContainer.appendChild(appRoot);\n\n    const cache = createCache({\n      key: this._id,\n      prepend: true,\n      container: shadowContainer,\n    });\n\n    const enhancedProps = {\n      ...props,\n      onClose: this.hide.bind(this),\n    };\n\n    const ComponentToRender = this._ReactComponent;\n    this.#reactRoot = ReactDOM.createRoot(appRoot);\n    this.#reactRoot.render(\n      <React.StrictMode>\n        <CacheProvider value={cache}>\n          <ComponentToRender {...enhancedProps} />\n        </CacheProvider>\n      </React.StrictMode>\n    );\n  }\n}\n"
  },
  {
    "path": "src/libs/shortcut.js",
    "content": "import { isSameSet } from \"./utils\";\n\n/**\n * 键盘快捷键监听器\n * @param {(pressedKeys: Set<string>, event: KeyboardEvent) => void} onKeyDown - Keydown 回调\n * @param {(pressedKeys: Set<string>, event: KeyboardEvent) => void} onKeyUp - Keyup 回调\n * @param {EventTarget} target - 监听的目标元素\n * @returns {() => void} - 用于注销监听的函数\n */\nexport const shortcutListener = (\n  onKeyDown = () => {},\n  onKeyUp = () => {},\n  target = window\n) => {\n  const pressedKeys = new Set();\n\n  const handleKeyDown = (e) => {\n    if (!e.code) {\n      return;\n    }\n\n    if (pressedKeys.has(e.code)) return;\n    pressedKeys.add(e.code);\n    onKeyDown(new Set(pressedKeys), e);\n  };\n\n  const handleKeyUp = (e) => {\n    if (!e.code) {\n      return;\n    }\n\n    // onKeyUp 应该在 key 从集合中移除前触发，以便判断组合键\n    onKeyUp(new Set(pressedKeys), e);\n    pressedKeys.delete(e.code);\n  };\n\n  const handleBlur = () => {\n    pressedKeys.clear();\n  };\n\n  target.addEventListener(\"keydown\", handleKeyDown, true);\n  target.addEventListener(\"keyup\", handleKeyUp, true);\n  window.addEventListener(\"blur\", handleBlur);\n\n  return () => {\n    target.removeEventListener(\"keydown\", handleKeyDown, true);\n    target.removeEventListener(\"keyup\", handleKeyUp, true);\n    window.removeEventListener(\"blur\", handleBlur);\n    pressedKeys.clear();\n  };\n};\n\n/**\n * 注册键盘快捷键\n * @param {string[]} targetKeys - 目标快捷键数组\n * @param {() => void} fn - 匹配成功后执行的回调\n * @param {EventTarget} target - 监听目标\n * @returns {() => void} - 注销函数\n */\nexport const shortcutRegister = (targetKeys = [], fn, target = window) => {\n  if (targetKeys.length === 0) return () => {};\n\n  const targetKeySet = new Set(targetKeys);\n  let hasInterference = false;\n  const onKeyDown = (pressedKeys, event) => {\n    // if (isSameSet(targetKeySet, pressedKeys)) {\n    //   // event.preventDefault(); // 阻止浏览器的默认行为\n    //   // event.stopPropagation(); // 阻止事件继续（向父元素）冒泡\n    //   fn();\n    // }\n    if (!targetKeySet.has(event.code)) {\n      hasInterference = true;\n    }\n  };\n  const onKeyUp = (pressedKeys, event) => {\n    if (isSameSet(targetKeySet, pressedKeys) && !hasInterference) {\n      fn();\n    }\n    if (pressedKeys.size === 1) {\n      hasInterference = false;\n    }\n  };\n\n  return shortcutListener(onKeyDown, onKeyUp, target);\n};\n\n/**\n * 高阶函数：为目标函数增加计次和超时重置功能\n * @param {() => void} fn - 需要被包装的函数\n * @param {number} step - 需要触发的次数\n * @param {number} timeout - 超时毫秒数\n * @returns {() => void} - 包装后的新函数\n */\nconst withStepCounter = (fn, step, timeout) => {\n  let count = 0;\n  let timer = null;\n\n  return () => {\n    timer && clearTimeout(timer);\n    timer = setTimeout(() => {\n      count = 0;\n    }, timeout);\n\n    count++;\n    if (count === step) {\n      count = 0;\n      clearTimeout(timer);\n      fn();\n    }\n  };\n};\n\n/**\n * 注册连续快捷键\n * @param {string[]} targetKeys - 目标快捷键数组\n * @param {() => void} fn - 成功回调\n * @param {number} step - 连续触发次数\n * @param {number} timeout - 每次触发的间隔超时\n * @param {EventTarget} target - 监听目标\n * @returns {() => void} - 注销函数\n */\nexport const stepShortcutRegister = (\n  targetKeys = [],\n  fn,\n  step = 2,\n  timeout = 500,\n  target = window\n) => {\n  const steppedFn = withStepCounter(fn, step, timeout);\n  return shortcutRegister(targetKeys, steppedFn, target);\n};\n"
  },
  {
    "path": "src/libs/storage.js",
    "content": "import {\n  STOKEY_SETTING,\n  STOKEY_SETTING_OLD,\n  STOKEY_RULES,\n  STOKEY_RULES_OLD,\n  STOKEY_WORDS,\n  STOKEY_FAB,\n  STOKEY_TRANBOX,\n  STOKEY_SYNC,\n  STOKEY_MSAUTH,\n  STOKEY_BDAUTH,\n  STOKEY_RULESCACHE_PREFIX,\n  DEFAULT_SETTING,\n  DEFAULT_RULES,\n  DEFAULT_SYNC,\n  BUILTIN_RULES,\n} from \"../config\";\nimport { isExt, isGm } from \"./client\";\nimport { browser } from \"./browser\";\nimport { kissLog } from \"./log\";\nimport { debounce } from \"./utils\";\n\nasync function set(key, val) {\n  if (isExt) {\n    await browser.storage.local.set({ [key]: val });\n  } else if (isGm) {\n    await (window.KISS_GM || GM).setValue(key, val);\n  } else {\n    window.localStorage.setItem(key, val);\n  }\n}\n\nasync function get(key) {\n  if (isExt) {\n    const val = await browser.storage.local.get([key]);\n    return val[key];\n  } else if (isGm) {\n    const val = await (window.KISS_GM || GM).getValue(key);\n    return val;\n  }\n  return window.localStorage.getItem(key);\n}\n\nasync function del(key) {\n  if (isExt) {\n    await browser.storage.local.remove([key]);\n  } else if (isGm) {\n    await (window.KISS_GM || GM).deleteValue(key);\n  } else {\n    window.localStorage.removeItem(key);\n  }\n}\n\nasync function setObj(key, obj) {\n  await set(key, JSON.stringify(obj));\n}\n\nasync function trySetObj(key, obj) {\n  if (!(await get(key))) {\n    await setObj(key, obj);\n  }\n}\n\nasync function getObj(key) {\n  const val = await get(key);\n  if (val === null || val === undefined) return null;\n  try {\n    return JSON.parse(val);\n  } catch (err) {\n    kissLog(\"parse json in storage err: \", key);\n  }\n  return null;\n}\n\nasync function putObj(key, obj) {\n  const cur = (await getObj(key)) ?? {};\n  await setObj(key, { ...cur, ...obj });\n}\n\n/**\n * 对storage的封装\n */\nexport const storage = {\n  get,\n  set,\n  del,\n  setObj,\n  trySetObj,\n  getObj,\n  putObj,\n  // onChanged,\n};\n\n/**\n * 设置信息\n */\nexport const getSetting = () => getObj(STOKEY_SETTING);\nexport const getSettingOld = () => getObj(STOKEY_SETTING_OLD);\nexport const getSettingWithDefault = async () => ({\n  ...DEFAULT_SETTING,\n  ...((await getSetting()) || {}),\n});\nexport const setSetting = (val) => setObj(STOKEY_SETTING, val);\nexport const putSetting = (obj) => putObj(STOKEY_SETTING, obj);\n\n/**\n * 规则列表\n */\nexport const getRules = () => getObj(STOKEY_RULES);\nexport const getRulesOld = () => getObj(STOKEY_RULES_OLD);\nexport const getRulesWithDefault = async () =>\n  (await getRules()) || DEFAULT_RULES;\nexport const setRules = (val) => setObj(STOKEY_RULES, val);\n\n/**\n * 词汇列表\n */\nexport const getWords = () => getObj(STOKEY_WORDS);\nexport const getWordsWithDefault = async () => (await getWords()) || {};\nexport const setWords = (val) => setObj(STOKEY_WORDS, val);\n\n/**\n * 订阅规则\n */\nexport const getSubRules = (url) => getObj(STOKEY_RULESCACHE_PREFIX + url);\nexport const getSubRulesWithDefault = async () => (await getSubRules()) || [];\nexport const delSubRules = (url) => del(STOKEY_RULESCACHE_PREFIX + url);\nexport const setSubRules = (url, val) =>\n  setObj(STOKEY_RULESCACHE_PREFIX + url, val);\n\n/**\n * fab位置\n */\nexport const getFab = () => getObj(STOKEY_FAB);\nexport const getFabWithDefault = async () => (await getFab()) || {};\nexport const setFab = (obj) => setObj(STOKEY_FAB, obj);\nexport const putFab = (obj) => putObj(STOKEY_FAB, obj);\n\n/**\n * tranbox位置大小\n */\nexport const getTranBox = () => getObj(STOKEY_TRANBOX);\nexport const putTranBox = (obj) => putObj(STOKEY_TRANBOX, obj);\nexport const debouncePutTranBox = debounce(putTranBox, 300);\n\n/**\n * 数据同步\n */\nexport const getSync = () => getObj(STOKEY_SYNC);\nexport const getSyncWithDefault = async () => (await getSync()) || DEFAULT_SYNC;\nexport const putSync = (obj) => putObj(STOKEY_SYNC, obj);\nexport const putSyncMeta = async (key) => {\n  const { syncMeta = {} } = await getSyncWithDefault();\n  syncMeta[key] = { ...(syncMeta[key] || {}), updateAt: Date.now() };\n  await putSync({ syncMeta });\n};\nexport const debounceSyncMeta = debounce(putSyncMeta, 300);\n\n/**\n * ms auth\n */\nexport const getMsauth = () => getObj(STOKEY_MSAUTH);\nexport const setMsauth = (val) => setObj(STOKEY_MSAUTH, val);\n\n/**\n * baidu auth\n */\nexport const getBdauth = () => getObj(STOKEY_BDAUTH);\nexport const setBdauth = (val) => setObj(STOKEY_BDAUTH, val);\n\n/**\n * 存入默认数据\n */\nexport const tryInitDefaultData = async () => {\n  try {\n    await trySetObj(STOKEY_SETTING, DEFAULT_SETTING);\n    await trySetObj(STOKEY_RULES, DEFAULT_RULES);\n    await trySetObj(STOKEY_SYNC, DEFAULT_SYNC);\n    await trySetObj(\n      `${STOKEY_RULESCACHE_PREFIX}${process.env.REACT_APP_RULESURL}`,\n      BUILTIN_RULES\n    );\n  } catch (err) {\n    kissLog(\"init default\", err);\n  }\n};\n"
  },
  {
    "path": "src/libs/stream.js",
    "content": "import { JSONParser } from \"@streamparser/json\";\nimport {\n  OPT_TRANS_OPENAI,\n  OPT_TRANS_GEMINI,\n  OPT_TRANS_GEMINI_2,\n  OPT_TRANS_OPENROUTER,\n  OPT_TRANS_OLLAMA,\n  OPT_TRANS_CLAUDE,\n} from \"../config\";\n\n/**\n * 创建 SSE 流解析器\n * 处理 buffer 管理和 SSE 格式解析，逐条 yield 解析出的数据\n * @returns {Function} 生成器函数，接收 chunk 逐条 yield data\n */\nexport const createSSEParser = () => {\n  let buffer = \"\";\n\n  return function* (chunk) {\n    buffer += chunk;\n    const lines = buffer.split(\"\\n\");\n    buffer = lines.pop() || \"\";\n\n    for (const line of lines) {\n      if (!line.startsWith(\"data: \")) continue;\n      const data = line.slice(6);\n      if (data === \"[DONE]\") continue;\n      yield data;\n    }\n  };\n};\n\n/**\n * 创建异步队列，用于在回调式 API 和异步生成器之间桥接\n * @returns {Object} 队列对象\n */\nexport const createAsyncQueue = () => {\n  const queue = [];\n  let resolve = null;\n  let done = false;\n  let error = null;\n\n  return {\n    push: (data) => {\n      queue.push(data);\n      if (resolve) {\n        resolve();\n        resolve = null;\n      }\n    },\n    finish: () => {\n      done = true;\n      if (resolve) {\n        resolve();\n        resolve = null;\n      }\n    },\n    error: (e) => {\n      error = e;\n      done = true;\n      if (resolve) {\n        resolve();\n        resolve = null;\n      }\n    },\n    async *iterate() {\n      const setResolve = (r) => {\n        resolve = r;\n      };\n      while (!done || queue.length > 0) {\n        if (queue.length > 0) {\n          yield queue.shift();\n        } else if (!done) {\n          await new Promise(setResolve);\n        }\n      }\n      if (error) throw error;\n    },\n  };\n};\n\n/**\n * 从流式响应数据中提取 delta 内容\n * @param {Object} json 解析后的 SSE 数据\n * @param {string} apiType API 类型\n * @returns {string} delta 内容\n */\nexport function getStreamDelta(json, apiType) {\n  switch (apiType) {\n    case OPT_TRANS_OPENAI:\n    case OPT_TRANS_GEMINI_2:\n    case OPT_TRANS_OPENROUTER:\n    case OPT_TRANS_OLLAMA:\n      return json.choices?.[0]?.delta?.content || \"\";\n    case OPT_TRANS_GEMINI:\n      return json.candidates?.[0]?.content?.parts?.[0]?.text || \"\";\n    case OPT_TRANS_CLAUDE:\n      if (json.type === \"content_block_delta\") {\n        return json.delta?.text || \"\";\n      }\n      return \"\";\n    default:\n      return \"\";\n  }\n}\n\n/**\n * 解析 SSE 流式响应中的段落（支持 XML、行格式）\n * @param {string} content 当前累积的内容\n * @param {Set} processedIds 已处理的 ID 集合\n * @yields {{ id, translation }} 解析出的段落\n */\nexport function* parseStreamingSegments(content, processedIds) {\n  if (!content) return;\n\n  // 尝试解析 XML 格式: <t id=\"0\" sourceLanguage=\"en\">翻译内容</t>\n  const xmlRegex =\n    /<(t|item|seg)\\s+id=\"(\\d+)\"(?:\\s+sourceLanguage=\"([^\"]*)\")?[^>]*>([\\s\\S]*?)<\\/\\1>/gi;\n  let match;\n  let hasXml = false;\n  while ((match = xmlRegex.exec(content)) !== null) {\n    hasXml = true;\n    const id = parseInt(match[2], 10);\n    if (!processedIds.has(id)) {\n      processedIds.add(id);\n      const sourceLanguage = match[3] || \"\";\n      const translation = [match[4].trim(), sourceLanguage];\n      yield { id, translation };\n    }\n  }\n\n  if (hasXml) return;\n\n  // 尝试解析行格式: 0 | 翻译内容\n  const endsWithNewline = content.endsWith(\"\\n\");\n  const lines = content.split(\"\\n\");\n  const linesToProcess = endsWithNewline ? lines : lines.slice(0, -1);\n\n  for (const line of linesToProcess) {\n    const trimmedLine = line.trim();\n    if (!trimmedLine) continue;\n\n    const pipeMatch = trimmedLine.match(/^(\\d+)\\s*\\|\\s*(.*)/);\n    if (pipeMatch) {\n      const id = parseInt(pipeMatch[1], 10);\n      if (!processedIds.has(id)) {\n        processedIds.add(id);\n        const translation = [\n          pipeMatch[2].trim().replace(/<br\\s*\\/?>/gi, \"\\n\"),\n          \"\",\n        ];\n        yield { id, translation };\n      }\n    }\n  }\n}\n\n/**\n * 创建流式 JSON 解析器\n * 支持的 JSON 格式:\n * - { \"translations\": [{ \"id\": 0, \"text\": \"翻译\" }, ...] }\n * - [{ \"id\": 0, \"text\": \"翻译\" }, ...]\n * @returns {{ write: Generator, end: Function }}\n */\nexport function createStreamingJsonParser() {\n  const pending = [];\n  const parser = new JSONParser({\n    paths: [\"$.translations.*\", \"$.*\"],\n    keepStack: false,\n  });\n\n  parser.onValue = ({ value }) => {\n    if (\n      value &&\n      typeof value === \"object\" &&\n      typeof value.id === \"number\" &&\n      (typeof value.text === \"string\" || typeof value.translation === \"string\")\n    ) {\n      const id = value.id;\n      const translation = value.text || value.translation || \"\";\n      const sourceLanguage = value.sourceLanguage || value.src || \"\";\n      pending.push({ id, translation: [translation, sourceLanguage] });\n    }\n  };\n\n  parser.onError = () => {};\n\n  return {\n    *write(delta) {\n      try {\n        parser.write(delta);\n      } catch (e) {\n        // 忽略解析错误\n      }\n      while (pending.length > 0) {\n        yield pending.shift();\n      }\n    },\n    end() {\n      try {\n        parser.end();\n      } catch (e) {\n        // 忽略\n      }\n    },\n  };\n}\n\n/**\n * 检测流式内容的格式类型\n * @param {string} content 累积的内容\n * @returns {{ isJson: boolean, detected: boolean }}\n */\nexport function detectStreamFormat(content) {\n  const stripped = content.trim();\n\n  // 查找第一个有意义的格式标识符位置\n  const jsonStart = stripped.search(/[{[]/);\n  const xmlStart = stripped.search(/<(t|item|seg)\\s/i);\n  const lineStart = stripped.search(/^\\d+\\s*\\|/m);\n\n  // 如果都没找到，无法确定格式\n  if (jsonStart === -1 && xmlStart === -1 && lineStart === -1) {\n    return { isJson: false, detected: false };\n  }\n\n  // 找出最先出现的格式\n  const positions = [\n    { type: \"json\", pos: jsonStart },\n    { type: \"xml\", pos: xmlStart },\n    { type: \"line\", pos: lineStart },\n  ].filter((p) => p.pos !== -1);\n\n  if (positions.length === 0) {\n    return { isJson: false, detected: false };\n  }\n\n  const first = positions.reduce((a, b) => (a.pos < b.pos ? a : b));\n  return { isJson: first.type === \"json\", detected: true };\n}\n"
  },
  {
    "path": "src/libs/style.js",
    "content": "import { css, keyframes } from \"@emotion/css\";\nimport {\n  OPT_STYLE_NONE,\n  OPT_STYLE_LINE,\n  OPT_STYLE_DOTLINE,\n  OPT_STYLE_DASHLINE,\n  OPT_STYLE_WAVYLINE,\n  OPT_STYLE_DASHBOX,\n  OPT_STYLE_FUZZY,\n  OPT_STYLE_HIGHLIGHT,\n  OPT_STYLE_BLOCKQUOTE,\n  OPT_STYLE_GRADIENT,\n  OPT_STYLE_BLINK,\n  OPT_STYLE_GLOW,\n  OPT_STYLE_COLORFUL,\n  DEFAULT_COLOR,\n  OPT_STYLE_MARKER,\n  OPT_STYLE_GRADIENT_MARKER,\n  OPT_STYLE_DASHBOX_BOLD,\n  OPT_STYLE_DASHLINE_BOLD,\n  OPT_STYLE_WAVYLINE_BOLD,\n} from \"../config\";\n\nconst gradientFlow = keyframes`\n  to {\n    background-position: 200% center;\n  }\n`;\n\nconst blink = keyframes`\n  0%, 100% {\n    opacity: 1;\n  }\n  50% {\n    opacity: 0;\n  }\n`;\n\nconst glow = keyframes`\n  from {\n    text-shadow: 0 0 10px #fff, \n    0 0 20px #fff, \n    0 0 30px #0073e6, \n    0 0 40px #0073e6;\n  }\n  to {\n    text-shadow: 0 0 20px #fff, \n    0 0 30px #ff4da6, \n    0 0 40px #ff4da6, \n    0 0 50px #ff4da6;\n  }\n`;\n\nconst genLineStyle = (style, color, thickness = 1) => `\n  text-decoration-line: underline;\n  text-decoration-style: ${style};\n  text-decoration-color: ${color};\n  text-decoration-thickness: ${thickness}px;\n  text-underline-offset: 0.3em;\n  -webkit-text-decoration-line: underline;\n  -webkit-text-decoration-style: ${style};\n  -webkit-text-decoration-color: ${color};\n  -webkit-text-decoration-thickness: 1px;\n  -webkit-text-underline-offset: 0.3em;\n\n  opacity: 0.8;\n  -webkit-opacity: 0.8;\n  &:hover {\n    opacity: 1;\n    -webkit-opacity: 1;\n  }\n`;\n\nconst genBuiltinStyles = (color = DEFAULT_COLOR) => ({\n  // 无样式\n  [OPT_STYLE_NONE]: ``,\n  // 下划线\n  [OPT_STYLE_LINE]: genLineStyle(\"solid\", color),\n  // 点状线\n  [OPT_STYLE_DOTLINE]: genLineStyle(\"dotted\", color),\n  // 虚线\n  [OPT_STYLE_DASHLINE]: genLineStyle(\"dashed\", color),\n  // 虚线加粗\n  [OPT_STYLE_DASHLINE_BOLD]: genLineStyle(\"dashed\", color, 2),\n  // 波浪线\n  [OPT_STYLE_WAVYLINE]: genLineStyle(\"wavy\", color),\n  // 波浪线加粗\n  [OPT_STYLE_WAVYLINE_BOLD]: genLineStyle(\"wavy\", color, 2),\n  // 虚线框\n  [OPT_STYLE_DASHBOX]: `\n    border: 1px dashed ${color};\n    display: block;\n    padding: 0.2em 0.3em;\n    box-sizing: border-box;\n  `,\n  // 虚线框加粗\n  [OPT_STYLE_DASHBOX_BOLD]: `\n    border: 2px dashed ${color};\n    display: block;\n    padding: 0.2em 0.3em;\n    box-sizing: border-box;\n  `,\n  // 马克笔\n  [OPT_STYLE_MARKER]: `\n    background: linear-gradient(to top, ${color} 50%, transparent 50%);\n  `,\n  // 渐变马克笔\n  [OPT_STYLE_GRADIENT_MARKER]: `\n    background: linear-gradient(to top, transparent, ${color} 20%, transparent 60%);\n  `,\n  // 模糊\n  [OPT_STYLE_FUZZY]: `\n    filter: blur(0.2em);\n    -webkit-filter: blur(0.2em);\n    &:hover {\n      filter: none;\n      -webkit-filter: none;\n    }\n  `,\n  // 高亮\n  [OPT_STYLE_HIGHLIGHT]: `\n    color: #fff;\n    background-color: ${color};\n  `,\n  // 引用\n  [OPT_STYLE_BLOCKQUOTE]: `\n    opacity: 0.8;\n    -webkit-opacity: 0.8;\n    display: block;\n    padding: 0.25em 0.5em;\n    border-left: 0.25em solid ${color};\n    background: rgb(32, 156, 238, 0.2);\n    &:hover {\n      opacity: 1;\n      -webkit-opacity: 1;\n    }\n  `,\n  // 渐变\n  [OPT_STYLE_GRADIENT]: `\n    background-image: linear-gradient(\n      90deg,\n      #3b82f6,\n      #9333ea,\n      #ec4899,\n      #3b82f6\n    );\n    background-size: 200% auto;\n    color: transparent;\n    -webkit-background-clip: text;\n    background-clip: text;\n    animation: ${gradientFlow} 4s linear infinite;\n  `,\n  // 闪现\n  [OPT_STYLE_BLINK]: `\n    animation: ${blink} 1s infinite;\n  `,\n  // 发光\n  [OPT_STYLE_GLOW]: `\n    animation: ${glow} 2s ease-in-out infinite alternate;\n  `,\n  // 多彩\n  [OPT_STYLE_COLORFUL]: `\n    color: #333;\n    background: linear-gradient(\n      45deg,\n      LightGreen 20%,\n      LightPink 20% 40%,\n      LightSalmon 40% 60%,\n      LightSeaGreen 60% 80%,\n      LightSkyBlue 80%\n    );\n    &:hover {\n      color: #111;\n    };\n  `,\n});\n\nexport const genTextClass = (customStyles = []) => {\n  const styles = genBuiltinStyles();\n  customStyles.forEach((style) => {\n    styles[style.styleSlug] = style.styleCode;\n  });\n\n  const textClass = {};\n  let textStyles = \"\";\n  Object.entries(styles).forEach(([k, v]) => {\n    textClass[k] = css`\n      ${v}\n    `;\n  });\n  Object.entries(styles).forEach(([k, v]) => {\n    textStyles += `\n      .${textClass[k]} {\n        ${v}\n      }\n    `;\n  });\n  return [textClass, textStyles];\n};\n\nexport const builtinStylesMap = genBuiltinStyles();\n"
  },
  {
    "path": "src/libs/subRules.js",
    "content": "import { GLOBAL_KEY } from \"../config\";\nimport {\n  getSyncWithDefault,\n  putSync,\n  setSubRules,\n  getSubRules,\n} from \"./storage\";\nimport { apiFetch } from \"../apis\";\nimport { checkRules } from \"./rules\";\nimport { isAllchar } from \"./utils\";\nimport { kissLog } from \"./log\";\n\n/**\n * 更新缓存同步时间\n * @param {*} url\n */\nconst updateSyncDataCache = async (url) => {\n  const { dataCaches = {} } = await getSyncWithDefault();\n  dataCaches[url] = Date.now();\n  await putSync({ dataCaches });\n};\n\n/**\n * 同步订阅规则\n * @param {*} url\n * @returns\n */\nexport const syncSubRules = async (url) => {\n  const res = await apiFetch(url);\n  const rules = checkRules(res).filter(\n    ({ pattern }) => !isAllchar(pattern, GLOBAL_KEY)\n  );\n  if (rules.length > 0) {\n    await setSubRules(url, rules);\n  }\n  return rules;\n};\n\n/**\n * 同步所有订阅规则\n * @param {*} url\n * @returns\n */\nexport const syncAllSubRules = async (subrulesList) => {\n  for (const subrules of subrulesList) {\n    try {\n      await syncSubRules(subrules.url);\n      await updateSyncDataCache(subrules.url);\n    } catch (err) {\n      kissLog(`sync subrule error: ${subrules.url}`, err);\n    }\n  }\n};\n\n/**\n * 根据时间同步所有订阅规则\n * @param {*} url\n * @returns\n */\nexport const trySyncAllSubRules = async ({ subrulesList }) => {\n  try {\n    const { subRulesSyncAt } = await getSyncWithDefault();\n    const now = Date.now();\n    const interval = 24 * 60 * 60 * 1000; // 间隔一天\n    if (now - subRulesSyncAt > interval) {\n      // 同步订阅规则\n      await syncAllSubRules(subrulesList);\n      await putSync({ subRulesSyncAt: now });\n    }\n  } catch (err) {\n    kissLog(\"try sync all subrules\", err);\n  }\n};\n\n/**\n * 从缓存或远程加载订阅规则\n * @param {*} url\n * @returns\n */\nexport const loadOrFetchSubRules = async (url) => {\n  let rules = await getSubRules(url);\n  if (!rules || rules.length === 0) {\n    rules = await syncSubRules(url);\n    await updateSyncDataCache(url);\n  }\n  return rules || [];\n};\n"
  },
  {
    "path": "src/libs/svg.js",
    "content": "export const loadingSvg = `<svg viewBox=\"-20 0 100 100\" \n     style=\"display: inline-block; width: 1em; height: 1em; vertical-align: middle;\">\n  <circle fill=\"#209CEE\" stroke=\"none\" cx=\"6\" cy=\"50\" r=\"6\">\n    <animateTransform attributeName=\"transform\" dur=\"1s\" type=\"translate\" values=\"0 15 ; 0 -15; 0 15\" repeatCount=\"indefinite\" begin=\"0.1\"/>\n  </circle>\n  <circle fill=\"#209CEE\" stroke=\"none\" cx=\"30\" cy=\"50\" r=\"6\">\n    <animateTransform attributeName=\"transform\" dur=\"1s\" type=\"translate\" values=\"0 10 ; 0 -10; 0 10\" repeatCount=\"indefinite\" begin=\"0.2\"/>\n  </circle>\n  <circle fill=\"#209CEE\" stroke=\"none\" cx=\"54\" cy=\"50\" r=\"6\">\n    <animateTransform attributeName=\"transform\" dur=\"1s\" type=\"translate\" values=\"0 5 ; 0 -5; 0 5\" repeatCount=\"indefinite\" begin=\"0.3\"/>\n  </circle>\n</svg>\n`;\n\nfunction createSVGElement(tag, attributes) {\n  const svgNS = \"http://www.w3.org/2000/svg\";\n  const el = document.createElementNS(svgNS, tag);\n  for (const key in attributes) {\n    el.setAttribute(key, attributes[key]);\n  }\n  return el;\n}\n\n/**\n * 创建loding动画\n * @returns\n */\nexport function createLoadingSVG() {\n  const svg = createSVGElement(\"svg\", {\n    viewBox: \"-20 0 100 100\",\n    style:\n      \"display: inline-block; width: 1em; height: 1em; vertical-align: middle;\",\n  });\n\n  const circleData = [\n    { cx: \"6\", begin: \"0.1\", values: \"0 15 ; 0 -15; 0 15\" },\n    { cx: \"30\", begin: \"0.2\", values: \"0 10 ; 0 -10; 0 10\" },\n    { cx: \"54\", begin: \"0.3\", values: \"0 5 ; 0 -5; 0 5\" },\n  ];\n\n  circleData.forEach((data) => {\n    const circle = createSVGElement(\"circle\", {\n      fill: \"#209CEE\",\n      stroke: \"none\",\n      cx: data.cx,\n      cy: \"50\",\n      r: \"6\",\n    });\n    const animation = createSVGElement(\"animateTransform\", {\n      attributeName: \"transform\",\n      dur: \"1s\",\n      type: \"translate\",\n      values: data.values,\n      repeatCount: \"indefinite\",\n      begin: data.begin,\n    });\n    circle.appendChild(animation);\n    svg.appendChild(circle);\n  });\n\n  return svg;\n}\n\n/**\n * 创建logo\n * @param {*} param0\n * @returns\n */\nexport function createLogoSVG({\n  width = \"24\",\n  height = \"24\",\n  viewBox = \"-5 -5 40 40\",\n  isSelected = false,\n} = {}) {\n  const svg = createSVGElement(\"svg\", {\n    xmlns: \"http://www.w3.org/2000/svg\",\n    width,\n    height,\n    viewBox,\n    version: \"1.1\",\n  });\n\n  const primaryColor = \"#209CEE\";\n  const secondaryColor = \"#E9F5FD\";\n\n  const path1Fill = isSelected ? secondaryColor : primaryColor;\n  const path2Fill = isSelected ? primaryColor : secondaryColor;\n\n  const path1 = createSVGElement(\"path\", {\n    d: \"M0 0 C10.56 0 21.12 0 32 0 C32 10.56 32 21.12 32 32 C21.44 32 10.88 32 0 32 C0 21.44 0 10.88 0 0 Z \",\n    fill: path1Fill,\n    transform: \"translate(0,0)\",\n  });\n\n  const path2 = createSVGElement(\"path\", {\n    d: \"M0 0 C0.66 0 1.32 0 2 0 C2 2.97 2 5.94 2 9 C2.969375 8.2575 3.93875 7.515 4.9375 6.75 C5.48277344 6.33234375 6.02804688 5.9146875 6.58984375 5.484375 C8.39053593 3.83283924 8.39053593 3.83283924 9 0 C13.95 0 18.9 0 24 0 C24 0.99 24 1.98 24 3 C22.68 3 21.36 3 20 3 C20 9.27 20 15.54 20 22 C19.01 22 18.02 22 17 22 C17 15.73 17 9.46 17 3 C15.35 3 13.7 3 12 3 C11.731875 3.598125 11.46375 4.19625 11.1875 4.8125 C10.01506533 6.97224808 8.80630718 8.35790256 7 10 C8.01790655 12.27071461 8.77442829 13.80784632 10.6875 15.4375 C11.120625 15.953125 11.55375 16.46875 12 17 C11.6875 19.6875 11.6875 19.6875 11 22 C10.34 22 9.68 22 9 22 C8.773125 21.236875 8.54625 20.47375 8.3125 19.6875 C6.73268318 16.45263699 5.16717283 15.58358642 2 14 C2 16.64 2 19.28 2 22 C1.34 22 0.68 22 0 22 C0 14.74 0 7.48 0 0 Z \",\n    fill: path2Fill,\n    transform: \"translate(4,5)\",\n  });\n\n  svg.appendChild(path1);\n  svg.appendChild(path2);\n\n  return svg;\n}\n"
  },
  {
    "path": "src/libs/sync.js",
    "content": "import {\n  APP_LCNAME,\n  KV_SETTING_KEY,\n  KV_RULES_KEY,\n  KV_WORDS_KEY,\n  KV_RULES_SHARE_KEY,\n  KV_SALT_SHARE,\n  OPT_SYNCTYPE_WEBDAV,\n} from \"../config\";\nimport {\n  getSyncWithDefault,\n  putSync,\n  getSettingWithDefault,\n  getRulesWithDefault,\n  getWordsWithDefault,\n  setSetting,\n  setRules,\n  setWords,\n} from \"./storage\";\nimport { apiSyncData } from \"../apis\";\nimport { sha256, removeEndchar } from \"./utils\";\nimport { createClient, getPatcher } from \"webdav\";\nimport { fetchPatcher } from \"./fetch\";\nimport { kissLog } from \"./log\";\n\ngetPatcher().patch(\"request\", (opts) => {\n  return fetchPatcher(opts.url, {\n    method: opts.method,\n    headers: opts.headers,\n    body: opts.data,\n  });\n});\n\nconst syncByWebdav = async (data, { syncUrl, syncUser, syncKey }) => {\n  const client = createClient(syncUrl, {\n    username: syncUser,\n    password: syncKey,\n  });\n  const pathname = `/${APP_LCNAME}`;\n  const filename = `/${APP_LCNAME}/${data.key}`;\n\n  if ((await client.exists(pathname)) === false) {\n    await client.createDirectory(pathname);\n  }\n\n  const isExist = await client.exists(filename);\n  if (isExist) {\n    const cont = await client.getFileContents(filename, { format: \"text\" });\n    const webData = JSON.parse(cont);\n    if (webData.updateAt >= data.updateAt) {\n      return webData;\n    }\n  }\n\n  await client.putFileContents(filename, JSON.stringify(data, null, 2));\n  return data;\n};\n\nconst syncByWorker = async (data, { syncUrl, syncKey }) => {\n  syncUrl = removeEndchar(syncUrl, \"/\");\n  return await apiSyncData(`${syncUrl}/sync`, syncKey, data);\n};\n\nexport const syncData = async (key, value) => {\n  const {\n    syncType,\n    syncUrl,\n    syncUser,\n    syncKey,\n    syncMeta = {},\n  } = await getSyncWithDefault();\n  if (!syncUrl || !syncKey || (syncType === OPT_SYNCTYPE_WEBDAV && !syncUser)) {\n    // throw new Error(\"sync args err\");\n    return;\n  }\n\n  let { updateAt = 0, syncAt = 0 } = syncMeta[key] || {};\n  if (syncAt === 0) {\n    updateAt = 0; // 没有同步过，更新时间置零\n  }\n\n  const data = {\n    key,\n    value: JSON.stringify(value),\n    updateAt,\n  };\n  const args = {\n    syncUrl,\n    syncUser,\n    syncKey,\n  };\n\n  const res =\n    syncType === OPT_SYNCTYPE_WEBDAV\n      ? await syncByWebdav(data, args)\n      : await syncByWorker(data, args);\n\n  if (!res) {\n    throw new Error(\"sync data got err\", key);\n  }\n\n  const newVal = JSON.parse(res.value);\n  const isNew = res.updateAt > updateAt;\n\n  syncMeta[key] = {\n    updateAt: res.updateAt,\n    syncAt: Date.now(),\n  };\n  await putSync({ syncMeta });\n\n  return { value: newVal, isNew };\n};\n\n/**\n * 同步设置\n * @returns\n */\nconst syncSetting = async () => {\n  const value = await getSettingWithDefault();\n  const res = await syncData(KV_SETTING_KEY, value);\n  if (res?.isNew) {\n    await setSetting(res.value);\n  }\n};\n\nexport const trySyncSetting = async () => {\n  try {\n    await syncSetting();\n  } catch (err) {\n    kissLog(\"sync setting\", err.message);\n  }\n};\n\n/**\n * 同步规则\n * @returns\n */\nconst syncRules = async () => {\n  const value = await getRulesWithDefault();\n  const res = await syncData(KV_RULES_KEY, value);\n  if (res?.isNew) {\n    await setRules(res.value);\n  }\n};\n\nexport const trySyncRules = async () => {\n  try {\n    await syncRules();\n  } catch (err) {\n    kissLog(\"sync user rules\", err.message);\n  }\n};\n\n/**\n * 同步词汇\n * @returns\n */\nconst syncWords = async () => {\n  const value = await getWordsWithDefault();\n  const res = await syncData(KV_WORDS_KEY, value);\n  if (res?.isNew) {\n    await setWords(res.value);\n  }\n};\n\nexport const trySyncWords = async () => {\n  try {\n    await syncWords();\n  } catch (err) {\n    kissLog(\"sync fav words\", err.message);\n  }\n};\n\n/**\n * 同步分享规则\n * @param {*} param0\n * @returns\n */\nexport const syncShareRules = async ({ rules, syncUrl, syncKey }) => {\n  const data = {\n    key: KV_RULES_SHARE_KEY,\n    value: JSON.stringify(rules, null, 2),\n    updateAt: Date.now(),\n  };\n  const args = {\n    syncUrl,\n    syncKey,\n  };\n  await syncByWorker(data, args);\n  const psk = await sha256(syncKey, KV_SALT_SHARE);\n  const shareUrl = `${syncUrl}/rules?psk=${psk}`;\n  return shareUrl;\n};\n\n/**\n * 同步个人设置和规则\n * @returns\n */\nexport const syncSettingAndRules = async () => {\n  await syncSetting();\n  await syncRules();\n  await syncWords();\n};\n\nexport const trySyncSettingAndRules = async () => {\n  await trySyncSetting();\n  await trySyncRules();\n  await trySyncWords();\n};\n"
  },
  {
    "path": "src/libs/touch.js",
    "content": "export function touchTapListener(fn, options = {}) {\n  const config = {\n    taps: 2,\n    fingers: 1,\n    delay: 300,\n    ...options,\n  };\n\n  let maxTouches = 0;\n  let tapCount = 0;\n  let tapTimer = null;\n\n  const handleTouchStart = (e) => {\n    maxTouches = Math.max(maxTouches, e.touches.length);\n  };\n\n  const handleTouchend = (e) => {\n    if (e.touches.length === 0) {\n      if (maxTouches === config.fingers) {\n        tapCount++;\n        clearTimeout(tapTimer);\n\n        if (tapCount === config.taps) {\n          fn(e);\n          tapCount = 0;\n        } else {\n          tapTimer = setTimeout(() => {\n            tapCount = 0;\n          }, config.delay);\n        }\n      } else {\n        tapCount = 0;\n        clearTimeout(tapTimer);\n      }\n      maxTouches = 0;\n    }\n  };\n\n  document.addEventListener(\"touchstart\", handleTouchStart, { passive: true });\n  document.addEventListener(\"touchend\", handleTouchend, { passive: true });\n\n  return () => {\n    clearTimeout(tapTimer);\n    document.removeEventListener(\"touchstart\", handleTouchStart);\n    document.removeEventListener(\"touchend\", handleTouchend);\n  };\n}\n"
  },
  {
    "path": "src/libs/tranbox.js",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom/client\";\nimport createCache from \"@emotion/cache\";\nimport { CacheProvider } from \"@emotion/react\";\nimport Slection from \"../views/Selection\";\nimport { DEFAULT_TRANBOX_SETTING, APP_CONSTS } from \"../config\";\n\nexport class TransboxManager {\n  #container = null;\n  #reactRoot = null;\n  #shadowContainer = null;\n  #props = {};\n\n  constructor(initialProps = {}) {\n    this.#props = initialProps;\n\n    const { tranboxSetting = DEFAULT_TRANBOX_SETTING } = this.#props;\n    if (tranboxSetting?.transOpen) {\n      this.enable();\n    }\n  }\n\n  isEnabled() {\n    return !!this.#container && document.body.contains(this.#container);\n  }\n\n  enable() {\n    if (!this.isEnabled()) {\n      this.#container = document.createElement(\"div\");\n      this.#container.id = APP_CONSTS.boxID;\n      this.#container.className = \"notranslate\";\n\n      document.body.appendChild(this.#container);\n      this.#shadowContainer = this.#container.attachShadow({ mode: \"open\" });\n      const shadowRootElement = document.createElement(\"div\");\n      shadowRootElement.className = `${APP_CONSTS.boxID}_wrapper notranslate`;\n      this.#shadowContainer.appendChild(shadowRootElement);\n\n      const cache = createCache({\n        key: APP_CONSTS.boxID,\n        prepend: true,\n        container: this.#shadowContainer,\n      });\n\n      this.#reactRoot = ReactDOM.createRoot(shadowRootElement);\n      this.#reactRoot.render(\n        <React.StrictMode>\n          <CacheProvider value={cache}>\n            <Slection {...this.#props} />\n          </CacheProvider>\n        </React.StrictMode>\n      );\n    }\n  }\n\n  disable() {\n    if (!this.isEnabled() || !this.#reactRoot) {\n      return;\n    }\n    this.#reactRoot.unmount();\n    this.#container.remove();\n    this.#container = null;\n    this.#reactRoot = null;\n    this.#shadowContainer = null;\n  }\n\n  toggle() {\n    if (this.isEnabled()) {\n      this.disable();\n    } else {\n      this.enable();\n    }\n  }\n\n  update(newProps) {\n    this.#props = { ...this.#props, ...newProps };\n    if (this.isEnabled()) {\n      if (!this.#props.tranboxSetting?.transOpen) {\n        this.disable();\n      } else {\n        this.enable();\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/libs/translator.js",
    "content": "import {\n  APP_LCNAME,\n  APP_CONSTS,\n  OPT_STYLE_FUZZY,\n  GLOBLA_RULE,\n  DEFAULT_SETTING,\n  // DEFAULT_MOUSEHOVER_KEY,\n  OPT_STYLE_NONE,\n  DEFAULT_API_SETTING,\n  OPT_HIGHLIGHT_WORDS_BEFORETRANS,\n  OPT_HIGHLIGHT_WORDS_AFTERTRANS,\n  OPT_SPLIT_PARAGRAPH_PUNCTUATION,\n  OPT_SPLIT_PARAGRAPH_DISABLE,\n  OPT_SPLIT_PARAGRAPH_TEXTLENGTH,\n  MSG_INJECT_CSS,\n  MSG_UPDATE_ICON,\n  OPT_LANGS_TO_SPEC,\n} from \"../config\";\nimport { interpreter } from \"./interpreter\";\nimport { clearFetchPool } from \"./pool\";\nimport {\n  debounce,\n  scheduleIdle,\n  genEventName,\n  truncateWords,\n  escapeHTML,\n} from \"./utils\";\nimport { apiTranslate } from \"../apis\";\nimport { kissLog } from \"./log\";\nimport { clearAllBatchQueue } from \"./batchQueue\";\nimport { genTextClass } from \"./style\";\nimport { createLoadingSVG } from \"./svg\";\nimport { shortcutRegister } from \"./shortcut\";\nimport { tryDetectLang } from \"./detect\";\nimport { trustedTypesHelper } from \"./trustedTypes\";\nimport { injectJs, INJECTOR } from \"../injectors\";\nimport { injectInternalCss } from \"./injector\";\nimport { isExt } from \"./client\";\nimport { sendBgMsg } from \"./msg\";\nimport { getDocInfo } from \"./docInfo\";\n\n/**\n * @class Translator\n * @description 翻译核心逻辑封装\n */\nexport class Translator {\n  static displayCache = new WeakMap();\n  static TAGS = {\n    BREAK_LINE: new Set([\"BR\", \"WBR\"]),\n    BLOCK: new Set([\n      \"ADDRESS\",\n      \"ARTICLE\",\n      \"ASIDE\",\n      \"BLOCKQUOTE\",\n      \"CANVAS\",\n      \"DD\",\n      \"DIV\",\n      \"DL\",\n      \"DT\",\n      \"FIELDSET\",\n      \"FIGCAPTION\",\n      \"FIGURE\",\n      \"FOOTER\",\n      \"FORM\",\n      \"H1\",\n      \"H2\",\n      \"H3\",\n      \"H4\",\n      \"H5\",\n      \"H6\",\n      \"HEADER\",\n      \"HR\",\n      \"LI\",\n      \"MAIN\",\n      \"NAV\",\n      \"NOSCRIPT\",\n      \"OL\",\n      \"P\",\n      \"PRE\",\n      \"SECTION\",\n      \"TABLE\",\n      \"TFOOT\",\n      \"UL\",\n      \"VIDEO\",\n    ]),\n    INLINE: new Set([\n      // \"A\",\n      \"ABBR\",\n      \"ACRONYM\",\n      \"B\",\n      \"BDO\",\n      \"BIG\",\n      \"BR\",\n      \"BUTTON\",\n      \"CITE\",\n      \"CODE\",\n      \"DFN\",\n      \"DEL\",\n      \"FONT\",\n      \"EM\",\n      \"I\",\n      \"IMG\",\n      \"INPUT\",\n      \"INS\",\n      \"KBD\",\n      \"LABEL\",\n      \"MAP\",\n      \"MARK\",\n      \"OBJECT\",\n      \"OUTPUT\",\n      \"Q\",\n      \"RUBY\",\n      \"SAMP\",\n      \"SCRIPT\",\n      \"SELECT\",\n      \"SMALL\",\n      // \"SPAN\",\n      \"STRONG\",\n      \"SUB\",\n      \"SUP\",\n      \"TEXTAREA\",\n      \"TIME\",\n      \"TT\",\n      \"U\",\n      \"VAR\",\n    ]),\n    REPLACE: new Set([\n      \"ABBR\",\n      \"CODE\",\n      \"DFN\",\n      \"IMG\",\n      \"KBD\",\n      \"OUTPUT\",\n      \"RP\",\n      \"RT\",\n      \"SAMP\",\n      \"SUB\",\n      \"SUP\",\n      \"SVG\",\n      \"TIME\",\n      \"VAR\",\n    ]),\n    WARP: new Set([\n      \"A\",\n      \"B\",\n      \"BDO\",\n      \"BDI\",\n      \"BIG\",\n      \"CITE\",\n      \"DEL\",\n      \"EM\",\n      \"FONT\",\n      \"I\",\n      \"INS\",\n      \"MARK\",\n      \"Q\",\n      \"RUBY\",\n      \"S\",\n      \"SMALL\",\n      \"SPAN\",\n      \"STRONG\",\n      \"U\",\n    ]),\n  };\n\n  // 译文相关class\n  static KISS_CLASS = {\n    warpper: `${APP_LCNAME}-wrapper`,\n    inner: `${APP_LCNAME}-inner`,\n    term: `${APP_LCNAME}-term`,\n    br: `${APP_LCNAME}-br`,\n    highlight: `${APP_LCNAME}-highlight`,\n  };\n\n  // 内置跳过翻译文本\n  // todo: 验证有效性\n  static BUILTIN_SKIP_PATTERNS = [\n    // 1. URL (覆盖 http, https, ftp, file 协议)\n    /^(?:(?:https?|ftp|file):\\/\\/|www\\.)[^\\s/$.?#].[^\\s]*$/i,\n\n    // 2. 邮箱地址\n    /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$/,\n\n    // 3. 文件路径 (为 Unix 和 Windows 做了简化)\n    /^(?:[a-zA-Z]:\\\\|\\/|\\\\)(?:[\\w\\-. ]+\\/|[\\w\\-. ]+\\\\)*[\\w\\-. ]*\\.?[\\w\\-. ]*$/,\n\n    // 4. UUID (通用唯一标识符)\n    /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/,\n\n    // 5. 纯数字字符串 (整数, 浮点数, 包含常见分隔符)\n    // 同时也处理单位 (如 px, %, em, rem 等) 和货币符号。\n    /^[$\\u00A2-\\u00A5\\u20A0-\\u20CF]?\\s?-?\\d{1,3}(?:[.,]\\d{3})*(?:[.,]\\d+)?\\s?(?:px|%|em|rem|pt|vw|vh|deg|s|ms)?$/,\n\n    // 6. 版本号 (例如 v1.2.3, 10.0.1)\n    /^v?\\d+(\\.\\d+){1,3}$/,\n\n    // 7. ISO 8601 日期/时间格式\n    /^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})?)?$/,\n\n    // 8. 模板占位符 (例如 {{var}}, ${var}, __VAR__)\n    /^({{[^}]+}}|\\${[^}]+}|__\\w+__|%\\w+)$/,\n\n    // 9. CSS 选择器 (简单的 class/ID) 和十六进制颜色值\n    /^(?:\\.|#)[\\w-]+$|^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,\n\n    // 10. 用户名 (例如 @username, @user.name, @user-name) - [已修改]\n    /^@[\\w.-]+$/,\n\n    // 11. HTML 实体\n    /^&\\w+;$/,\n\n    // 12. 中括号包裹的序号 (例如 [1], [99])\n    /^\\[\\d+\\]$/,\n\n    // 13. 简单时间格式 (例如 12:30, 9:45:30) - [新增]\n    /^\\d{1,2}:\\d{2}(:\\d{2})?$/,\n\n    // 14. 包含常见扩展名的文件名 (例如: document.pdf, image.jpeg)\n    /^[^\\s\\\\/:]+?\\.[a-zA-Z0-9]{2,5}$/,\n\n    // todo: 数字和特殊字符组成的字符串\n  ];\n\n  static DEFAULT_OPTIONS = DEFAULT_SETTING; // 默认配置\n  static DEFAULT_RULE = GLOBLA_RULE; // 默认规则\n\n  static isElement(el) {\n    return el instanceof Element;\n  }\n\n  static isElementOrFragment(el) {\n    return el instanceof Element || el instanceof DocumentFragment;\n  }\n\n  // 判断是否块级元素\n  static isBlockNode(el) {\n    if (!Translator.isElementOrFragment(el)) return false;\n\n    if (el.attributes?.display?.value?.includes(\"inline\")) return false;\n    if (Translator.TAGS.INLINE.has(el.nodeName?.toUpperCase())) return false;\n    if (Translator.TAGS.BLOCK.has(el.nodeName?.toUpperCase())) return true;\n\n    if (Translator.displayCache.has(el)) {\n      return Translator.displayCache.get(el);\n    }\n\n    const isBlock = !window.getComputedStyle(el).display.startsWith(\"inline\");\n    Translator.displayCache.set(el, isBlock);\n    return isBlock;\n  }\n\n  // 判断是否包含块级子元素\n  static hasBlockNode(el) {\n    if (!Translator.isElementOrFragment(el)) return false;\n    for (const child of el.childNodes) {\n      if (Translator.isBlockNode(child)) {\n        return true;\n      }\n    }\n    return false;\n  }\n\n  // 判断是否直接包含非空文本节点\n  static hasTextNode(el) {\n    if (!Translator.isElementOrFragment(el)) return false;\n    for (const child of el.childNodes) {\n      if (child.nodeType === Node.TEXT_NODE && /\\S/.test(child.nodeValue)) {\n        return true;\n      }\n    }\n    return false;\n  }\n\n  // 特殊字符转义\n  static escapeRegex(str) {\n    return str.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n  }\n\n  // 内置忽略元素\n  static KISS_IGNORE_SELECTOR = `.${Translator.KISS_CLASS.warpper}, .kiss-caption-container, .kiss-subtitle-controls, #kiss-youtube-subtitle-list-container,\n  #${APP_CONSTS.fabID}, .${APP_CONSTS.fabID}_warpper,\n  #${APP_CONSTS.boxID}, .${APP_CONSTS.boxID}_warpper,\n  #${APP_CONSTS.popupID}, .${APP_CONSTS.popupID}_warpper`;\n\n  static BUILTIN_IGNORE_SELECTOR = `address, area, audio, br, canvas, \n  data, datalist, embed, head, iframe, input, noscript, map, \n  object, option, param, picture, progress, \n  select, script, style, track, textarea, template, \n  video, wbr, .notranslate, [contenteditable='true'], [translate='no']`;\n\n  #setting; // 设置选项\n  #rule; // 规则\n  #isInitialized = false; // 初始化状态\n  #isJsInjected = false; // 注入用户JS\n  #isShadowRootJsInjected = false; //\n  #mouseHoverEnabled = false; // 鼠标悬停翻译\n  #enabled = false; // 全局默认状态\n  #runId = 0; // 用于中止过期的异步请求\n  #termValues = []; // 按顺序存储术语的替换值\n  #combinedTermsRegex; // 专业术语正则表达式\n  #combinedSkipsRegex; // 跳过文本正则表达式\n\n  #placeholderCache = null; // 缓存正则对象\n  #translationTagName = APP_LCNAME; // 翻译容器的标签名\n  #eventName = \"\"; // 通信事件名称\n  #docInfo = {}; // 网页信息\n  #glossary = {}; // AI词典\n  #textClass = {}; // 译文样式class\n  #textSheet = \"\"; // 译文样式字典\n  #apisMap = new Map(); // 用于接口快速查找\n  #favWords = []; // 收藏词汇\n\n  #observedNodes = new WeakSet(); // 存储所有被识别出的、可翻译的 DOM 节点单元\n  #translationNodes = new WeakMap(); // 存储所有插入到页面的译文节点\n  #viewNodes = new Set(); // 当前在可视范围内的单元\n  #processedNodes = new WeakMap(); // 已处理（已执行翻译DOM操作）的单元\n  #rootNodes = new Set(); // 已监控的根节点\n  #skipMoNodes = new WeakSet(); // 忽略变化的节点\n\n  #removeKeydownHandler; // 快捷键清理函数\n  #hoveredNode = null; // 存储当前悬停的可翻译节点\n  #boundMouseMoveHandler; // 鼠标事件\n  #boundKeyDownHandler; // 键盘事件\n  #windowMessageHandler = null;\n\n  #debouncedFindShadowRoot = null;\n\n  #io; // IntersectionObserver\n  #mo; // MutationObserver\n  #dmm; // DebounceMouseMover\n\n  #rescanQueue = new Set(); // “脏容器”队列\n  #isQueueProcessing = false; // 队列处理状态标志\n\n  // 忽略元素\n  get #ignoreSelector() {\n    if (this.#rule.scanAll === \"true\" || this.#rule.isPlainText) {\n      return Translator.KISS_IGNORE_SELECTOR;\n    }\n\n    const selectors = [Translator.KISS_IGNORE_SELECTOR];\n    if (this.#rule.autoScan !== \"false\") {\n      selectors.push(Translator.BUILTIN_IGNORE_SELECTOR);\n    }\n\n    const userSelector = this.#rule.ignoreSelector?.trim();\n    if (userSelector) {\n      selectors.push(userSelector);\n    }\n\n    return selectors.join(\", \");\n  }\n\n  // 接口参数\n  // todo: 不用频繁查找计算\n  get #apiSetting() {\n    // return (\n    //   this.#setting.transApis.find(\n    //     (api) => api.apiSlug === this.#rule.apiSlug\n    //   ) || DEFAULT_API_SETTING\n    // );\n    return this.#apisMap.get(this.#rule.apiSlug) || DEFAULT_API_SETTING;\n  }\n\n  // 占位符配置（包含正则）\n  get #placeholderConfig() {\n    if (this.#placeholderCache) {\n      return this.#placeholderCache;\n    }\n\n    const [startDelimiter, endDelimiter] =\n      this.#apiSetting.placeholder.split(\" \");\n\n    // 确保 placetag 始终是字符串（兼容旧配置可能是数组）\n    let tagName = this.#apiSetting.placetag;\n    if (Array.isArray(tagName)) {\n      tagName = tagName[0] || \"i\";\n    }\n    if (typeof tagName !== \"string\") {\n      tagName = \"i\"; // 默认值\n    }\n\n    const format = this.#apiSetting.placetagFormat || \"compact\"; // 占位符格式\n    const safeTag = \"span\";\n\n    // 1. 缓存常用还原正则\n    let openRegex, closeRegex;\n    if (format === \"attribute\") {\n      openRegex = new RegExp(`<${tagName}\\\\s+i=(\\\\d+)>`, \"gi\");\n      closeRegex = new RegExp(`<\\\\/${tagName}>`, \"gi\");\n    } else {\n      openRegex = new RegExp(`<${tagName}(\\\\d+)>`, \"gi\");\n      closeRegex = new RegExp(`<\\\\/${tagName}(\\\\d+)>`, \"gi\");\n    }\n\n    // 2. 创建普通占位符正则（标签占位符在restoreFromTranslation中单独处理）\n    // 只匹配普通占位符 {{1}}, {{2}} 等\n    const escapedStart = Translator.escapeRegex(startDelimiter);\n    const escapedEnd = Translator.escapeRegex(endDelimiter);\n    const placeholderPattern = `${escapedStart}\\\\d+${escapedEnd}`;\n    const placeholderRegex = new RegExp(placeholderPattern, \"g\");\n\n    const result = {\n      startDelimiter,\n      endDelimiter,\n      tagName,\n      format,\n      safeTag,\n      openRegex,\n      closeRegex,\n      placeholderRegex,\n    };\n\n    this.#placeholderCache = result;\n    return result;\n  }\n\n  constructor({ rule = {}, setting = {}, favWords = [] }) {\n    this.#setting = { ...Translator.DEFAULT_OPTIONS, ...setting };\n    this.#rule = { ...Translator.DEFAULT_RULE, ...rule, isPlainText: false };\n    this.#favWords = favWords;\n    this.#apisMap = new Map(\n      this.#setting.transApis.map((api) => [api.apiSlug, api])\n    );\n\n    this.#eventName = genEventName();\n    this.#combinedSkipsRegex = new RegExp(\n      Translator.BUILTIN_SKIP_PATTERNS.map((r) => `(${r.source})`).join(\"|\")\n    );\n\n    this.#parseTerms(this.#rule.terms);\n    this.#parseAITerms(this.#rule.aiTerms);\n    this.#createTextStyles();\n\n    this.#boundMouseMoveHandler = this.#handleMouseMove.bind(this);\n    this.#boundKeyDownHandler = this.#handleKeyDown.bind(this);\n\n    this.#io = this.#createIntersectionObserver();\n    this.#mo = this.#createMutationObserver();\n    this.#dmm = this.#createDebounceMouseMover();\n\n    this.#windowMessageHandler = this.#handleWindowMessage.bind(this);\n    this.#debouncedFindShadowRoot = debounce(\n      this.#findAndObserveShadowRoot.bind(this),\n      300\n    );\n\n    // 鼠标悬停翻译\n    if (this.#setting.mouseHoverSetting.useMouseHover) {\n      this.#enableMouseHover();\n    }\n\n    if (document.readyState === \"loading\") {\n      document.addEventListener(\"DOMContentLoaded\", () => this.#run());\n    } else {\n      this.#run();\n    }\n  }\n\n  // 启动\n  #run() {\n    if (this.#rule.transOpen === \"true\") {\n      this.enable();\n    } else if (this.#setting.preInit) {\n      this.#init();\n    }\n  }\n\n  // 初始化\n  #init() {\n    this.#isInitialized = true;\n\n    // 注入JS/CSS\n    this.#initInjector();\n\n    // 纯文本预处理\n    if (this.#rule.isPlainText) {\n      document\n        .querySelectorAll(\"pre\")\n        .forEach(\n          (pre) =>\n            (pre.innerHTML = pre.innerHTML?.replace(\n              /(?:\\r\\n|\\r|\\n)/g,\n              \"<br />\"\n            ))\n        );\n    }\n\n    // 查找根节点并扫描\n    document\n      .querySelectorAll(this.#rule.rootsSelector || \"body\")\n      .forEach((root) => {\n        this.#startObserveRoot(root);\n      });\n\n    if (this.#rule.scanAll === \"true\" || this.#rule.hasShadowroot === \"true\") {\n      this.#attachShadowRootListener();\n      this.#findAndObserveShadowRoot();\n    }\n  }\n\n  #handleWindowMessage(event) {\n    if (event.data?.type === \"KISS_SHADOW_ROOT_CREATED\") {\n      this.#debouncedFindShadowRoot();\n    }\n  }\n\n  #attachShadowRootListener() {\n    if (!this.#isShadowRootJsInjected) {\n      const id = \"kiss-translator-inject-shadowroot-js\";\n      injectJs(INJECTOR.shadowroot, id);\n\n      this.#isShadowRootJsInjected = true;\n    }\n\n    window.addEventListener(\"message\", this.#windowMessageHandler);\n  }\n\n  #removeShadowRootListener() {\n    window.removeEventListener(\"message\", this.#windowMessageHandler);\n  }\n\n  // 查找现有的所有shadowroot\n  #findAndObserveShadowRoot() {\n    try {\n      this.#findAllShadowRoots().forEach((shadowRoot) => {\n        this.#startObserveShadowRoot(shadowRoot);\n      });\n    } catch (err) {\n      kissLog(\"findAllShadowRoots\", err);\n    }\n  }\n\n  // 创建样式\n  #createTextStyles() {\n    const [textClass, textStyles] = genTextClass(this.#setting.customStyles);\n    const textSheet = new CSSStyleSheet();\n    textSheet.replaceSync(textStyles);\n    this.#textClass = textClass;\n    this.#textSheet = textSheet;\n  }\n\n  // 注入样式\n  #injectSheet(shadowRoot) {\n    if (!shadowRoot.adoptedStyleSheets.includes(this.#textSheet)) {\n      shadowRoot.adoptedStyleSheets = [\n        ...shadowRoot.adoptedStyleSheets,\n        this.#textSheet,\n      ];\n    }\n  }\n\n  // 解析专业术语字符串\n  #parseTerms(termsString) {\n    this.#termValues = [];\n    this.#combinedTermsRegex = null;\n\n    if (!termsString || typeof termsString !== \"string\") return;\n\n    const termPatterns = [];\n    const lines = termsString.split(/\\n|;/); // 按换行或分号分割\n\n    for (const line of lines) {\n      const trimmedLine = line.trim();\n      if (!trimmedLine) continue;\n\n      let lastCommaIndex = trimmedLine.lastIndexOf(\",\");\n      if (lastCommaIndex === -1) {\n        lastCommaIndex = trimmedLine.length;\n      }\n      const key = trimmedLine.substring(0, lastCommaIndex).trim();\n      const value = trimmedLine.substring(lastCommaIndex + 1).trim();\n\n      if (key) {\n        try {\n          new RegExp(key);\n          termPatterns.push(`(${key})`);\n          this.#termValues.push(value);\n        } catch (err) {\n          kissLog(`Invalid RegExp for term: \"${key}\"`, err);\n        }\n      }\n    }\n\n    if (termPatterns.length > 0) {\n      this.#combinedTermsRegex = new RegExp(termPatterns.join(\"|\"), \"g\");\n    }\n  }\n\n  #parseAITerms(termsString) {\n    if (!termsString || typeof termsString !== \"string\") return;\n\n    try {\n      this.#glossary = Object.fromEntries(\n        termsString\n          .split(/\\n|;/)\n          .map((line) => {\n            const [k = \"\", v = \"\"] = line.split(\",\").map((s) => s.trim());\n            return [k, v];\n          })\n          .filter(([k]) => k)\n      );\n    } catch (err) {\n      kissLog(\"parse aiterms\", err);\n    }\n  }\n\n  // todo: 利用AI总结\n  #getDocDescription() {\n    try {\n      const meta = document.querySelector('meta[name=\"description\"]');\n      const description = meta?.getAttribute(\"content\") || \"\";\n      return truncateWords(description);\n    } catch (err) {\n      kissLog(\"get description\", err);\n    }\n    return \"\";\n  }\n\n  // 监控翻译单元的可见性\n  #createIntersectionObserver() {\n    const { transInterval, rootMargin = 500 } = this.#setting;\n\n    const pending = new Set();\n    const flush = debounce(() => {\n      pending.forEach((node) => this.#performSyncNode(node));\n      pending.clear();\n    }, transInterval);\n\n    return new IntersectionObserver(\n      (entries) => {\n        entries.forEach((entry) => {\n          if (entry.isIntersecting) {\n            this.#viewNodes.add(entry.target);\n            pending.add(entry.target);\n            flush();\n          } else {\n            this.#viewNodes.delete(entry.target);\n          }\n        });\n      },\n      { threshold: 0.01, rootMargin: `${rootMargin}px 0px ${rootMargin}px 0px` }\n    );\n  }\n\n  // 监控页面动态变化\n  #createMutationObserver() {\n    return new MutationObserver((mutations) => {\n      for (const mutation of mutations) {\n        if (\n          this.#skipMoNodes.has(mutation.target) ||\n          mutation.nextSibling?.tagName?.toLowerCase() ===\n            this.#translationTagName\n        ) {\n          continue;\n        }\n\n        if (mutation.type === \"characterData\") {\n          if (\n            mutation.oldValue !== mutation.target.nodeValue &&\n            !this.#combinedSkipsRegex.test(mutation.target.nodeValue)\n          ) {\n            this.#queueForRescan(mutation.target.parentElement);\n          }\n        } else if (mutation.type === \"childList\") {\n          let nodes = new Set();\n          let hasText = false;\n          mutation.addedNodes.forEach((node) => {\n            if (\n              this.#skipMoNodes.has(node) ||\n              node.nodeName?.toLowerCase() === this.#translationTagName\n            ) {\n              return;\n            }\n\n            if (node.nodeType === Node.TEXT_NODE) {\n              hasText = true;\n            } else if (Translator.isElementOrFragment(node)) {\n              nodes.add(node);\n            }\n          });\n          if (hasText) {\n            this.#queueForRescan(mutation.target);\n          } else {\n            nodes.forEach((node) => this.#queueForRescan(node));\n          }\n        }\n      }\n    });\n  }\n\n  // 节流的鼠标悬停事件\n  #createDebounceMouseMover() {\n    return debounce((targetNode) => {\n      const startNode = targetNode;\n      let foundNode = null;\n      while (targetNode && targetNode !== document.body) {\n        if (this.#observedNodes.has(targetNode)) {\n          foundNode = targetNode;\n          break;\n        }\n        targetNode = targetNode.parentElement;\n      }\n      this.#hoveredNode = foundNode || startNode;\n\n      const { mouseHoverKey } = this.#setting.mouseHoverSetting;\n      if (mouseHoverKey.length === 0 && !this.#isInitialized) {\n        this.#init();\n      }\n      if (mouseHoverKey.length === 0 && foundNode) {\n        this.#toggleTargetNode(foundNode);\n      }\n    }, 100);\n  }\n\n  // 跟踪鼠标下的可翻译节点\n  #handleMouseMove(event) {\n    let targetNode = event.composedPath()[0];\n    this.#dmm(targetNode);\n  }\n\n  // 快捷键按下时的处理器\n  #handleKeyDown() {\n    if (!this.#isInitialized) {\n      this.#init();\n    }\n    let targetNode = this.#hoveredNode;\n    if (!targetNode || !this.#observedNodes.has(targetNode)) return;\n\n    this.#toggleTargetNode(targetNode);\n  }\n\n  // 触发段落翻译\n  toggleHoverNode() {\n    this.#handleKeyDown();\n  }\n\n  // 切换节点翻译状态\n  #toggleTargetNode(targetNode) {\n    if (this.#processedNodes.has(targetNode)) {\n      this.#cleanupDirectTranslations(targetNode);\n    } else {\n      this.#processNode(targetNode);\n    }\n  }\n\n  // 获取元素的 shadowRoot（支持 closed 模式）\n  #getShadowRoot(element) {\n    // Firefox 原生支持\n    if (element.openOrClosedShadowRoot) {\n      return element.openOrClosedShadowRoot;\n    }\n    // Chrome 扩展 API\n    if (\n      typeof globalThis !== \"undefined\" &&\n      globalThis.chrome?.dom?.openOrClosedShadowRoot\n    ) {\n      return globalThis.chrome.dom.openOrClosedShadowRoot(element);\n    }\n    // 标准 API（只能获取 open 模式）\n    return element.shadowRoot;\n  }\n\n  // 找页面所有 ShadowRoot\n  #findAllShadowRoots(root = document.body, results = new Set()) {\n    // const start = performance.now();\n    try {\n      const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);\n      while (walker.nextNode()) {\n        const node = walker.currentNode;\n        const shadowRoot = this.#getShadowRoot(node);\n        if (shadowRoot) {\n          results.add(shadowRoot);\n          this.#findAllShadowRoots(shadowRoot, results);\n        }\n      }\n    } catch (err) {\n      kissLog(\"无法访问某个 shadowRoot\", err);\n    }\n    // const end = performance.now();\n    // const duration = end - start;\n    // console.log(`findAllShadowRoots 耗时：${duration} 毫秒`);\n    return results;\n  }\n\n  // 向上查找发生变化的块级元素\n  #findChangeContainer(startNode) {\n    if (\n      !Translator.isElementOrFragment(startNode) ||\n      startNode.closest?.(this.#ignoreSelector)\n    ) {\n      return null;\n    }\n\n    let current = startNode;\n    while (current && current !== document.body) {\n      if (Translator.isBlockNode(current) || this.#observedNodes.has(current)) {\n        // 确保找到的容器在我们监控的根节点内\n        for (const root of this.#rootNodes) {\n          if (root.contains(current)) {\n            return current;\n          }\n        }\n      }\n      current = current.parentElement;\n    }\n\n    return null;\n  }\n\n  // “脏容器”队列\n  #queueForRescan(target) {\n    this.#rescanQueue.add(target);\n    if (!this.#isQueueProcessing) {\n      this.#isQueueProcessing = true;\n      scheduleIdle(() => {\n        this.#rescanQueue.forEach((t) => this.#rescanContainer(t));\n        this.#rescanQueue.clear();\n        this.#isQueueProcessing = false;\n      }, 100);\n    }\n  }\n\n  // 处理“脏容器”\n  #rescanContainer(changedNode) {\n    const container = this.#findChangeContainer(changedNode);\n    if (!container) return;\n\n    this.#cleanupAllTranslations(container);\n    this.#scanNode(container);\n  }\n\n  // 重新观察\n  #reIO(node) {\n    this.#io.unobserve(node);\n    this.#io.observe(node);\n  }\n\n  // 重新观察可视范围内全部节点\n  #reIOViewNodes() {\n    this.#viewNodes.forEach((n) => this.#reIO(n));\n  }\n\n  // 监控shadowroot\n  #startObserveShadowRoot(shadowRoot) {\n    if (shadowRoot.host.matches(`#${APP_CONSTS.fabID}, #${APP_CONSTS.boxID}`)) {\n      return;\n    }\n    this.#startObserveRoot(shadowRoot);\n    this.#injectSheet(shadowRoot);\n  }\n\n  // 监控根节点\n  #startObserveRoot(root) {\n    if (this.#rootNodes.has(root)) return;\n    this.#rootNodes.add(root);\n    this.#mo.observe(root, {\n      childList: true,\n      subtree: true,\n      characterData: true,\n      characterDataOldValue: true,\n    });\n    this.#scanNode(root);\n  }\n\n  // 开始/重新监控节点\n  #startObserveNode(node) {\n    // todo: DocumentFragment 无法被 this.#io.observe\n    if (!Translator.isElement(node)) return;\n\n    if (this.#rule.highlightWords === OPT_HIGHLIGHT_WORDS_BEFORETRANS) {\n      this.#highlightWordsDeeply(node);\n    }\n\n    if (\n      !this.#observedNodes.has(node) &&\n      this.#enabled &&\n      this.#setting.transAllnow\n    ) {\n      this.#observedNodes.add(node);\n      this.#processNode(node);\n      return;\n    }\n\n    // 未监控\n    if (!this.#observedNodes.has(node)) {\n      this.#observedNodes.add(node);\n      this.#io.observe(node);\n      return;\n    }\n\n    // 已监控，但未处理状态，且在可视范围\n    if (!this.#processedNodes.has(node) && this.#viewNodes.has(node)) {\n      this.#reIO(node);\n    }\n  }\n\n  // 非自动识别文本模式下，快速查询目标节点\n  #queryNode(rootNode) {\n    // root 也可能是目标节点\n    if (rootNode.matches?.(this.#rule.selector)) {\n      this.#startObserveNode(rootNode);\n    }\n\n    rootNode.querySelectorAll(this.#rule.selector).forEach((node) => {\n      if (!node.closest?.(this.#ignoreSelector)) {\n        this.#startObserveNode(node);\n      }\n    });\n  }\n\n  // 寻找需要被监控的文本节点\n  #scanNode(rootNode) {\n    if (\n      !Translator.isElementOrFragment(rootNode) ||\n      // rootNode.matches?.(this.#rule.keepSelector) ||\n      rootNode.matches?.(this.#ignoreSelector)\n    ) {\n      return;\n    }\n\n    if (this.#rule.autoScan === \"false\") {\n      this.#queryNode(rootNode);\n      return;\n    }\n\n    const hasText = Translator.hasTextNode(rootNode);\n\n    if (!hasText && rootNode.children.length === 1) {\n      this.#scanNode(rootNode.children[0]);\n      return;\n    }\n\n    const hasBlock = Translator.hasBlockNode(rootNode);\n\n    if (hasText || !hasBlock) {\n      this.#startObserveNode(rootNode);\n    }\n\n    if (hasBlock) {\n      for (const child of rootNode.children) {\n        const isBlock = Translator.isBlockNode(child);\n        if (!hasText || isBlock) {\n          this.#scanNode(child);\n        }\n      }\n    }\n  }\n\n  // 处理一个待翻译的节点\n  async #processNode(node) {\n    if (\n      this.#processedNodes.has(node) ||\n      !Translator.isElementOrFragment(node)\n    ) {\n      return;\n    }\n\n    this.#processedNodes.set(node, { ...this.#rule });\n\n    // 提前检测文本\n    if (this.#isInvalidText(node.textContent)) {\n      return;\n    }\n\n    // 提前进行语言检测\n    let deLang = \"\";\n    const {\n      fromLang = \"auto\",\n      toLang,\n      splitParagraph = OPT_SPLIT_PARAGRAPH_DISABLE,\n      splitLength = 100,\n    } = this.#rule;\n    const { langDetector, skipLangs = [] } = this.#setting;\n    if (fromLang === \"auto\") {\n      // 与 #translateFetch 使用同一翻译服务，均来自 this.#apiSetting（rule.apiSlug + apisMap）\n      const apiType = this.#apiSetting?.apiType;\n      const langMap = apiType ? OPT_LANGS_TO_SPEC[apiType] : null;\n      const apiSupportsAutoDetect = langMap.get(\"auto\");\n\n      // 还是用检测下  google de auto当翻译zh 到葡萄牙语时有问题\n      deLang =\n        (await tryDetectLang(node.textContent, langDetector)) ||\n        apiSupportsAutoDetect;\n      if (\n        deLang &&\n        (toLang.slice(0, 2) === deLang.slice(0, 2) ||\n          skipLangs.includes(deLang))\n      ) {\n        // 保留处理状态，不做删除\n        // this.#processedNodes.delete(node);\n        return;\n      }\n    }\n\n    // 切分长段落\n    if (splitParagraph !== OPT_SPLIT_PARAGRAPH_DISABLE) {\n      this.#splitTextNodesBySentence(node, splitParagraph, splitLength);\n    }\n\n    let nodeGroup = [];\n    [...node.childNodes].forEach((child) => {\n      const shouldBreak = this.#shouldBreak(child);\n      const shouldGroup =\n        child.nodeType === Node.ELEMENT_NODE ||\n        child.nodeType === Node.TEXT_NODE;\n      if (!shouldBreak && shouldGroup) {\n        nodeGroup.push(child);\n      } else if (shouldBreak && nodeGroup.length) {\n        this.#translateNodeGroup(nodeGroup, node, deLang);\n        nodeGroup = [];\n      }\n    });\n\n    if (nodeGroup.length) {\n      this.#translateNodeGroup(nodeGroup, node, deLang);\n    }\n  }\n\n  // 高亮词汇\n  #highlightTextNode(textNode, wordRegex) {\n    if (textNode.parentNode?.nodeName.toLowerCase() === \"b\") {\n      return;\n    }\n\n    if (!wordRegex.test(textNode.textContent)) {\n      return;\n    }\n\n    wordRegex.lastIndex = 0;\n    const fragments = textNode.textContent.split(wordRegex);\n    const newNodes = [];\n\n    fragments.forEach((fragment, i) => {\n      if (!fragment) return;\n\n      if (i % 2 === 1) {\n        // 奇数索引是匹配到的关键词\n        const bTag = document.createElement(\"b\");\n        bTag.className = Translator.KISS_CLASS.highlight;\n        bTag.style.cssText = this.#rule.highlightStyle || \"\";\n        bTag.textContent = fragment;\n        this.#skipMoNodes.add(bTag);\n        newNodes.push(bTag);\n      } else {\n        // 偶数索引是普通文本\n        const newTextNode = document.createTextNode(fragment);\n        this.#skipMoNodes.add(newTextNode);\n        newNodes.push(newTextNode);\n      }\n    });\n\n    if (newNodes.length > 0) {\n      textNode.replaceWith(...newNodes);\n    }\n  }\n\n  // 高亮词汇\n  #highlightWordsDeeply(parentNode) {\n    if (!parentNode || this.#favWords.length === 0) {\n      return;\n    }\n\n    const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n    const escapedWords = this.#favWords.map(escapeRegex);\n    const wordRegex = new RegExp(`\\\\b(${escapedWords.join(\"|\")})\\\\b`, \"gi\");\n\n    if (parentNode.nodeType === Node.ELEMENT_NODE) {\n      const walker = document.createTreeWalker(\n        parentNode,\n        NodeFilter.SHOW_TEXT,\n        null,\n        false\n      );\n\n      const nodesToProcess = [];\n      let node;\n      while ((node = walker.nextNode())) {\n        nodesToProcess.push(node);\n      }\n\n      nodesToProcess.forEach((textNode) => {\n        this.#highlightTextNode(textNode, wordRegex);\n      });\n    } else if (parentNode.nodeType === Node.TEXT_NODE) {\n      this.#highlightTextNode(parentNode, wordRegex);\n    }\n  }\n\n  // 切分文本段落\n  #splitTextNodesBySentence(parentNode, splitParagraph, splitLength) {\n    const sentenceEndRegexForSplit = /[。！？]+|[.?!]+(?=\\s+|$)/g;\n\n    [...parentNode.childNodes].forEach((node) => {\n      if (node.nodeType !== Node.TEXT_NODE || node.textContent.trim() === \"\") {\n        return;\n      }\n\n      const text = node.textContent;\n      const parts = [];\n      let lastIndex = 0;\n      let match;\n\n      while ((match = sentenceEndRegexForSplit.exec(text)) !== null) {\n        let realEndIndex = match.index + match[0].length;\n        while (realEndIndex < text.length && /\\s/.test(text[realEndIndex])) {\n          realEndIndex++;\n        }\n        parts.push(text.substring(lastIndex, realEndIndex));\n        lastIndex = realEndIndex;\n        sentenceEndRegexForSplit.lastIndex = realEndIndex;\n      }\n      if (lastIndex < text.length) {\n        parts.push(text.substring(lastIndex));\n      }\n\n      const validParts = parts.filter((part) => part.trim().length > 0);\n      if (validParts.length <= 1) {\n        return;\n      }\n\n      const newNodes = validParts.map((part) => {\n        const newNode = document.createTextNode(part);\n        this.#skipMoNodes.add(newNode);\n        return newNode;\n      });\n\n      node.replaceWith(...newNodes);\n    });\n\n    const sentenceEndRegexForTest = /(?:[。！？?!]+|(?<!\\d)\\.)\\s*$/;\n    let textLength = 0;\n\n    [...parentNode.childNodes].forEach((node) => {\n      textLength += node.textContent.length;\n\n      const isSentenceEnd = sentenceEndRegexForTest.test(node.textContent);\n      if (\n        !isSentenceEnd ||\n        node.nextSibling?.nodeName?.toUpperCase() === \"BR\"\n      ) {\n        return;\n      }\n\n      if (\n        splitParagraph === OPT_SPLIT_PARAGRAPH_PUNCTUATION ||\n        (splitParagraph === OPT_SPLIT_PARAGRAPH_TEXTLENGTH &&\n          textLength >= splitLength)\n      ) {\n        textLength = 0;\n\n        const br = document.createElement(\"br\");\n        br.className = Translator.KISS_CLASS.br;\n        this.#skipMoNodes.add(br);\n\n        node.after(br);\n      }\n    });\n  }\n\n  // 清除高亮\n  #removeHighlights(parentNode) {\n    if (!parentNode) return;\n\n    const highlightedElements = parentNode.querySelectorAll(\n      `.${Translator.KISS_CLASS.highlight}`\n    );\n\n    highlightedElements.forEach((element) => {\n      const textNode = document.createTextNode(element.textContent);\n      element.replaceWith(textNode);\n    });\n\n    parentNode.normalize();\n  }\n\n  // 移除br\n  #removeBrTags(parentNode) {\n    if (!parentNode) return;\n\n    parentNode\n      .querySelectorAll(`.${Translator.KISS_CLASS.br}`)\n      .forEach((br) => br.remove());\n\n    parentNode.normalize();\n  }\n\n  // 判断是否需要换行\n  #shouldBreak(node) {\n    if (!Translator.isElementOrFragment(node)) return false;\n    if (node.matches(this.#rule.keepSelector)) return false;\n\n    if (\n      Translator.TAGS.BREAK_LINE.has(node.nodeName?.toUpperCase()) ||\n      node.matches?.(this.#ignoreSelector) ||\n      node.nodeName?.toLowerCase() === this.#translationTagName\n    ) {\n      return true;\n    }\n\n    if (this.#rule.autoScan && Translator.isBlockNode(node)) {\n      return true;\n    }\n\n    if (\n      !this.#rule.autoScan &&\n      (node.matches(this.#rule.selector) ||\n        node.querySelector(this.#rule.selector))\n    ) {\n      return true;\n    }\n\n    return false;\n  }\n\n  // 过滤文本\n  #isInvalidText(text) {\n    if (typeof text !== \"string\") {\n      return true;\n    }\n\n    const trimmedText = text.trim();\n\n    // 文本长度\n    if (\n      trimmedText.length < this.#setting.minLength ||\n      trimmedText.length > this.#setting.maxLength\n    ) {\n      return true;\n    }\n\n    // 单个非字母数字字符。\n    if (trimmedText.length === 1 && !trimmedText.match(/[a-zA-Z]/)) {\n      return true;\n    }\n\n    // 只是一个数字\n    if (!isNaN(parseFloat(trimmedText)) && isFinite(trimmedText)) {\n      return true;\n    }\n\n    // 正则匹配\n    if (this.#combinedSkipsRegex.test(trimmedText)) {\n      return true;\n    }\n\n    return false;\n  }\n\n  // 翻译内联节点\n  async #translateNodeGroup(nodes, hostNode, deLang) {\n    const {\n      transTag,\n      textStyle,\n      transEndHook,\n      transOnly,\n      termsStyle,\n      textExtStyle,\n      selectStyle,\n      parentStyle,\n      grandStyle,\n      // detectRemote,\n      toLang,\n      // skipLangs = [],\n      highlightWords,\n    } = this.#rule;\n    const {\n      newlineLength,\n      // langDetector，\n    } = this.#setting;\n    const parentNode = hostNode.parentElement;\n    const hideOrigin = transOnly === \"true\";\n\n    try {\n      const [processedString, placeholderMap] = this.#serializeForTranslation(\n        nodes,\n        termsStyle\n      );\n      if (this.#isInvalidText(processedString)) return;\n\n      const wrapper = document.createElement(this.#translationTagName);\n      wrapper.className = `${Translator.KISS_CLASS.warpper} notranslate`;\n\n      if (processedString.length > newlineLength) {\n        const br = document.createElement(\"br\");\n        br.hidden = hideOrigin;\n        wrapper.appendChild(br);\n      }\n\n      const inner = document.createElement(transTag);\n      inner.className = `${Translator.KISS_CLASS.inner} ${this.#textClass[textStyle] || \"\"}`;\n      if (textExtStyle?.trim()) {\n        inner.style.cssText = textExtStyle; // 附加内联样式\n      }\n      inner.appendChild(createLoadingSVG());\n      wrapper.appendChild(inner);\n      nodes[nodes.length - 1].after(wrapper);\n\n      const currentRunId = this.#runId;\n      const { trText: translatedText, isSame: isSameLang } =\n        await this.#translateFetch(processedString, deLang);\n      if (this.#runId !== currentRunId) {\n        throw new Error(\"Request terminated\");\n      }\n\n      if (!translatedText || isSameLang) {\n        wrapper.remove();\n        return;\n      }\n\n      const htmlString = this.#restoreFromTranslation(\n        translatedText,\n        placeholderMap\n      );\n      const trustedHTML = trustedTypesHelper.createHTML(htmlString);\n\n      // const parser = new DOMParser();\n      // const doc = parser.parseFromString(trustedHTML, \"text/html\");\n      // const innerElement = doc.body.firstChild;\n      // inner.replaceChildren(innerElement);\n\n      inner.innerHTML = trustedHTML;\n\n      this.#translationNodes.set(wrapper, {\n        nodes,\n        isHide: hideOrigin,\n      });\n      if (hideOrigin) {\n        this.#removeNodes(nodes);\n      }\n\n      // 附加样式\n      if (selectStyle && hostNode.style) {\n        hostNode.style.cssText += selectStyle;\n      }\n      if (parentStyle && parentNode && parentNode.style) {\n        parentNode.style.cssText += parentStyle;\n      }\n      if (grandStyle && parentNode && parentNode.parentElement) {\n        parentNode.parentElement.style.cssText += grandStyle;\n      }\n\n      // 高亮词汇\n      if (highlightWords === OPT_HIGHLIGHT_WORDS_AFTERTRANS) {\n        nodes.forEach((node) => this.#highlightWordsDeeply(node));\n      }\n\n      // 翻译完成钩子函数\n      if (transEndHook?.trim()) {\n        try {\n          interpreter.run(`exports.transEndHook = ${transEndHook}`);\n          interpreter.exports.transEndHook(\n            {\n              hostNode,\n              parentNode,\n              nodes,\n              wrapperNode: wrapper,\n              innerNode: inner,\n            },\n            {\n              text: processedString,\n              fromLang: deLang || this.#rule.fromLang,\n              toLang,\n            }\n          );\n        } catch (err) {\n          kissLog(\"transEndHook\", err);\n        }\n      }\n    } catch (err) {\n      // inner.textContent = `[失败]...`;\n      // todo: 失败重试按钮\n      kissLog(\"translate group error: \", err.message);\n      this.#cleanupDirectTranslations(hostNode);\n    }\n  }\n\n  // 处理节点转为翻译字符串\n  #serializeForTranslation(nodes, termsStyle) {\n    let replaceCounter = 0; // {{n}}\n    let wrapCounter = 0; // <tagn>\n    const placeholderMap = new Map();\n    const { startDelimiter, endDelimiter } = this.#placeholderConfig;\n\n    const pushReplace = (html) => {\n      replaceCounter++;\n      const placeholder = `${startDelimiter}${replaceCounter}${endDelimiter}`;\n      placeholderMap.set(placeholder, html);\n      return placeholder;\n    };\n\n    const traverse = (node) => {\n      if (\n        node.nodeType !== Node.ELEMENT_NODE &&\n        node.nodeType !== Node.TEXT_NODE\n      ) {\n        return \"\";\n      }\n\n      // 文本节点\n      if (node.nodeType === Node.TEXT_NODE) {\n        let text = node.textContent;\n\n        // 专业术语替换\n        if (this.#combinedTermsRegex) {\n          this.#combinedTermsRegex.lastIndex = 0;\n          text = text.replace(this.#combinedTermsRegex, (...args) => {\n            const groups = args.slice(1, -2);\n            const matchedIndex = groups.findIndex(\n              (group) => group !== undefined\n            );\n            const fullMatch = args[0];\n            const termValue = this.#termValues[matchedIndex];\n\n            return pushReplace(\n              `<i class=\"${Translator.KISS_CLASS.term}\" style=\"${termsStyle}\">${termValue || fullMatch}</i>`\n            );\n          });\n        }\n\n        return escapeHTML(text);\n      }\n\n      // 元素节点\n      if (node.nodeType === Node.ELEMENT_NODE) {\n        if (\n          (this.#rule.hasRichText === \"true\" &&\n            Translator.TAGS.REPLACE.has(node.tagName)) ||\n          node.matches(this.#rule.keepSelector) ||\n          // node.matches(this.#ignoreSelector) ||\n          !node.textContent.trim()\n        ) {\n          if (\n            node.tagName?.toUpperCase() === \"IMG\" ||\n            node.tagName?.toUpperCase() === \"SVG\"\n          ) {\n            node.style.width = `${node.offsetWidth}px`;\n            node.style.height = `${node.offsetHeight}px`;\n          }\n          return pushReplace(node.outerHTML);\n        }\n\n        let innerContent = \"\";\n        node.childNodes.forEach((child) => {\n          innerContent += traverse(child);\n        });\n\n        if (\n          this.#rule.hasRichText === \"true\" &&\n          Translator.TAGS.WARP.has(node.tagName?.toUpperCase())\n        ) {\n          wrapCounter++;\n          const { tagName, format } = this.#placeholderConfig;\n\n          // 存储序号对应的原始标签对（使用TAG_前缀避免与普通占位符{{1}}冲突）\n          placeholderMap.set(`TAG_${wrapCounter}`, {\n            openTag: buildOpeningTag(node),\n            closeTag: `</${node.localName}>`,\n          });\n\n          // 生成占位符\n          let startPlaceholder, endPlaceholder;\n          if (format === \"attribute\") {\n            // 属性格式：<a i=1>content</a>\n            startPlaceholder = `<${tagName} i=${wrapCounter}>`;\n            endPlaceholder = `</${tagName}>`;\n          } else {\n            // 简洁格式：<a1>content</a1>\n            startPlaceholder = `<${tagName}${wrapCounter}>`;\n            endPlaceholder = `</${tagName}${wrapCounter}>`;\n          }\n\n          return `${startPlaceholder}${innerContent}${endPlaceholder}`;\n        }\n\n        return innerContent;\n      }\n\n      return \"\";\n    };\n\n    function buildOpeningTag(node) {\n      const escapeAttr = (str) => str.replace(/\"/g, \"&quot;\");\n      let tag = `<${node.tagName.toLowerCase()}`;\n      for (const attr of node.attributes) {\n        tag += ` ${attr.name}=\"${escapeAttr(attr.value)}\"`;\n      }\n      tag += \">\";\n      return tag;\n    }\n\n    const processedString = nodes.map(traverse).join(\"\").trim();\n\n    return [processedString, placeholderMap];\n  }\n\n  // 组装恢复html字符串\n  #restoreFromTranslation(translatedText, placeholderMap) {\n    if (!placeholderMap.size) {\n      return translatedText;\n    }\n\n    if (!translatedText) return \"\";\n\n    const { safeTag, openRegex, closeRegex } = this.#placeholderConfig;\n    const restoreAttr = \"data-kiss-restore\";\n    let textToParse = translatedText;\n    let result = translatedText;\n\n    try {\n      // 1. 将所有占位符格式统一替换为 <span data-kiss-restore=\"index\">\n      textToParse = textToParse.replace(\n        openRegex,\n        `<${safeTag} ${restoreAttr}=\"$1\">`\n      );\n      textToParse = textToParse.replace(closeRegex, `</${safeTag}>`);\n\n      // 2. DOM 解析\n      const parser = new DOMParser();\n      const doc = parser.parseFromString(textToParse, \"text/html\");\n\n      // 3. 查找所有标记节点\n      const selector = `${safeTag}[${restoreAttr}]`;\n      const placeholders = Array.from(doc.querySelectorAll(selector));\n\n      // 4. 倒序还原 (自底向上)\n      placeholders.reverse().forEach((node) => {\n        const index = node.getAttribute(restoreAttr);\n        if (index) {\n          const tagPair = placeholderMap.get(`TAG_${index}`);\n          if (tagPair) {\n            // 使用 outerHTML 替换整个临时节点\n            // node.innerHTML 包含了该节点内部可能已经还原过的原本内容\n            node.outerHTML = `${tagPair.openTag}${node.innerHTML}${tagPair.closeTag}`;\n          }\n        }\n      });\n\n      result = doc.body.innerHTML;\n    } catch (e) {\n      kissLog(\"DOMParser restore failed, fallback to raw\", e);\n      // 如果解析失败，result 仍为 translatedText，继续尝试正则还原其他占位符\n    }\n\n    // 还原普通占位符 {{1}}, {{2}} 等 (保留原有逻辑)\n    result = result.replace(\n      this.#placeholderConfig.placeholderRegex,\n      (match) => placeholderMap.get(match) || match\n    );\n\n    return result;\n  }\n\n  // 发起翻译请求\n  #translateFetch(text, deLang = \"\") {\n    const { toLang, transStartHook } = this.#rule;\n    const fromLang = deLang || this.#rule.fromLang;\n    const apiSetting = { ...this.#apiSetting };\n    const glossary = { ...this.#glossary };\n    const apisMap = this.#apisMap;\n\n    const args = {\n      text,\n      fromLang,\n      toLang,\n      apiSetting,\n      glossary,\n    };\n\n    // 翻译开始钩子函数\n    if (transStartHook?.trim()) {\n      try {\n        interpreter.run(`exports.transStartHook = ${transStartHook}`);\n        const hookResult = interpreter.exports.transStartHook({\n          ...args,\n          apisMap,\n        });\n        if (hookResult) {\n          Object.assign(args, hookResult);\n        }\n      } catch (err) {\n        kissLog(\"transStartHook\", err);\n      }\n    }\n\n    return apiTranslate(args);\n  }\n\n  // 查找指定节点下所有译文节点\n  #findTranslationWrappers(parentNode) {\n    return parentNode.querySelectorAll(\n      `:scope > .${Translator.KISS_CLASS.warpper}`\n    );\n  }\n\n  // 清理所有插入的译文dom\n  #cleanupAllNodes() {\n    this.#rootNodes.forEach((root) => this.#cleanupAllTranslations(root));\n  }\n\n  // 清理节点下面所有译文dom\n  #cleanupAllTranslations(root) {\n    root\n      .querySelectorAll(`.${Translator.KISS_CLASS.warpper}`)\n      .forEach((el) => this.#removeTranslationElement(el));\n  }\n\n  // 清理子节点译文dom\n  #cleanupDirectTranslations(node) {\n    this.#findTranslationWrappers(node).forEach((el) => {\n      this.#removeTranslationElement(el);\n    });\n  }\n\n  // 清理译文\n  #removeTranslationElement(el) {\n    const parentElement = el.parentElement;\n    this.#processedNodes.delete(parentElement);\n\n    // 如果是仅显示译文模式，先恢复原文\n    const { nodes, isHide } = this.#translationNodes.get(el) || {};\n    if (isHide) {\n      this.#restoreOriginal(el, nodes);\n    }\n\n    this.#translationNodes.delete(el);\n    el.remove();\n\n    // todo: 可能不应深度清除\n    if (this.#rule.highlightWords === OPT_HIGHLIGHT_WORDS_AFTERTRANS) {\n      this.#removeHighlights(parentElement);\n    }\n    this.#removeBrTags(parentElement);\n  }\n\n  // 恢复原文\n  #restoreOriginal(el, nodes) {\n    if (nodes) {\n      const frag = document.createDocumentFragment();\n      nodes.forEach((n) => frag.appendChild(n));\n      const parent = el.parentElement;\n      parent?.insertBefore(frag, el);\n    }\n  }\n\n  // 移除多个节点\n  #removeNodes(nodes) {\n    if (nodes) {\n      const frag = document.createDocumentFragment();\n      nodes.forEach((n) => frag.appendChild(n));\n    }\n  }\n\n  // 切换译文和双语显示\n  #toggleTranslationOnly(node, transOnly) {\n    this.#findTranslationWrappers(node).forEach((el) => {\n      const br = el.querySelector(\":scope > br\");\n      const { nodes } = this.#translationNodes.get(el) || {};\n      if (transOnly === \"true\") {\n        // 双语变为仅译文\n        if (br) br.hidden = true;\n        this.#removeNodes(nodes);\n        this.#translationNodes.set(el, { nodes, isHide: true });\n      } else {\n        // 仅译文变为双语\n        if (br) br.hidden = false;\n        this.#restoreOriginal(el, nodes);\n        this.#translationNodes.set(el, { nodes, isHide: false });\n      }\n    });\n  }\n\n  // 更新样式\n  #updateStyle(node, oldStyle, newStyle) {\n    this.#findTranslationWrappers(node).forEach((el) => {\n      const inner = el.querySelector(\n        `:scope > .${Translator.KISS_CLASS.inner}`\n      );\n      inner.classList.remove(this.#textClass[oldStyle]);\n      inner.classList.add(this.#textClass[newStyle]);\n    });\n  }\n\n  // 刷新节点翻译\n  #refreshNode(node) {\n    this.#cleanupDirectTranslations(node);\n    this.#processNode(node);\n  }\n\n  // 使指定节点的状态与当前的全局同步\n  #performSyncNode(node) {\n    const appliedRule = this.#processedNodes.get(node);\n    if (!appliedRule) {\n      this.#enabled && this.#processNode(node);\n      return;\n    }\n\n    const { apiSlug, fromLang, toLang, hasRichText, textStyle, transOnly } =\n      this.#rule;\n\n    const needsRefresh =\n      appliedRule.apiSlug !== apiSlug ||\n      appliedRule.fromLang !== fromLang ||\n      appliedRule.toLang !== toLang ||\n      appliedRule.hasRichText !== hasRichText;\n\n    // 需要重新翻译\n    if (needsRefresh) {\n      Object.assign(appliedRule, {\n        apiSlug,\n        fromLang,\n        toLang,\n        hasRichText,\n        textStyle,\n        transOnly,\n      });\n      this.#refreshNode(node); // 会自动应用新样式\n      return;\n    }\n\n    // 样式规则过时\n    if (appliedRule.textStyle !== textStyle) {\n      const oldStyle = appliedRule.textStyle;\n      appliedRule.textStyle = textStyle;\n      this.#updateStyle(node, oldStyle, textStyle);\n    }\n\n    // 切换原文显示\n    if (appliedRule.transOnly !== transOnly) {\n      appliedRule.transOnly = transOnly;\n      this.#toggleTranslationOnly(node, transOnly);\n    }\n  }\n\n  // 停止监听，重置参数\n  #resetOptions() {\n    this.#removeShadowRootListener();\n\n    this.#io.disconnect();\n    this.#mo.disconnect();\n    this.#viewNodes.clear();\n    this.#rootNodes.clear();\n    this.#observedNodes = new WeakSet();\n    this.#translationNodes = new WeakMap();\n    this.#processedNodes = new WeakMap();\n  }\n\n  // 开启鼠标悬停翻译\n  #enableMouseHover() {\n    if (this.#mouseHoverEnabled) return;\n    this.#mouseHoverEnabled = true;\n    this.#setting.mouseHoverSetting.useMouseHover = true;\n\n    document.addEventListener(\"mousemove\", this.#boundMouseMoveHandler);\n    const { mouseHoverKey } = this.#setting.mouseHoverSetting;\n    if (mouseHoverKey.length === 0) {\n      // mouseHoverKey = DEFAULT_MOUSEHOVER_KEY;\n      return;\n    }\n    this.#removeKeydownHandler = shortcutRegister(\n      mouseHoverKey,\n      this.#boundKeyDownHandler\n    );\n  }\n\n  // 禁用鼠标悬停翻译\n  #disableMouseHover() {\n    if (!this.#mouseHoverEnabled) return;\n    this.#mouseHoverEnabled = false;\n    this.#setting.mouseHoverSetting.useMouseHover = false;\n\n    document.removeEventListener(\"mousemove\", this.#boundMouseMoveHandler);\n    this.#removeKeydownHandler?.();\n  }\n\n  // 注入JS/CSS\n  #initInjector() {\n    if (this.#isJsInjected) {\n      return;\n    }\n    this.#isJsInjected = true;\n\n    try {\n      // const { injectJs, injectCss } = this.#rule;\n      // if (isExt) {\n      //   injectJs && sendBgMsg(MSG_INJECT_JS, injectJs);\n      //   injectCss && sendBgMsg(MSG_INJECT_CSS, injectCss);\n      // } else {\n      //   injectJs &&\n      //     injectInlineJs(injectJs, \"kiss-translator-userinit-injector\");\n      //   injectCss && injectInternalCss(injectCss);\n      // }\n\n      const { injectJs, injectCss, toLang } = this.#rule;\n\n      if (isExt) {\n        injectCss && sendBgMsg(MSG_INJECT_CSS, injectCss);\n      } else {\n        injectCss && injectInternalCss(injectCss);\n      }\n\n      if (injectJs?.trim()) {\n        const apiSetting = { ...this.#apiSetting };\n        const glossary = { ...this.#glossary };\n        const apisMap = this.#apisMap;\n        const apiDectect = tryDetectLang;\n        interpreter.import({\n          KT: {\n            apiTranslate,\n            apiDectect,\n            apiSetting,\n            apisMap,\n            toLang,\n            glossary,\n          },\n        });\n        interpreter.run(injectJs);\n      }\n    } catch (err) {\n      kissLog(\"inject js\", err);\n    }\n  }\n\n  // 移除JS/CSS\n  #removeInjector() {\n    document\n      .querySelectorAll(`[data-source^=\"kiss-inject\"]`)\n      ?.forEach((el) => el.remove());\n  }\n\n  // 切换鼠标悬停翻译\n  toggleMouseHover() {\n    this.#mouseHoverEnabled\n      ? this.#disableMouseHover()\n      : this.#enableMouseHover();\n  }\n\n  // 开启翻译\n  enable() {\n    if (this.#enabled) return;\n    this.#enabled = true;\n    this.#rule.transOpen = \"true\";\n    this.#runId++;\n\n    if (this.#isInitialized) {\n      if (this.#setting.transAllnow) {\n        this.rescan();\n      } else {\n        this.#reIOViewNodes();\n      }\n    } else {\n      this.#init();\n    }\n\n    if (this.#rule.transTitle === \"true\") {\n      this.#translateTitle();\n    }\n\n    isExt && sendBgMsg(MSG_UPDATE_ICON, true);\n  }\n\n  // 翻译页面标题\n  async #translateTitle() {\n    const docInfo = getDocInfo();\n    if (!docInfo?.title) return;\n\n    try {\n      const deLang = await tryDetectLang(docInfo.title);\n      const { trText } = await this.#translateFetch(docInfo.title, deLang);\n      this.#docInfo.title = document.title; // 缓存原标题\n      document.title = trText || docInfo.title;\n    } catch (err) {\n      kissLog(\"tanslate title\", err);\n    }\n  }\n\n  // 关闭翻译\n  disable() {\n    if (!this.#enabled) return;\n    this.#enabled = false;\n    this.#rule.transOpen = \"false\";\n    this.#runId++;\n\n    this.#cleanupAllNodes();\n    clearFetchPool();\n    clearAllBatchQueue();\n\n    // 恢复页面标题\n    if (this.#rule.transTitle === \"true\" && this.#docInfo.title) {\n      document.title = this.#docInfo.title;\n    }\n\n    isExt && sendBgMsg(MSG_UPDATE_ICON, false);\n  }\n\n  // 重新扫描页面\n  rescan() {\n    if (!this.#isInitialized) return;\n    this.#runId++;\n\n    this.#cleanupAllNodes();\n    this.#resetOptions();\n    clearFetchPool();\n    clearAllBatchQueue();\n\n    // 重新初始化\n    this.#init();\n  }\n\n  // 切换是否翻译\n  toggle() {\n    this.#enabled ? this.disable() : this.enable();\n  }\n\n  // 快速切换模糊样式\n  toggleStyle() {\n    const textStyle =\n      this.#rule.textStyle === OPT_STYLE_FUZZY\n        ? OPT_STYLE_NONE\n        : OPT_STYLE_FUZZY;\n    this.updateRule({ textStyle });\n  }\n\n  // 切换划词翻译\n  toggleTransbox() {\n    this.#setting.tranboxSetting.transOpen =\n      !this.#setting.tranboxSetting.transOpen;\n  }\n\n  // 切换输入框翻译\n  toggleInputTranslate() {\n    this.#setting.inputRule.transOpen = !this.#setting.inputRule.transOpen;\n  }\n\n  // 停止运行\n  stop() {\n    this.disable();\n    this.#resetOptions();\n    this.#disableMouseHover();\n    this.#removeInjector();\n    this.#isInitialized = false;\n  }\n\n  // 更新规则\n  updateRule(newRule) {\n    let hasChanged = false;\n    let needsRescan = false;\n    for (const key in newRule) {\n      if (\n        Object.prototype.hasOwnProperty.call(this.#rule, key) &&\n        this.#rule[key] !== newRule[key]\n      ) {\n        this.#rule[key] = newRule[key];\n        if (\n          key === \"autoScan\" ||\n          key === \"hasShadowroot\" ||\n          key === \"scanAll\" ||\n          key === \"isPlainText\"\n        ) {\n          needsRescan = true;\n        } else {\n          hasChanged = true;\n        }\n      }\n    }\n\n    // 配置变更时清空正则缓存\n    this.#placeholderCache = null;\n\n    if (needsRescan || (this.#enabled && this.#setting.transAllnow)) {\n      this.rescan();\n      return;\n    }\n\n    if (hasChanged) {\n      this.#reIOViewNodes();\n    }\n  }\n\n  get setting() {\n    return { ...this.#setting };\n  }\n\n  get rule() {\n    return { ...this.#rule };\n  }\n\n  get eventName() {\n    return this.#eventName;\n  }\n}\n"
  },
  {
    "path": "src/libs/translatorManager.js",
    "content": "import { browser } from \"./browser\";\nimport { Translator } from \"./translator\";\nimport { InputTranslator } from \"./inputTranslate\";\nimport { TransboxManager } from \"./tranbox\";\nimport { shortcutRegister } from \"./shortcut\";\nimport { sendIframeMsg } from \"./iframe\";\nimport {\n  EVENT_KISS_INNER,\n  EVENT_KISS_TRANSLATOR,\n  MSG_HOVERNODE_TOGGLE,\n  MSG_INPUT_TRANSLATE,\n  newI18n,\n} from \"../config\";\nimport { touchTapListener } from \"./touch\";\nimport { PopupManager } from \"./popupManager\";\nimport { FabManager } from \"./fabManager\";\nimport {\n  OPT_SHORTCUT_TRANSLATE,\n  OPT_SHORTCUT_STYLE,\n  OPT_SHORTCUT_POPUP,\n  OPT_SHORTCUT_SETTING,\n  MSG_TRANS_TOGGLE,\n  MSG_TRANS_TOGGLE_STYLE,\n  MSG_TRANS_GETRULE,\n  MSG_TRANS_PUTRULE,\n  MSG_OPEN_TRANBOX,\n  MSG_TRANSBOX_TOGGLE,\n  MSG_POPUP_TOGGLE,\n  MSG_MOUSEHOVER_TOGGLE,\n  MSG_TRANSINPUT_TOGGLE,\n} from \"../config\";\nimport { logger } from \"./log\";\n\nexport default class TranslatorManager {\n  #clearShortcuts = [];\n  #menuCommandIds = [];\n  #clearTouchListeners = [];\n  #isActive = false;\n  #isUserscript;\n  #isIframe;\n\n  #innerMessageHandler = null;\n  #browserMessageHandler = null;\n  #windowMessageHandler = null;\n\n  _translator;\n  _transboxManager;\n  _inputTranslator;\n  _popupManager;\n  _fabManager;\n\n  constructor({ setting, rule, fabConfig, favWords, isIframe, isUserscript }) {\n    this.#isIframe = isIframe;\n    this.#isUserscript = isUserscript;\n\n    this._translator = new Translator({\n      rule,\n      setting,\n      favWords,\n      isUserscript,\n      isIframe,\n    });\n\n    this._transboxManager = new TransboxManager(setting);\n\n    if (!isIframe) {\n      this._inputTranslator = new InputTranslator(setting);\n      this._popupManager = new PopupManager({\n        translator: this._translator,\n        processActions: this.#processActions.bind(this),\n      });\n      this._fabManager = new FabManager({\n        processActions: this.#processActions.bind(this),\n        fabConfig,\n      });\n    }\n\n    this.#innerMessageHandler = this.#handleInnerMessage.bind(this);\n    this.#browserMessageHandler = this.#handleBrowserMessage.bind(this);\n    this.#windowMessageHandler = this.#handleWindowMessage.bind(this);\n  }\n\n  start() {\n    if (this.#isActive) {\n      logger.info(\"TranslatorManager is already started.\");\n      return;\n    }\n\n    this.#setupMessageListeners();\n    this.#setupTouchOperations();\n\n    if (!this.#isIframe && this.#isUserscript) {\n      this.#registerShortcuts();\n      this.#registerMenus();\n    }\n\n    this.#isActive = true;\n    logger.info(\"TranslatorManager started.\");\n  }\n\n  stop() {\n    if (!this.#isActive) {\n      logger.info(\"TranslatorManager is not running.\");\n      return;\n    }\n\n    // 移除消息监听器\n    window.removeEventListener(\n      EVENT_KISS_TRANSLATOR,\n      this.#innerMessageHandler\n    );\n    if (this.#isUserscript) {\n      window.removeEventListener(\"message\", this.#innerMessageHandler);\n    } else {\n      browser.runtime.onMessage.removeListener(this.#browserMessageHandler);\n      if (this.#isIframe) {\n        window.removeEventListener(\"message\", this.#innerMessageHandler);\n      }\n    }\n\n    // 已注册的快捷键\n    this.#clearShortcuts.forEach((clear) => clear());\n    this.#clearShortcuts = [];\n\n    // 触屏\n    this.#clearTouchListeners.forEach((clear) => clear());\n    this.#clearTouchListeners = [];\n\n    // 油猴菜单\n    if (globalThis.GM && this.#menuCommandIds.length > 0) {\n      this.#menuCommandIds.forEach((id) => GM.unregisterMenuCommand?.(id));\n      this.#menuCommandIds = [];\n    }\n\n    // 子模块\n    this._popupManager?.destroy();\n    this._fabManager?.destroy();\n    this._transboxManager?.disable();\n    this._inputTranslator?.disable();\n    this._translator.stop();\n\n    this.#isActive = false;\n    logger.info(\"TranslatorManager stopped.\");\n  }\n\n  #setupMessageListeners() {\n    if (this.#isUserscript) {\n      window.addEventListener(\"message\", this.#innerMessageHandler);\n    } else {\n      browser.runtime.onMessage.addListener(this.#browserMessageHandler);\n      if (this.#isIframe) {\n        window.addEventListener(\"message\", this.#innerMessageHandler);\n      }\n    }\n\n    // 监听外部调用消息\n    window.addEventListener(EVENT_KISS_TRANSLATOR, this.#windowMessageHandler);\n  }\n\n  #setupTouchOperations() {\n    if (this.#isIframe) return;\n\n    const { touchModes = [2] } = this._translator.setting;\n    if (touchModes.length === 0) {\n      return;\n    }\n\n    const handleTap = () => {\n      this.#processActions({ action: MSG_TRANS_TOGGLE });\n    };\n\n    const handleListener = (mode) => {\n      let options = null;\n      switch (mode) {\n        case 2:\n        case 3:\n        case 4:\n          options = { taps: 1, fingers: mode };\n          break;\n        case 5:\n          options = { taps: 2, fingers: 1 };\n          break;\n        case 6:\n          options = { taps: 3, fingers: 1 };\n          break;\n        case 7:\n          options = { taps: 2, fingers: 2 };\n          break;\n        default:\n      }\n      if (options) {\n        this.#clearTouchListeners.push(touchTapListener(handleTap, options));\n      }\n    };\n\n    touchModes.forEach((mode) => handleListener(mode));\n  }\n\n  // 处理外部调用\n  #handleWindowMessage(event) {\n    logger.debug(\"handle window message:\", event);\n    this.#processActions(event.detail);\n  }\n\n  #handleInnerMessage(event) {\n    this.#processActions(event.data);\n  }\n\n  #handleBrowserMessage(message, sender, sendResponse) {\n    const result = this.#processActions(message, true);\n    const response = result || {\n      rule: this._translator.rule,\n      setting: this._translator.setting,\n    };\n    sendResponse(response);\n    return true;\n  }\n\n  #registerShortcuts() {\n    const { shortcuts } = this._translator.setting;\n    this.#clearShortcuts = [\n      shortcutRegister(shortcuts[OPT_SHORTCUT_TRANSLATE], () =>\n        this.#processActions({ action: MSG_TRANS_TOGGLE })\n      ),\n      shortcutRegister(shortcuts[OPT_SHORTCUT_STYLE], () =>\n        this.#processActions({ action: MSG_TRANS_TOGGLE_STYLE })\n      ),\n      shortcutRegister(shortcuts[OPT_SHORTCUT_POPUP], () =>\n        this.#processActions({ action: MSG_POPUP_TOGGLE })\n      ),\n      shortcutRegister(shortcuts[OPT_SHORTCUT_SETTING], () =>\n        window.open(process.env.REACT_APP_OPTIONSPAGE, \"_blank\")\n      ),\n    ];\n  }\n\n  #registerMenus() {\n    if (!globalThis.GM) return;\n    const { contextMenuType, uiLang } = this._translator.setting;\n    if (contextMenuType === 0) return;\n\n    const i18n = newI18n(uiLang || \"zh\");\n\n    this.#menuCommandIds = [\n      GM.registerMenuCommand?.(\n        i18n(\"translate_switch\"),\n        () => this.#processActions({ action: MSG_TRANS_TOGGLE }),\n        \"Q\"\n      ),\n      GM.registerMenuCommand?.(\n        i18n(\"toggle_style\"),\n        () => this.#processActions({ action: MSG_TRANS_TOGGLE_STYLE }),\n        \"C\"\n      ),\n      GM.registerMenuCommand?.(\n        i18n(\"open_menu\"),\n        () => this.#processActions({ action: MSG_POPUP_TOGGLE }),\n        \"K\"\n      ),\n      GM.registerMenuCommand?.(\n        i18n(\"open_setting\"),\n        () => window.open(process.env.REACT_APP_OPTIONSPAGE, \"_blank\"),\n        \"O\"\n      ),\n    ];\n  }\n\n  #processActions({ action, args } = {}, fromExt = false) {\n    if (!action) return;\n\n    if (!fromExt) {\n      sendIframeMsg(action, args);\n    }\n\n    logger.debug(\"process action:\", action, args);\n\n    switch (action) {\n      case MSG_TRANS_TOGGLE:\n        this._translator.toggle();\n        break;\n      case MSG_TRANS_TOGGLE_STYLE:\n        this._translator.toggleStyle();\n        break;\n      case MSG_TRANS_GETRULE:\n        break;\n      case MSG_TRANS_PUTRULE:\n        this._translator.updateRule(args);\n        break;\n      case MSG_OPEN_TRANBOX:\n        document.dispatchEvent(\n          new CustomEvent(EVENT_KISS_INNER, {\n            detail: { action: MSG_OPEN_TRANBOX },\n          })\n        );\n        break;\n      case MSG_POPUP_TOGGLE:\n        this._popupManager?.toggle();\n        break;\n      case MSG_TRANSBOX_TOGGLE:\n        this._transboxManager?.toggle();\n        this._translator.toggleTransbox();\n        break;\n      case MSG_MOUSEHOVER_TOGGLE:\n        this._translator.toggleMouseHover();\n        break;\n      case MSG_TRANSINPUT_TOGGLE:\n        this._inputTranslator?.toggle();\n        this._translator.toggleInputTranslate();\n        break;\n      case MSG_HOVERNODE_TOGGLE:\n        this._translator.toggleHoverNode();\n        break;\n      case MSG_INPUT_TRANSLATE:\n        this._inputTranslator.handleTranslate();\n        break;\n      default:\n        logger.info(`Message action is unavailable: ${action}`);\n        return { error: `Message action is unavailable: ${action}` };\n    }\n  }\n}\n"
  },
  {
    "path": "src/libs/trustedTypes.js",
    "content": "import { logger } from \"./log\";\n\nexport const trustedTypesHelper = (() => {\n  const POLICY_NAME = \"kiss-translator-policy\";\n  let policy = null;\n\n  if (globalThis.trustedTypes && globalThis.trustedTypes.createPolicy) {\n    try {\n      policy = globalThis.trustedTypes.createPolicy(POLICY_NAME, {\n        createHTML: (string) => string,\n        createScript: (string) => string,\n        createScriptURL: (string) => string,\n      });\n    } catch (err) {\n      if (err.message.includes(\"already exists\")) {\n        policy = globalThis.trustedTypes.policies.get(POLICY_NAME);\n      } else {\n        logger.info(\"cont create Trusted Types\", err);\n      }\n    }\n  }\n\n  return {\n    createHTML: (htmlString) => {\n      return policy ? policy.createHTML(htmlString) : htmlString;\n    },\n    createScript: (scriptString) => {\n      return policy ? policy.createScript(scriptString) : scriptString;\n    },\n    createScriptURL: (urlString) => {\n      return policy ? policy.createScriptURL(urlString) : urlString;\n    },\n    isEnabled: () => policy !== null,\n  };\n})();\n"
  },
  {
    "path": "src/libs/url.js",
    "content": "/**\n * URL 處理工具函數\n */\n\n/**\n * 檢查是否為 IP 位址 (v4 或 v6)\n * @param {string} hostname - 主機名稱\n * @returns {boolean}\n * @example\n * isIPAddress(\"192.168.1.1\") -> true\n * isIPAddress(\"::1\") -> true\n * isIPAddress(\"example.com\") -> false\n */\nexport const isIPAddress = (hostname) => {\n  const isIPv4 = /^(\\d{1,3}\\.){3}\\d{1,3}$/.test(hostname);\n  const isIPv6 = hostname.includes(\":\");\n  return isIPv4 || isIPv6;\n};\n\n/**\n * 中間截斷字串，保留頭尾\n * @param {string} str - 原始字串\n * @param {number} [maxLen=45] - 最大長度\n * @returns {string} 截斷後的字串\n * @example\n * truncateMiddle(\"short\") -> \"short\"\n * truncateMiddle(\"/C:/Users/JohnDoe/Documents/Projects/article.txt\", 30) -> \"/C:/Users/Joh...cle/article.txt\"\n */\nexport const truncateMiddle = (str, maxLen = 45) => {\n  if (str.length <= maxLen) return str;\n  const ellipsis = \"...\";\n  const charsToShow = maxLen - ellipsis.length;\n  const frontChars = Math.ceil(charsToShow / 2);\n  const backChars = Math.floor(charsToShow / 2);\n  return `${str.slice(0, frontChars)}${ellipsis}${str.slice(-backChars)}`;\n};\n\n/**\n * 從 file:// URL 提取檔案匹配選項\n * @param {string} href - file:// URL\n * @returns {string[]} 匹配選項陣列，由精確到寬鬆排序\n * @example\n * \"file:///C:/docs/article.txt\" -> [\"/C:/docs/article.txt\", \"/C:/docs/*\", \"*.txt\", \"article.txt\"]\n * \"file:///home/user/test.md\" -> [\"/home/user/test.md\", \"/home/user/*\", \"*.md\", \"test.md\"]\n */\nconst getFileOptions = (href) => {\n  const path = href.replace(/^file:\\/\\//, \"\");\n  const filename = path.substring(path.lastIndexOf(\"/\") + 1);\n  const dir = path.substring(0, path.lastIndexOf(\"/\"));\n  const ext = filename.includes(\".\")\n    ? filename.substring(filename.lastIndexOf(\".\"))\n    : \"\";\n\n  const options = [];\n\n  // 完整路徑（最精確）\n  if (path) {\n    try {\n      options.push(decodeURIComponent(path));\n    } catch {\n      options.push(path);\n    }\n  }\n  // 目錄萬用\n  if (dir) {\n    options.push(`${dir}/*`);\n  }\n  // 副檔名萬用\n  if (ext) {\n    options.push(`*${ext}`);\n  }\n  // 檔名（最寬鬆）\n  if (filename && filename !== path) {\n    try {\n      options.push(decodeURIComponent(filename));\n    } catch {\n      options.push(filename);\n    }\n  }\n\n  return options;\n};\n\n/**\n * 從 hostname 生成通配符選項\n * @param {string} hostname - 主機名稱\n * @returns {string[]} 匹配選項陣列，由精確到寬鬆排序\n * @example\n * \"localhost\" -> [\"localhost\"]\n * \"example.com\" -> [\"example.com\", \"*.example.com\"]\n * \"foo.example.com\" -> [\"foo.example.com\", \"*.example.com\"]\n * \"foo.bar.example.com\" -> [\"foo.bar.example.com\", \"*.bar.example.com\", \"*.*.example.com\"]\n * \"a.b.c.example.com\" -> [\"a.b.c.example.com\", \"*.b.c.example.com\", \"*.*.c.example.com\", \"*.*.*.example.com\"]\n * \"192.168.1.1\" -> [\"192.168.1.1\"]\n */\nconst getWildcardOptions = (hostname) => {\n  if (isIPAddress(hostname)) {\n    return [hostname];\n  }\n\n  const parts = hostname.split(\".\");\n\n  if (parts.length <= 1) {\n    return [hostname];\n  }\n\n  if (parts.length === 2) {\n    return [hostname, `*.${hostname}`];\n  }\n\n  // 逐層往上替換成 *\n  // foo.bar.example.com -> [foo.bar.example.com, *.bar.example.com, *.*.example.com]\n  const options = [hostname];\n  const mainDomain = parts.slice(-2).join(\".\");\n\n  for (let i = 1; i <= parts.length - 2; i++) {\n    const wildcards = \"*.\".repeat(i);\n    const remainingParts = parts.slice(i).join(\".\");\n    // 避免重複：*.example.com 與 *.*.example.com 不同\n    if (remainingParts !== mainDomain || i === 1) {\n      options.push(`${wildcards}${remainingParts}`);\n    }\n  }\n\n  return options;\n};\n\n/**\n * 生成網域匹配選項\n * @param {string} href - 完整 URL\n * @returns {string[]} 可選的匹配模式陣列，由精確到寬鬆排序\n * @example\n * \"https://example.com/page\" -> [\"example.com\", \"*.example.com\"]\n * \"http://localhost:3000/\" -> [\"localhost:3000\", \"localhost:*\", \"localhost\"]\n * \"http://192.168.1.1:8080/\" -> [\"192.168.1.1:8080\", \"192.168.1.1:*\", \"192.168.1.1\"]\n * \"https://foo.bar.example.com/\" -> [\"foo.bar.example.com\", \"*.bar.example.com\", \"*.*.example.com\"]\n * \"http://dev.example.com:8080/\" -> [\"dev.example.com:8080\", \"dev.example.com:*\", \"dev.example.com\", \"*.example.com\"]\n * \"file:///C:/docs/article.txt\" -> [\"/C:/docs/article.txt\", \"/C:/docs/*\", \"*.txt\", \"article.txt\"]\n * \"chrome-extension://xxx/\" -> []\n */\nexport const getDomainOptions = (href) => {\n  if (!href || typeof href !== \"string\") {\n    return [];\n  }\n\n  try {\n    if (href.startsWith(\"file\")) {\n      return getFileOptions(href);\n    }\n\n    if (!href.startsWith(\"http\")) {\n      return [];\n    }\n\n    const url = new URL(href);\n    const { hostname, port, protocol } = url;\n    const defaultPort = protocol === \"https:\" ? \"443\" : \"80\";\n\n    const wildcardOptions = getWildcardOptions(hostname);\n\n    // 非預設 port 時，加入 port 相關選項\n    if (port && port !== defaultPort) {\n      const host = wildcardOptions[0];\n      return [\n        `${host}:${port}`,\n        `${host}:*`,\n        host,\n        ...wildcardOptions.slice(1),\n      ];\n    }\n\n    return wildcardOptions;\n  } catch {\n    return [];\n  }\n};\n"
  },
  {
    "path": "src/libs/utils.js",
    "content": "/**\n * 移除 Markdown 代码块标记\n * @param {string} text 原始文本\n * @param {boolean} startOnly 是否只处理开头\n * @returns {string} 移除代码块标记后的文本\n */\nexport function stripMarkdownCodeBlock(text, startOnly = false) {\n  if (!text) return \"\";\n  let result = text.replace(/^```[a-z]*\\s*\\n?/i, \"\");\n  if (!startOnly) {\n    result = result.replace(/\\n?```$/i, \"\");\n  }\n  return result;\n}\n\n/**\n * 限制数字大小\n * @param {*} num\n * @param {*} min\n * @param {*} max\n * @returns\n */\nexport const limitNumber = (num, min = 0, max = 100) => {\n  const number = parseInt(num);\n  if (Number.isNaN(number) || number < min) {\n    return min;\n  } else if (number > max) {\n    return max;\n  }\n  return number;\n};\n\nexport const limitFloat = (num, min = 0.0, max = 100.0) => {\n  const number = parseFloat(num);\n  if (Number.isNaN(number) || number < min) {\n    return min;\n  } else if (number > max) {\n    return max;\n  }\n  return number;\n};\n\n/**\n * 匹配是否为数组中的值\n * @param {*} arr\n * @param {*} val\n * @returns\n */\nexport const matchValue = (arr, val) => {\n  if (arr.length === 0 || arr.includes(val)) {\n    return val;\n  }\n  return arr[0];\n};\n\n/**\n * 等待\n * @param {*} delay\n * @returns\n */\nexport const sleep = (delay) =>\n  new Promise((resolve) => {\n    const timer = setTimeout(() => {\n      clearTimeout(timer);\n      resolve();\n    }, delay);\n  });\n\n/**\n * 防抖函数\n * @param {*} func\n * @param {*} delay\n * @returns\n */\nexport const debounce = (func, delay = 200) => {\n  let timer = null;\n\n  const debouncedFunc = (...args) => {\n    timer && clearTimeout(timer);\n    timer = setTimeout(() => {\n      func(...args);\n      timer = null;\n    }, delay);\n  };\n\n  debouncedFunc.cancel = () => {\n    clearTimeout(timer);\n    timer = null;\n  };\n\n  return debouncedFunc;\n};\n\n/**\n * 节流函数\n * @param {Function} func 要执行的函数\n * @param {number} delay 延迟时间\n * @param {object} options 选项 { leading: boolean, trailing: boolean }\n * @returns {Function}\n */\nexport const throttle = (\n  func,\n  delay,\n  options = { leading: true, trailing: true }\n) => {\n  let timeoutId = null;\n  let lastArgs = null;\n  let lastThis = null;\n  let result;\n  let previous = 0;\n\n  function later() {\n    previous = options.leading === false ? 0 : Date.now();\n    timeoutId = null;\n    result = func.apply(lastThis, lastArgs);\n    if (!timeoutId) {\n      lastThis = lastArgs = null;\n    }\n  }\n\n  const throttled = function (...args) {\n    const now = Date.now();\n    if (!previous && options.leading === false) {\n      previous = now;\n    }\n\n    const remaining = delay - (now - previous);\n    lastArgs = args;\n    lastThis = this;\n\n    if (remaining <= 0 || remaining > delay) {\n      if (timeoutId) {\n        clearTimeout(timeoutId);\n        timeoutId = null;\n      }\n      previous = now;\n      result = func.apply(lastThis, lastArgs);\n      if (!timeoutId) {\n        lastThis = lastArgs = null;\n      }\n    } else if (!timeoutId && options.trailing !== false) {\n      timeoutId = setTimeout(later, remaining);\n    }\n    return result;\n  };\n\n  throttled.cancel = () => {\n    clearTimeout(timeoutId);\n    previous = 0;\n    timeoutId = null;\n    lastThis = lastArgs = null;\n  };\n\n  return throttled;\n};\n\n/**\n * 判断字符串全是某个字符\n * @param {*} s\n * @param {*} c\n * @param {*} i\n * @returns\n */\nexport const isAllchar = (s, c, i = 0) => {\n  while (i < s.length) {\n    if (s[i] !== c) {\n      return false;\n    }\n    i++;\n  }\n  return true;\n};\n\n/**\n * 字符串通配符(*)匹配\n * @param {*} s\n * @param {*} p\n * @returns\n */\nexport const isMatch = (s, p) => {\n  if (s.length === 0 || p.length === 0) {\n    return false;\n  }\n\n  p = \"*\" + p + \"*\";\n\n  let [sIndex, pIndex] = [0, 0];\n  let [sRecord, pRecord] = [-1, -1];\n  while (sIndex < s.length && pRecord < p.length) {\n    if (p[pIndex] === \"*\") {\n      pIndex++;\n      [sRecord, pRecord] = [sIndex, pIndex];\n    } else if (s[sIndex] === p[pIndex]) {\n      sIndex++;\n      pIndex++;\n    } else if (sRecord + 1 < s.length) {\n      sRecord++;\n      [sIndex, pIndex] = [sRecord, pRecord];\n    } else {\n      return false;\n    }\n  }\n\n  if (p.length === pIndex) {\n    return true;\n  }\n\n  return isAllchar(p, \"*\", pIndex);\n};\n\n/**\n * 类型检查\n * @param {*} o\n * @returns\n */\nexport const type = (o) => {\n  const s = Object.prototype.toString.call(o);\n  return s.match(/\\[object (.*?)\\]/)[1].toLowerCase();\n};\n\n/**\n * sha256\n * @param {*} text\n * @returns\n */\nexport const sha256 = async (text, salt) => {\n  const data = new TextEncoder().encode(text + salt);\n  const digest = await crypto.subtle.digest({ name: \"SHA-256\" }, data);\n  return [...new Uint8Array(digest)]\n    .map((b) => b.toString(16).padStart(2, \"0\"))\n    .join(\"\");\n};\n\n/**\n * 生成随机事件名称\n * @returns\n */\nexport const genEventName = () => `kiss-${btoa(Math.random()).slice(3, 11)}`;\n\n/**\n * 判断两个 Set 是否相同\n * @param {*} a\n * @param {*} b\n * @returns\n */\nexport const isSameSet = (a, b) => {\n  const s = new Set([...a, ...b]);\n  return s.size === a.size && s.size === b.size;\n};\n\n/**\n * 去掉字符串末尾某个字符\n * @param {*} s\n * @param {*} c\n * @param {*} count\n * @returns\n */\nexport const removeEndchar = (s, c, count = 1) => {\n  if (!s) return \"\";\n\n  let i = s.length;\n  while (i > s.length - count && s[i - 1] === c) {\n    i--;\n  }\n  return s.slice(0, i);\n};\n\n/**\n * 匹配字符串及语言标识\n * @param {*} str\n * @param {*} sign\n * @returns\n */\nexport const matchInputStr = (str, sign) => {\n  switch (sign) {\n    case \"//\":\n      return str.match(/\\/\\/([\\w-]+)\\s+([^]+)/);\n    case \"\\\\\":\n      return str.match(/\\\\([\\w-]+)\\s+([^]+)/);\n    case \"\\\\\\\\\":\n      return str.match(/\\\\\\\\([\\w-]+)\\s+([^]+)/);\n    case \">\":\n      return str.match(/>([\\w-]+)\\s+([^]+)/);\n    case \">>\":\n      return str.match(/>>([\\w-]+)\\s+([^]+)/);\n    default:\n  }\n  return str.match(/\\/([\\w-]+)\\s+([^]+)/);\n};\n\n/**\n * 判断是否英文单词\n * @param {*} str\n * @returns\n */\nexport const isValidWord = (str) => {\n  const regex = /^[a-zA-Z-]+$/;\n  return regex.test(str);\n};\n\n/**\n * blob转为base64\n * @param {*} blob\n * @returns\n */\nexport const blobToBase64 = (blob) => {\n  return new Promise((resolve) => {\n    const reader = new FileReader();\n    reader.onloadend = () => resolve(reader.result);\n    reader.readAsDataURL(blob);\n  });\n};\n\n/**\n * 获取html内的文本\n * @param {*} htmlStr\n * @param {*} skipTag\n * @returns\n */\nexport const getHtmlText = (htmlStr, skipTag = \"\") => {\n  const parser = new DOMParser();\n  const doc = parser.parseFromString(htmlStr, \"text/html\");\n\n  if (skipTag) {\n    doc.querySelectorAll(skipTag).forEach((el) => el.remove());\n  }\n\n  return doc.body.innerText.trim();\n};\n\n/**\n * 解析JSON字符串对象\n * @param {*} str\n * @returns\n */\nexport const parseJsonObj = (str) => {\n  if (!str || type(str) !== \"string\") {\n    return {};\n  }\n\n  try {\n    if (str.trim()[0] !== \"{\") {\n      str = `{${str}}`;\n    }\n    return JSON.parse(str);\n  } catch (err) {\n    //\n  }\n\n  return {};\n};\n\n/**\n * 提取json内容\n * @param {*} s\n * @returns\n */\nexport const extractJson = (raw) => {\n  const jsonRegex = /({.*}|\\[.*\\])/s;\n  const match = raw.match(jsonRegex);\n  return match ? match[0] : null;\n};\n\n/**\n * 空闲执行\n * @param {*} cb\n * @param {*} timeout\n * @returns\n */\nexport const scheduleIdle = (cb, timeout = 200) => {\n  if (window.requestIdleCallback) {\n    return requestIdleCallback(cb, { timeout });\n  }\n  return setTimeout(cb, timeout);\n};\n\n/**\n * 截取url部分\n * @param {*} href\n * @returns\n */\nexport const parseUrlPattern = (href) => {\n  if (href.startsWith(\"file\")) {\n    const filename = href.substring(href.lastIndexOf(\"/\") + 1);\n    return filename;\n  } else if (href.startsWith(\"http\")) {\n    const url = new URL(href);\n    return url.host;\n  }\n  return href;\n};\n\n/**\n * 带超时的任务\n * @param {Promise|Function} task - 任务\n * @param {number} timeout - 超时时间 (毫秒)\n * @param {string} [timeoutMsg] - 超时错误提示\n * @returns {Promise}\n */\nexport const withTimeout = (task, timeout, timeoutMsg = \"Task timed out\") => {\n  const promise = typeof task === \"function\" ? task() : task;\n  return Promise.race([\n    promise,\n    new Promise((_, reject) =>\n      setTimeout(() => reject(new Error(timeoutMsg)), timeout)\n    ),\n  ]);\n};\n\n/**\n * 截短字符串\n * @param {*} str\n * @param {*} maxLength\n * @returns\n */\nexport const truncateWords = (str, maxLength = 300) => {\n  if (typeof str !== \"string\") return \"\";\n  if (str.length <= maxLength) return str;\n  const truncated = str.slice(0, maxLength);\n  return truncated.slice(0, truncated.lastIndexOf(\" \")) + \" …\";\n};\n\n/**\n * 生成随机数\n * @param {*} min\n * @param {*} max\n * @param {*} integer\n * @returns\n */\nexport const randomBetween = (min, max, integer = true) => {\n  const value = Math.random() * (max - min) + min;\n  return integer ? Math.floor(value) : value;\n};\n\n/**\n * 根据文件名自动获取 MIME 类型\n * @param {*} filename\n * @returns\n */\nfunction getMimeTypeFromFilename(filename) {\n  const defaultType = \"application/octet-stream\";\n  if (!filename || filename.indexOf(\".\") === -1) {\n    return defaultType;\n  }\n\n  const extension = filename.split(\".\").pop().toLowerCase();\n  const mimeMap = {\n    // 文本\n    txt: \"text/plain;charset=utf-8\",\n    html: \"text/html;charset=utf-8\",\n    css: \"text/css;charset=utf-8\",\n    js: \"text/javascript;charset=utf-8\",\n    json: \"application/json;charset=utf-8\",\n    xml: \"application/xml;charset=utf-8\",\n    md: \"text/markdown;charset=utf-8\",\n    vtt: \"text/vtt;charset=utf-8\",\n\n    // 图像\n    png: \"image/png\",\n    jpg: \"image/jpeg\",\n    jpeg: \"image/jpeg\",\n    gif: \"image/gif\",\n    svg: \"image/svg+xml\",\n    webp: \"image/webp\",\n    ico: \"image/x-icon\",\n\n    // 音频/视频\n    mp3: \"audio/mpeg\",\n    mp4: \"video/mp4\",\n    webm: \"video/webm\",\n    wav: \"audio/wav\",\n\n    // 应用程序/文档\n    pdf: \"application/pdf\",\n    zip: \"application/zip\",\n    doc: \"application/msword\",\n    docx: \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n    xls: \"application/vnd.ms-excel\",\n    xlsx: \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n  };\n\n  // 默认值\n  return mimeMap[extension] || defaultType;\n}\n\n/**\n * 下载文件\n * @param {*} str\n * @param {*} filename\n */\nexport function downloadBlobFile(str, filename = \"kiss-file.txt\") {\n  const mimeType = getMimeTypeFromFilename(filename);\n  const blob = new Blob([str], { type: mimeType });\n  const url = URL.createObjectURL(blob);\n\n  const a = document.createElement(\"a\");\n  a.style.display = \"none\";\n  a.href = url;\n  a.download = filename || `kiss-file.txt`;\n\n  document.body.appendChild(a);\n  a.click();\n\n  document.body.removeChild(a);\n  URL.revokeObjectURL(url);\n}\n\n// HTML转义\nexport function escapeHTML(str) {\n  const div = document.createElement(\"div\");\n  div.textContent = str;\n  return div.innerHTML;\n}\n"
  },
  {
    "path": "src/options.js",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom/client\";\nimport Options from \"./views/Options\";\n\nglobalThis.__KISS_CONTEXT__ = \"options\";\n\nconst root = ReactDOM.createRoot(document.getElementById(\"root\"));\nroot.render(\n  <React.StrictMode>\n    <Options />\n  </React.StrictMode>\n);\n"
  },
  {
    "path": "src/popup.js",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom/client\";\nimport { SettingProvider } from \"./hooks/Setting\";\nimport ThemeProvider from \"./hooks/Theme\";\nimport Popup from \"./views/Popup\";\n\nglobalThis.__KISS_CONTEXT__ = \"popup\";\n\nconst root = ReactDOM.createRoot(document.getElementById(\"root\"));\nroot.render(\n  <React.StrictMode>\n    <SettingProvider context=\"popup\">\n      <ThemeProvider>\n        <Popup />\n      </ThemeProvider>\n    </SettingProvider>\n  </React.StrictMode>\n);\n"
  },
  {
    "path": "src/rules.js",
    "content": "import fs from \"fs\";\nimport path from \"path\";\nimport { BUILTIN_RULES } from \"./config/rules\";\n\n(() => {\n  // rules\n  try {\n    const data = JSON.stringify(BUILTIN_RULES, null, 2);\n    const file = path.resolve(\n      __dirname,\n      \"../build/web/kiss-translator-rules.json\"\n    );\n    fs.writeFileSync(file, data);\n    console.info(`Built-in rules generated: ${file}`);\n  } catch (err) {\n    console.error(err);\n  }\n\n  // version\n  try {\n    var pjson = require(\"../package.json\");\n    const file = path.resolve(__dirname, \"../build/web/version.txt\");\n    fs.writeFileSync(file, pjson.version);\n    console.info(`Version file generated: ${file}`);\n  } catch (err) {\n    console.error(err);\n  }\n})();\n"
  },
  {
    "path": "src/scripts/archive.mjs",
    "content": "#!/usr/bin/env zx\n\nconsole.log(chalk.cyan(\"\\nStarting compression tasks...\\n\"));\n\n// 1. 进入 build 目录\ncd(\"build\");\n\n// 2. 清理旧的 zip 文件\nawait $`npx shx rm -f *.zip`;\n\n/**\n * 定义打包任务配置\n * * @property {string} output - 输出文件名\n * @property {string} source - 要打包的源（文件或目录名）\n * @property {string} [cwd]  - (可选) 执行打包命令时所在的目录。\n */\nconst tasks = [\n  { output: \"chrome.zip\", source: \"chrome\" },\n  { output: \"edge.zip\", source: \"edge\" },\n  { output: \"userscript.zip\", source: \"userscript\" },\n  {\n    output: \"../firefox.zip\",\n    source: \"*\",\n    cwd: \"firefox\",\n  },\n  {\n    output: \"../thunderbird.zip\",\n    source: \"*\",\n    cwd: \"thunderbird\",\n  },\n];\n\ntry {\n  for (const task of tasks) {\n    if (task.cwd) {\n      // === 特殊打包：进入目录内部打包 (Firefox/Thunderbird) ===\n      const originalCwd = process.cwd(); // 记录当前位置 (build/)\n\n      // 1. 进入子目录\n      cd(task.cwd);\n      console.log(`Zipping contents of ${task.cwd} (flat structure)...`);\n\n      // 2. 执行打包: 将当前目录所有文件 (*) 打包到父级目录的 zip 中\n      await $`npx bestzip ${task.output} *`;\n\n      // 3. 回到原目录\n      cd(originalCwd);\n    } else {\n      // === 普通打包：打包文件夹本身 (Chrome/Edge) ===\n      console.log(`Zipping folder ${task.source}...`);\n      await $`npx bestzip ${task.output} ${task.source}`;\n    }\n  }\n  console.log(chalk.green(\"\\n✅ All zip files created successfully.\"));\n} catch (err) {\n  console.error(chalk.red(\"❌ Error during zipping:\"), err);\n  process.exit(1);\n}\n"
  },
  {
    "path": "src/scripts/build-ios.mjs",
    "content": "#!/usr/bin/env zx\n\nconsole.log(chalk.cyan(\"\\nBuilding iOS Userscript...\\n\"));\n\nconst srcFile = \"build/web/kiss-translator.user.js\";\nconst destFile = \"build/web/kiss-translator-ios-safari.user.js\";\nconst userscriptDir = \"build/userscript\"; // 目标汇总目录\n\ntry {\n  // 1. 检查源文件\n  if (!fs.existsSync(srcFile)) {\n    throw new Error(\n      `Source file not found: ${srcFile}. Run 'pnpm build:web' first.`\n    );\n  }\n\n  // 2. 复制并重命名\n  await fs.copy(srcFile, destFile);\n\n  // 3. 读取并替换内容\n  let content = await fs.readFile(destFile, \"utf-8\");\n\n  const oldStr = \"// @grant         unsafeWindow\";\n  const newStr = \"// @inject-into   content\";\n\n  if (!content.includes(oldStr)) {\n    console.warn(chalk.yellow(`Warning: Pattern \"${oldStr}\" not found.`));\n  }\n\n  content = content.replace(new RegExp(oldStr, \"g\"), newStr);\n\n  // 4. 写入原 Web 目录\n  await fs.writeFile(destFile, content, \"utf-8\");\n\n  // 5. 同时复制一份到 userscript 目录\n  await fs.ensureDir(userscriptDir);\n  const iosDestInUserscript = path.join(userscriptDir, path.basename(destFile));\n  await fs.copy(destFile, iosDestInUserscript);\n\n  console.log(chalk.green(`✅ iOS Userscript generated at: ${destFile}`));\n  console.log(chalk.green(`✅ Copied to: ${iosDestInUserscript}`));\n} catch (err) {\n  console.error(chalk.red(\"❌ Error building iOS userscript:\"), err);\n  process.exit(1);\n}\n"
  },
  {
    "path": "src/scripts/build-safari.js",
    "content": "import { $, globby } from \"zx\";\nimport path from \"node:path\";\nimport fs from \"node:fs/promises\";\nimport dotenv from \"dotenv\";\nimport { findUp } from \"find-up\";\n\nasync function main() {\n  const rootPath = path.dirname(await findUp(\"package.json\"));\n  dotenv.config({ path: path.resolve(rootPath, \".env.local\") });\n  // https://github.com/vitejs/vite/issues/5885\n  process.env.NODE_ENV = \"production\";\n\n  const ProjectName = \"Kiss Translator\";\n  const AppCategory = \"public.app-category.productivity\";\n  const Identifier = \"com.fishjar.kiss-translator\";\n  const DevelopmentTeam = process.env.DEVELOPMENT_TEAM;\n  const DistPath = \"build\";\n\n  await $`pnpm build:safari-output`;\n  await $`xcrun safari-web-extension-converter --bundle-identifier ${Identifier} --force --project-location ${DistPath} build/safari`;\n  async function updateProjectConfig() {\n    const projectConfigPath = path.resolve(\n      rootPath,\n      `${DistPath}/${ProjectName}/${ProjectName}.xcodeproj/project.pbxproj`\n    );\n    const packageJson = JSON.parse(\n      await fs.readFile(path.resolve(rootPath, \"package.json\"))\n    );\n    const content = await fs.readFile(projectConfigPath, \"utf-8\");\n    const newContent = content\n      .replaceAll(\n        \"MARKETING_VERSION = 1.0;\",\n        `MARKETING_VERSION = ${packageJson.version};`\n      )\n      .replace(\n        new RegExp(\n          `INFOPLIST_KEY_CFBundleDisplayName = (\"?${ProjectName}\"?);`,\n          \"g\"\n        ),\n        `INFOPLIST_KEY_CFBundleDisplayName = $1;\\n\t\t\t\tINFOPLIST_KEY_LSApplicationCategoryType = \"${AppCategory}\";`\n      )\n      .replace(\n        new RegExp(\n          `INFOPLIST_KEY_CFBundleDisplayName = (\"?${ProjectName}\"?);`,\n          \"g\"\n        ),\n        `INFOPLIST_KEY_CFBundleDisplayName = $1;\\n\t\t\t\tINFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;`\n      )\n      .replaceAll(\n        `COPY_PHASE_STRIP = NO;`,\n        DevelopmentTeam\n          ? `COPY_PHASE_STRIP = NO;\\n\t\t\t\tDEVELOPMENT_TEAM = ${DevelopmentTeam};`\n          : \"COPY_PHASE_STRIP = NO;\"\n      )\n      .replace(\n        /CURRENT_PROJECT_VERSION = \\d+;/g,\n        `CURRENT_PROJECT_VERSION = ${parseProjectVersion(packageJson.version)};`\n      );\n    await fs.writeFile(projectConfigPath, newContent);\n  }\n\n  async function updateInfoPlist() {\n    const projectPath = path.resolve(rootPath, DistPath, ProjectName);\n    const files = await globby(\"**/*.plist\", {\n      cwd: projectPath,\n    });\n    for (const file of files) {\n      const content = await fs.readFile(\n        path.resolve(projectPath, file),\n        \"utf-8\"\n      );\n      await fs.writeFile(\n        path.resolve(projectPath, file),\n        content.replaceAll(\n          \"</dict>\\n</plist>\",\n          \"\t<key>CFBundleVersion</key>\\n\t<string>$(CURRENT_PROJECT_VERSION)</string>\\n</dict>\\n</plist>\"\n        )\n      );\n    }\n  }\n\n  function parseProjectVersion(version) {\n    const [major, minor, patch] = version.split(\".\").map(Number);\n    return major * 10000 + minor * 100 + patch;\n  }\n\n  await updateProjectConfig();\n  await updateInfoPlist();\n}\n\nmain();\n"
  },
  {
    "path": "src/scripts/build-safari.mjs",
    "content": "#!/usr/bin/env zx\nimport { $, globby } from \"zx\";\nimport path from \"node:path\";\nimport fs from \"node:fs/promises\";\nimport dotenv from \"dotenv\";\nimport { findUp } from \"find-up\";\n\n// 打开详细日志，方便调试\n$.verbose = true;\n\nasync function main() {\n  // 1. 初始化路径与配置\n  const packageJsonPath = await findUp(\"package.json\");\n  if (!packageJsonPath) throw new Error(\"Could not find package.json\");\n\n  const rootPath = path.dirname(packageJsonPath);\n\n  // 加载环境变量\n  dotenv.config({ path: path.join(rootPath, \".env.local\") });\n\n  // 从 package.json 读取版本\n  const pkg = JSON.parse(await fs.readFile(packageJsonPath, \"utf-8\"));\n\n  // 2. 集中配置项\n  const CONFIG = {\n    projectName: \"Kiss Translator\",\n    identifier: \"com.fishjar.kiss-translator\",\n    appCategory: \"public.app-category.productivity\",\n    developmentTeam: process.env.DEVELOPMENT_TEAM, // 如果没有设置，后续逻辑会处理\n    distPath: \"build\",\n    sourcePath: \"build/safari\", // Web Extension 产物位置\n    version: pkg.version,\n  };\n\n  // 设置环境变量\n  process.env.NODE_ENV = \"production\";\n\n  console.log(`🚀 开始构建: ${CONFIG.projectName} v${CONFIG.version}`);\n\n  // 3. 执行构建命令\n  // 确保构建目录存在\n  await $`pnpm build:safari-output`;\n\n  // 转换项目 (注意：--force 会覆盖已存在的项目)\n  await $`xcrun safari-web-extension-converter --bundle-identifier ${CONFIG.identifier} --force --project-location ${CONFIG.distPath} ${CONFIG.sourcePath}`;\n\n  /**\n   * 核心逻辑：修改 Xcode 工程配置 (project.pbxproj)\n   */\n  async function updateProjectConfig() {\n    const projectPbxPath = path.join(\n      rootPath,\n      CONFIG.distPath,\n      CONFIG.projectName,\n      `${CONFIG.projectName}.xcodeproj`,\n      \"project.pbxproj\"\n    );\n\n    let content = await fs.readFile(projectPbxPath, \"utf-8\");\n\n    // 预先计算 Project Version (例如: 1.2.3 -> 10203)\n    const projectVersionInt = parseProjectVersion(CONFIG.version);\n\n    // 准备要注入的 Info.plist 键值对\n    const additionalInfoKeys = [\n      `INFOPLIST_KEY_LSApplicationCategoryType = \"${CONFIG.appCategory}\";`,\n      `INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;`,\n    ].join(\"\\n\\t\\t\"); // 使用 Xcode 风格的缩进\n\n    // --- 开始替换 ---\n\n    // 1. 替换 Marketing Version\n    content = content.replace(\n      /MARKETING_VERSION = .*?;/g,\n      `MARKETING_VERSION = ${CONFIG.version};`\n    );\n\n    // 2. 替换 Project Version\n    content = content.replace(\n      /CURRENT_PROJECT_VERSION = \\d+;/g,\n      `CURRENT_PROJECT_VERSION = ${projectVersionInt};`\n    );\n\n    // 3. 注入 Development Team (如果有)\n    if (CONFIG.developmentTeam) {\n      // 查找 COPY_PHASE_STRIP，在其后插入 TEAM ID\n      // 使用更宽松的正则来匹配可能的空白字符\n      content = content.replace(\n        /(COPY_PHASE_STRIP = NO;)/g,\n        `$1\\n\\t\\t\\t\\tDEVELOPMENT_TEAM = ${CONFIG.developmentTeam};`\n      );\n    }\n\n    // 4. 注入 InfoPlist 额外配置\n    // 原逻辑是在 DisplayName 后追加。这里合并操作，只替换一次，避免重复查找。\n    // 匹配: INFOPLIST_KEY_CFBundleDisplayName = \"Name\";\n    const displayNameRegex = new RegExp(\n      `(INFOPLIST_KEY_CFBundleDisplayName = \"${CONFIG.projectName}\";)`,\n      \"g\"\n    );\n    content = content.replace(\n      displayNameRegex,\n      `$1\\n\\t\\t${additionalInfoKeys}`\n    );\n\n    await fs.writeFile(projectPbxPath, content);\n    console.log(\"✅ Xcode 项目配置已更新\");\n  }\n\n  /**\n   * 核心逻辑：修改 Info.plist\n   */\n  async function updateInfoPlist() {\n    const projectDir = path.join(rootPath, CONFIG.distPath, CONFIG.projectName);\n    const files = await globby(\"**/*.plist\", {\n      cwd: projectDir,\n      absolute: true,\n    });\n\n    // 构造要插入的 XML 片段\n    const versionXml = `\n    <key>CFBundleVersion</key>\n    <string>$(CURRENT_PROJECT_VERSION)</string>`;\n\n    for (const file of files) {\n      let content = await fs.readFile(file, \"utf-8\");\n\n      // 使用正则精准匹配文件末尾的 closing tags，忽略空白符差异\n      // 替换 </dict>\\n</plist> 为 新内容 + 闭合标签\n      if (!content.includes(\"<key>CFBundleVersion</key>\")) {\n        content = content.replace(\n          /\\s*<\\/dict>\\s*<\\/plist>\\s*$/,\n          `${versionXml}\\n</dict>\\n</plist>`\n        );\n        await fs.writeFile(file, content);\n      }\n    }\n    console.log(`✅ 已更新 ${files.length} 个 Info.plist 文件`);\n  }\n\n  await updateProjectConfig();\n  await updateInfoPlist();\n\n  console.log(\"🎉 构建完成！\");\n}\n\nfunction parseProjectVersion(version) {\n  const [major, minor, patch] = version.split(\".\").map(Number);\n  // 处理 NaN 情况，防止版本号格式错误导致 NaN\n  return (major || 0) * 10000 + (minor || 0) * 100 + (patch || 0);\n}\n\nmain().catch((err) => {\n  console.error(\"❌ 构建失败:\", err);\n  process.exit(1);\n});\n"
  },
  {
    "path": "src/scripts/build-task.mjs",
    "content": "#!/usr/bin/env zx\nimport { argv, quote, $ } from \"zx\";\n\n// 在 Windows 上使用 cmd.exe，避免 zx 默认使用 WSL bash 导致 node not found\nif (process.platform === \"win32\") {\n  $.shell = \"cmd.exe\";\n  $.prefix = \"\";\n  $.quote = quote\n}\n\n// 用法: zx src/scripts/build-task.mjs --target=chrome\nconst target = argv.target;\n\nif (!target) {\n  console.error(\n    chalk.red(\"Error: Please specify a target, e.g., --target=chrome\")\n  );\n  process.exit(1);\n}\n\nconst buildRoot = \"build\";\nconst targetDir = path.join(buildRoot, target);\n\n// 辅助：获取构建目录下的文件路径\nconst inDest = (file) => path.join(targetDir, file);\n\nconsole.log(chalk.blue(`\\n🚀 Starting build task for: ${chalk.bold(target)}`));\n\ntry {\n  // 1. 【清理】 清空当前目标的构建目录\n  await fs.remove(targetDir);\n\n  // 2. 【构建】 区分普通构建和特殊构建（如 Edge）\n  if (target === \"edge\") {\n    // Edge 特殊逻辑：直接复制 Chrome 构建结果\n    const chromeDir = path.join(buildRoot, \"chrome\");\n    if (!(await fs.pathExists(chromeDir))) {\n      throw new Error(\n        'Chrome build not found! Please run \"pnpm build:chrome\" first.'\n      );\n    }\n    await fs.copy(chromeDir, targetDir);\n    console.log(chalk.green(\"✅ Copied Chrome build to Edge.\"));\n  } else {\n    // 标准 React 构建流程\n    const clientEnv = target === \"web\" ? \"userscript\" : target;\n\n    process.env.BUILD_PATH = `./${targetDir}`;\n    process.env.REACT_APP_CLIENT = clientEnv;\n    process.env.FORCE_COLOR = \"1\";\n\n    console.log(chalk.gray(`Running react-app-rewired build...`));\n    await $`react-app-rewired build`;\n  }\n\n  // 3. 【后处理】 文件清理与移动\n  console.log(chalk.gray(`Running post-build cleanups...`));\n\n  // -----------------------------------------------------------------------\n  // 场景 A: Chrome, Edge, Safari (标准扩展)\n  // -----------------------------------------------------------------------\n  if ([\"chrome\", \"edge\", \"safari\"].includes(target)) {\n    // 1. 清理 HTML\n    await fs.remove(inDest(\"content.html\"));\n\n    // 2. 清理多余的 Firefox/Thunderbird manifest\n    await fs.remove(inDest(\"manifest.firefox.json\"));\n    await fs.remove(inDest(\"manifest.thunderbird.json\"));\n  }\n\n  // -----------------------------------------------------------------------\n  // 场景 B: Firefox, Thunderbird (需要替换 Manifest)\n  // -----------------------------------------------------------------------\n  if ([\"firefox\", \"thunderbird\"].includes(target)) {\n    await fs.remove(inDest(\"content.html\"));\n\n    const specificManifest = inDest(`manifest.${target}.json`);\n    const finalManifest = inDest(\"manifest.json\");\n\n    if (await fs.pathExists(specificManifest)) {\n      await fs.move(specificManifest, finalManifest, { overwrite: true });\n    }\n\n    // 清理所有残留的 manifest.*.json\n    const files = await fs.readdir(targetDir);\n    for (const f of files) {\n      if (f.startsWith(\"manifest.\") && f !== \"manifest.json\") {\n        await fs.remove(inDest(f));\n      }\n    }\n  }\n\n  // -----------------------------------------------------------------------\n  // 场景 C: Web (Userscript)\n  // -----------------------------------------------------------------------\n  if (target === \"web\") {\n    // 1. Web 版不需要任何 manifest 文件\n    const filesInDir = await fs.readdir(targetDir);\n    for (const f of filesInDir) {\n      if (f.startsWith(\"manifest\") && f.endsWith(\".json\")) {\n        await fs.remove(inDest(f));\n      }\n    }\n\n    // 2. 将生成的普通 userscript 复制到 userscript 汇总目录\n    const userscriptDir = path.join(buildRoot, \"userscript\");\n    await fs.ensureDir(userscriptDir);\n\n    for (const f of filesInDir) {\n      // 重新遍历，因为上面可能删除了文件\n      if (f.endsWith(\".user.js\")) {\n        await fs.copy(inDest(f), path.join(userscriptDir, f));\n      }\n    }\n  }\n\n  console.log(\n    chalk.green(`✅ Build task for [${target}] completed successfully!`)\n  );\n} catch (err) {\n  console.error(chalk.red(`\\n❌ Build failed for ${target}:`));\n  console.error(err);\n  process.exit(1);\n}\n"
  },
  {
    "path": "src/scripts/sync-version.mjs",
    "content": "#!/usr/bin/env zx\nimport { $ } from \"zx\";\n\n/**\n * 版本号同步脚本\n * 从 package.json 读取版本号，自动同步到其他配置文件\n */\n\nconst rootDir = path.resolve(__dirname, \"../..\");\n\n// 读取 package.json 中的版本号\nconst pkgPath = path.join(rootDir, \"package.json\");\nconst pkg = await fs.readJSON(pkgPath);\nconst version = pkg.version;\n\nconsole.log(chalk.blue(`📦 从 package.json 读取版本号: ${chalk.bold(version)}`));\n\n// 需要同步的文件列表\nconst filesToSync = [\n    {\n        path: path.join(rootDir, \".env\"),\n        type: \"env\",\n        pattern: /^REACT_APP_VERSION=.+$/m,\n        replacement: `REACT_APP_VERSION=${version}`,\n    },\n    {\n        path: path.join(rootDir, \"public/manifest.json\"),\n        type: \"json\",\n        key: \"version\",\n    },\n    {\n        path: path.join(rootDir, \"public/manifest.firefox.json\"),\n        type: \"json\",\n        key: \"version\",\n    },\n    {\n        path: path.join(rootDir, \"public/manifest.thunderbird.json\"),\n        type: \"json\",\n        key: \"version\",\n    },\n];\n\nlet syncCount = 0;\n\n// 遍历并更新每个文件\nfor (const file of filesToSync) {\n    try {\n        if (file.type === \"env\") {\n            // 处理 .env 文件\n            let content = await fs.readFile(file.path, \"utf-8\");\n            const newContent = content.replace(file.pattern, file.replacement);\n\n            if (content !== newContent) {\n                await fs.writeFile(file.path, newContent, \"utf-8\");\n                console.log(chalk.green(`✅ 已更新: ${path.relative(rootDir, file.path)}`));\n                syncCount++;\n            } else {\n                console.log(chalk.gray(`⏭️  无需更新: ${path.relative(rootDir, file.path)}`));\n            }\n        } else if (file.type === \"json\") {\n            // 处理 JSON 文件\n            const jsonData = await fs.readJSON(file.path);\n\n            if (jsonData[file.key] !== version) {\n                jsonData[file.key] = version;\n                await fs.writeJSON(file.path, jsonData, { spaces: 2 });\n                console.log(chalk.green(`✅ 已更新: ${path.relative(rootDir, file.path)}`));\n                syncCount++;\n            } else {\n                console.log(chalk.gray(`⏭️  无需更新: ${path.relative(rootDir, file.path)}`));\n            }\n        }\n    } catch (err) {\n        console.error(chalk.red(`❌ 更新失败: ${path.relative(rootDir, file.path)}`));\n        console.error(err.message);\n    }\n}\n\nconsole.log(chalk.blue(`\\n🎉 版本号同步完成！共更新 ${syncCount} 个文件到版本 ${chalk.bold(version)}`));\n"
  },
  {
    "path": "src/scripts/update-version.mjs",
    "content": "#!/usr/bin/env zx\nimport { $, argv } from \"zx\";\n\n/**\n * 版本号更新脚本\n * 使用 npm version 命令更新 package.json 中的版本号，然后自动同步到其他文件\n * \n * 用法:\n *   pnpm version:patch  // 2.0.19 -> 2.0.20\n *   pnpm version:minor  // 2.0.19 -> 2.1.0\n *   pnpm version:major  // 2.0.19 -> 3.0.0\n *   pnpm version:set -- 2.1.0  // 手动指定版本号\n */\n\nconst rootDir = path.resolve(__dirname, \"../..\");\nconst versionType = argv._[0] || argv.type || \"patch\";\n\nconsole.log(chalk.blue(`\\n🚀 开始更新版本号...\\n`));\n\ntry {\n    // 读取当前版本\n    const pkgPath = path.join(rootDir, \"package.json\");\n    const pkg = await fs.readJSON(pkgPath);\n    const oldVersion = pkg.version;\n\n    console.log(chalk.gray(`当前版本: ${oldVersion}`));\n\n    // 使用 npm version 更新 package.json\n    // --no-git-tag-version 参数防止自动创建 git tag\n    if (versionType === \"set\") {\n        const newVersion = argv._[1];\n        if (!newVersion) {\n            console.error(chalk.red(\"❌ 错误: 请指定版本号，例如: pnpm version:set -- 2.1.0\"));\n            process.exit(1);\n        }\n        await $`npm version ${newVersion} --no-git-tag-version`;\n    } else {\n        await $`npm version ${versionType} --no-git-tag-version`;\n    }\n\n    // 重新读取更新后的版本\n    const updatedPkg = await fs.readJSON(pkgPath);\n    const newVersion = updatedPkg.version;\n\n    console.log(chalk.green(`✅ package.json 版本已更新: ${oldVersion} -> ${newVersion}\\n`));\n\n    // 同步到其他文件\n    console.log(chalk.blue(`📦 开始同步版本号到其他文件...\\n`));\n    await $`zx src/scripts/sync-version.mjs`;\n\n    console.log(chalk.green.bold(`\\n✨ 版本更新完成！新版本: ${newVersion}\\n`));\n    console.log(chalk.gray(`提示: 别忘了更新 CHANGELOG.md 并提交更改\\n`));\n\n} catch (err) {\n    console.error(chalk.red(\"\\n❌ 版本更新失败:\"));\n    console.error(err.message);\n    process.exit(1);\n}\n"
  },
  {
    "path": "src/subtitle/BilingualSubtitleManager.js",
    "content": "import { logger } from \"../libs/log.js\";\nimport { truncateWords, throttle } from \"../libs/utils.js\";\nimport { apiTranslate } from \"../apis/index.js\";\nimport { apiMicrosoftDict } from \"../apis/index.js\";\nimport { trustedTypesHelper } from \"../libs/trustedTypes.js\";\nimport { isMobile } from \"../libs/mobile.js\";\n\n// 添加CSS样式用于高亮显示悬停的单词\nconst addWordHoverStyles = () => {\n  if (document.getElementById(\"kiss-word-hover-styles\")) return;\n\n  const style = document.createElement(\"style\");\n  style.id = \"kiss-word-hover-styles\";\n  style.textContent = `\n    .kiss-word-hover {\n      cursor: pointer;\n      text-decoration: underline;\n      text-decoration-color: #4fc3f7;\n      text-decoration-thickness: 2px;\n    }\n    \n    .kiss-word-tooltip {\n      position: fixed;\n      background: rgba(0, 0, 0, 0.9);\n      color: white;\n      border-radius: 6px;\n      padding: 12px;\n      font-size: 14px;\n      z-index: 2147483647;\n      max-width: 300px;\n      word-wrap: break-word;\n      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);\n      backdrop-filter: blur(4px);\n      border: 1px solid rgba(255, 255, 255, 0.1);\n      font-family: Arial, sans-serif;\n    }\n    \n    .kiss-word-tooltip-header {\n      display: flex;\n      justify-content: space-between;\n      align-items: center;\n      margin-bottom: 8px;\n      font-weight: bold;\n      font-size: 16px;\n      color: #4fc3f7;\n    }\n    \n    .kiss-word-tooltip-close {\n      background: none;\n      border: none;\n      color: #aaa;\n      cursor: pointer;\n      font-size: 18px;\n      padding: 0;\n      margin-left: 10px;\n      width: 24px;\n      height: 24px;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n    }\n    \n    .kiss-word-tooltip-close:hover {\n      color: white;\n      background: rgba(255, 255, 255, 0.1);\n      border-radius: 50%;\n    }\n    \n    .kiss-word-loading {\n      color: #bbb;\n      font-style: italic;\n    }\n    \n    .kiss-word-definition {\n      margin: 4px 0;\n    }\n    \n    .kiss-word-pos {\n      color: #4fc3f7;\n      font-weight: bold;\n    }\n    \n    .kiss-word-phonetic {\n      color: #bbb;\n      font-style: italic;\n      margin-right: 10px;\n    }\n    \n    .kiss-word-example {\n      margin-top: 10px;\n      padding-top: 8px;\n      border-top: 1px solid #444;\n    }\n    \n    .kiss-word-example-title {\n      font-weight: bold;\n      margin-bottom: 5px;\n    }\n    \n    .kiss-word-example-sentence {\n      margin-bottom: 3px;\n    }\n    \n    .kiss-word-example-translation {\n      color: #bbb;\n      font-style: italic;\n    }\n  `;\n  document.head.appendChild(style);\n};\n\n/**\n * @class BilingualSubtitleManager\n * @description 负责在视频上显示和翻译字幕的核心逻辑\n */\nexport class BilingualSubtitleManager {\n  #videoEl;\n  #formattedSubtitles = [];\n  #captionWindowEl = null;\n  #paperEl = null;\n  #currentSubtitleIndex = -1;\n  // #preTranslateSeconds = 90;\n  // #throttleSeconds = 30;\n  #setting = {};\n  #isAdPlaying = false;\n  #throttledTriggerTranslations;\n  #tooltipEl = null;\n  #hoverTimeout = null; // 用于延迟显示/隐藏tooltip\n  #wasPlayingBeforeHover = false; //记录hover单词前视频是否处于播放状态\n  #hoverTarget = null;\n\n  /**\n   * @param {object} options\n   * @param {HTMLVideoElement} options.videoEl - 页面上的 video 元素。\n   * @param {Array<object>} options.formattedSubtitles - 已格式化好的字幕数组。\n   * @param {object} options.setting - 配置对象，如目标翻译语言。\n   */\n  constructor({ videoEl, formattedSubtitles, setting }) {\n    this.#setting = setting;\n    this.#videoEl = videoEl;\n    this.#formattedSubtitles = formattedSubtitles;\n\n    this.onTimeUpdate = this.onTimeUpdate.bind(this);\n    this.onSeek = this.onSeek.bind(this);\n\n    this.#throttledTriggerTranslations = throttle(\n      this.#triggerTranslations.bind(this),\n      (setting.throttleTrans ?? 30) * 1000\n    );\n\n    // todo: 使用 @emotion/css\n    const enhanceMode = this.#setting.enhanceMode ?? \"mobile_off\";\n    const isEnhance =\n      enhanceMode === \"on\" || (enhanceMode === \"mobile_off\" && !isMobile);\n\n    if (isEnhance) {\n      addWordHoverStyles();\n    }\n  }\n\n  /**\n   * 启动字幕显示和翻译。\n   */\n  start() {\n    if (this.#formattedSubtitles.length === 0) {\n      logger.warn(\"Bilingual Subtitles: No subtitles to display.\");\n      return;\n    }\n\n    logger.info(\"Bilingual Subtitle Manager: Starting...\");\n    this.#createCaptionWindow();\n    this.#attachEventListeners();\n    this.onTimeUpdate();\n  }\n\n  /**\n   * 销毁实例，清理资源。\n   */\n  destroy() {\n    logger.info(\"Bilingual Subtitle Manager: Destroying...\");\n    this.#removeEventListeners();\n    this.#throttledTriggerTranslations?.cancel();\n    this.#captionWindowEl?.parentElement?.parentElement?.remove();\n    this.#formattedSubtitles = [];\n    // 清理tooltip元素\n    if (this.#tooltipEl) {\n      this.#tooltipEl.remove();\n      this.#tooltipEl = null;\n    }\n\n    // 清理定时器\n    if (this.#hoverTimeout) {\n      clearTimeout(this.#hoverTimeout);\n      this.#hoverTimeout = null;\n    }\n  }\n\n  /**\n   * 更新广告播放状态。\n   */\n  setIsAdPlaying(isPlaying) {\n    this.#isAdPlaying = isPlaying;\n    this.onTimeUpdate();\n  }\n\n  /**\n   * 创建并配置用于显示字幕的 DOM 元素。\n   */\n  #createCaptionWindow() {\n    const container = document.createElement(\"div\");\n    container.className = `kiss-caption-container notranslate`;\n    Object.assign(container.style, {\n      position: \"absolute\",\n      width: \"100%\",\n      height: \"100%\",\n      left: \"0\",\n      top: \"0\",\n      pointerEvents: \"none\",\n    });\n\n    const paper = document.createElement(\"div\");\n    paper.className = `kiss-caption-paper`;\n    Object.assign(paper.style, {\n      position: \"absolute\",\n      width: \"80%\",\n      left: \"50%\",\n      bottom: \"10%\",\n      transform: \"translateX(-50%)\",\n      textAlign: \"center\",\n      containerType: \"inline-size\",\n      zIndex: \"2147483647\",\n      pointerEvents: \"auto\",\n      display: \"none\",\n    });\n    this.#paperEl = paper;\n\n    this.#captionWindowEl = document.createElement(\"div\");\n    this.#captionWindowEl.className = `kiss-caption-window`;\n    this.#captionWindowEl.style.cssText = this.#setting.windowStyle;\n    this.#captionWindowEl.style.pointerEvents = \"auto\";\n    this.#captionWindowEl.style.cursor = \"grab\";\n    this.#captionWindowEl.style.opacity = \"1\";\n\n    this.#paperEl.appendChild(this.#captionWindowEl);\n    container.appendChild(this.#paperEl);\n\n    const videoContainer = this.#videoEl.parentElement?.parentElement;\n    if (!videoContainer) {\n      logger.warn(\"could not find videoContainer\");\n      return;\n    }\n\n    videoContainer.style.position = \"relative\";\n    videoContainer.appendChild(container);\n\n    const enhanceMode = this.#setting.enhanceMode ?? \"mobile_off\";\n    const isEnhance =\n      enhanceMode === \"on\" || (enhanceMode === \"mobile_off\" && !isMobile);\n\n    this.#enableDragging(this.#paperEl, container, this.#captionWindowEl);\n\n    if (isEnhance) {\n      this.#captionWindowEl.addEventListener(\"pointerenter\", (e) => {\n        if (e.target === this.#captionWindowEl) {\n          this.#wasPlayingBeforeHover = this.#videoEl && !this.#videoEl.paused;\n          if (this.#videoEl && !this.#videoEl.paused) {\n            this.#videoEl.pause();\n          }\n        }\n      });\n\n      this.#captionWindowEl.addEventListener(\"pointerleave\", (e) => {\n        if (e.target === this.#captionWindowEl) {\n          if (\n            this.#wasPlayingBeforeHover &&\n            this.#videoEl &&\n            this.#videoEl.paused\n          ) {\n            this.#videoEl.play();\n          }\n          this.#wasPlayingBeforeHover = false;\n          this.#hoverTarget = null;\n        }\n      });\n    }\n  }\n\n  // 处理单词悬停事件\n  #handleWordHover(event) {\n    const target = event.target;\n    if (target.classList.contains(\"kiss-subtitle-word\")) {\n      // 清除之前的定时器\n      if (this.#hoverTimeout) {\n        clearTimeout(this.#hoverTimeout);\n        this.#hoverTimeout = null;\n      }\n\n      target.classList.add(\"kiss-word-hover\");\n\n      // 延迟显示tooltip，避免误触\n      this.#hoverTimeout = setTimeout(() => {\n        this.#showWordTooltip(\n          target.dataset.word,\n          event.clientX,\n          event.clientY\n        );\n      }, 300);\n    }\n  }\n\n  // 处理鼠标移出事件\n  #handleWordHoverOut(event) {\n    const target = event.target;\n    if (target.classList.contains(\"kiss-subtitle-word\")) {\n      target.classList.remove(\"kiss-word-hover\");\n\n      // 清除显示定时器\n      if (this.#hoverTimeout) {\n        clearTimeout(this.#hoverTimeout);\n        this.#hoverTimeout = null;\n      }\n\n      // 延迟隐藏tooltip\n      this.#hoverTimeout = setTimeout(() => {\n        this.#hideWordTooltip();\n      }, 100);\n    }\n  }\n\n  // 处理鼠标移动事件\n  #handleWordMouseMove(event) {\n    // 不再跟随鼠标移动，保持tooltip在固定位置\n    // 移除之前的逻辑\n  }\n\n  #attachSpanListeners() {\n    if (!this.#captionWindowEl) return;\n    const spans = this.#captionWindowEl.querySelectorAll(\".kiss-subtitle-word\");\n    spans.forEach((span) => {\n      if (span.dataset.kissListenerAttached) return;\n      const enterHandler = (e) => this.#handleWordHover(e);\n      const leaveHandler = (e) => this.#handleWordHoverOut(e);\n      span.addEventListener(\"pointerenter\", enterHandler);\n      span.addEventListener(\"pointerleave\", leaveHandler);\n      span.dataset.kissListenerAttached = \"1\";\n    });\n  }\n\n  // 显示单词提示框\n  async #showWordTooltip(word, x, y) {\n    // 如果已经存在提示框，则先移除\n    if (this.#tooltipEl) {\n      this.#tooltipEl.remove();\n    }\n\n    // 创建提示框\n    this.#tooltipEl = document.createElement(\"div\");\n    this.#tooltipEl.className = \"kiss-word-tooltip\";\n    this.#tooltipEl.innerHTML = trustedTypesHelper.createHTML(\n      '<div class=\"kiss-word-loading\">Looking up...</div>'\n    );\n\n    // 将提示框定位在播放器右上角\n    const videoContainer = this.#videoEl.parentElement?.parentElement;\n    if (videoContainer) {\n      const containerRect = videoContainer.getBoundingClientRect();\n      const tooltipWidth = 300;\n      const tooltipHeight = 400;\n\n      // 定位在播放器右上角，距离右边缘45px，上下边缘各20px\n      const left = containerRect.right - tooltipWidth - 45;\n      const top = containerRect.top + 20;\n\n      // 确保提示框不会超出浏览器窗口右边界\n      const maxLeft = window.innerWidth - tooltipWidth - 10;\n      this.#tooltipEl.style.left = Math.min(maxLeft, Math.max(10, left)) + \"px\";\n      this.#tooltipEl.style.top = Math.max(10, top) + \"px\";\n      this.#tooltipEl.style.maxWidth = tooltipWidth + \"px\";\n      this.#tooltipEl.style.maxHeight = tooltipHeight + \"px\";\n      this.#tooltipEl.style.overflow = \"auto\";\n    }\n\n    document.body.appendChild(this.#tooltipEl);\n\n    try {\n      // 获取单词翻译\n      const dictResult = await apiMicrosoftDict(word);\n\n      // 构造美式音标字符串\n      let phonetic = \"\";\n      if (dictResult && dictResult.aus) {\n        // 只使用美式音标，去除\"美\"标签和方括号\n        const usPhonetic = dictResult.aus.find((au) => au.key === \"美\");\n        if (usPhonetic && usPhonetic.phonetic) {\n          phonetic = usPhonetic.phonetic;\n        } else if (dictResult.aus.length > 0 && dictResult.aus[0].phonetic) {\n          // 如果没有明确标记为\"美\"的音标，使用第一个音标\n          phonetic = dictResult.aus[0].phonetic;\n        }\n      }\n\n      // 构造释义字符串\n      let definition = \"\";\n      if (dictResult && dictResult.trs) {\n        definition = dictResult.trs\n          .slice(0, 3)\n          .map((tr) => `${tr.pos ? tr.pos + \" \" : \"\"}${tr.def}`)\n          .join(\"; \");\n      }\n\n      // 构造例句数组\n      let examples = [];\n      if (dictResult && dictResult.sentences) {\n        examples = dictResult.sentences.slice(0, 2).map((sentence) => ({\n          eng: sentence.eng,\n          chs: sentence.chs,\n        }));\n      }\n\n      // 获取当前字幕的时间戳（使用重新分段后的时间）\n      const currentTimeMs = this.#getCurrentSubtitleStartTime();\n\n      // 添加单词和完整信息到生词本\n      const event = new CustomEvent(\"kiss-add-word\", {\n        detail: {\n          word,\n          phonetic, // 现在只包含音标本身，如 ɪnˈkredəb(ə)l\n          definition,\n          examples,\n          timestamp: currentTimeMs, // 添加时间戳\n        },\n      });\n      document.dispatchEvent(event);\n\n      if (\n        dictResult &&\n        (dictResult.trs || dictResult.aus || dictResult.sentences)\n      ) {\n        let content = `<div class=\"kiss-word-tooltip-header\">\n          <span>${word}</span>\n          <button class=\"kiss-word-tooltip-close\" onclick=\"this.closest('.kiss-word-tooltip').remove()\">×</button>\n        </div>`;\n\n        // 显示音标\n        if (dictResult.aus && dictResult.aus.length > 0) {\n          content += \"<div>\";\n          dictResult.aus.forEach((au) => {\n            if (au.phonetic) {\n              content += `<span class=\"kiss-word-phonetic\">${au.phonetic}</span>`;\n            }\n          });\n          content += \"</div>\";\n        }\n\n        // 显示释义\n        if (dictResult.trs) {\n          dictResult.trs.slice(0, 3).forEach((tr) => {\n            content += `<div class=\"kiss-word-definition\">${tr.pos ? '<span class=\"kiss-word-pos\">' + tr.pos + \"</span> \" : \"\"}${tr.def}</div>`;\n          });\n        }\n\n        // 显示例句\n        if (dictResult.sentences && dictResult.sentences.length > 0) {\n          content += `<div class=\"kiss-word-example\">\n            <div class=\"kiss-word-example-title\">例句</div>`;\n          dictResult.sentences.slice(0, 2).forEach((sentence) => {\n            content += `<div class=\"kiss-word-example-sentence\">${sentence.eng}</div>\n              <div class=\"kiss-word-example-translation\">${sentence.chs}</div>`;\n          });\n          content += \"</div>\";\n        }\n\n        if (this.#tooltipEl) {\n          this.#tooltipEl.innerHTML = trustedTypesHelper.createHTML(content);\n        }\n      } else {\n        if (this.#tooltipEl) {\n          this.#tooltipEl.innerHTML =\n            trustedTypesHelper.createHTML(`<div class=\"kiss-word-tooltip-header\">\n          <span>${word}</span>\n          <button class=\"kiss-word-tooltip-close\" onclick=\"this.closest('.kiss-word-tooltip').remove()\">×</button>\n        </div>\n        <div class=\"kiss-word-definition\">No definition found</div>`);\n        }\n      }\n    } catch (error) {\n      logger.info(\"Dictionary lookup failed for word:\", word, error);\n\n      // 获取当前字幕的时间戳\n      const currentTimeMs = this.#getCurrentSubtitleStartTime();\n\n      // 即使查询失败，也将单词添加到生词本（无完整信息）\n      const event = new CustomEvent(\"kiss-add-word\", {\n        detail: {\n          word,\n          phonetic: \"\",\n          definition: \"\",\n          examples: [],\n          timestamp: currentTimeMs, // 添加时间戳\n        },\n      });\n      document.dispatchEvent(event);\n\n      if (this.#tooltipEl) {\n        this.#tooltipEl.innerHTML =\n          trustedTypesHelper.createHTML(`<div class=\"kiss-word-tooltip-header\">\n        <span>${word}</span>\n        <button class=\"kiss-word-tooltip-close\" onclick=\"this.closest('.kiss-word-tooltip').remove()\">×</button>\n      </div>\n      <div class=\"kiss-word-definition\">Failed to load definition</div>`);\n      }\n    }\n  }\n\n  // 隐藏单词提示框\n  #hideWordTooltip() {\n    if (this.#tooltipEl) {\n      this.#tooltipEl.remove();\n      this.#tooltipEl = null;\n    }\n  }\n\n  /**\n   * 为指定的元素启用垂直拖动功能。\n   */\n  #enableDragging(dragElement, boundaryContainer, handleElement) {\n    let isDragging = false;\n    let startY;\n    let initialBottom;\n    let dragElementHeight;\n\n    const onDragStart = (e) => {\n      if (e.type === \"mousedown\" && e.button !== 0) return;\n\n      e.preventDefault();\n\n      isDragging = true;\n      handleElement.style.cursor = \"grabbing\";\n      startY = e.type === \"touchstart\" ? e.touches[0].clientY : e.clientY;\n\n      initialBottom =\n        boundaryContainer.getBoundingClientRect().bottom -\n        dragElement.getBoundingClientRect().bottom;\n\n      dragElementHeight = dragElement.offsetHeight;\n\n      document.addEventListener(\"mousemove\", onDragMove, { capture: true });\n      document.addEventListener(\"touchmove\", onDragMove, {\n        capture: true,\n        passive: false,\n      });\n      document.addEventListener(\"mouseup\", onDragEnd, { capture: true });\n      document.addEventListener(\"touchend\", onDragEnd, { capture: true });\n    };\n\n    const onDragMove = (e) => {\n      if (!isDragging) return;\n\n      e.preventDefault();\n\n      const currentY =\n        e.type === \"touchmove\" ? e.touches[0].clientY : e.clientY;\n      const deltaY = currentY - startY;\n      let newBottom = initialBottom - deltaY;\n\n      const containerHeight = boundaryContainer.clientHeight;\n      newBottom = Math.max(0, newBottom);\n      newBottom = Math.min(containerHeight - dragElementHeight, newBottom);\n      if (dragElementHeight > containerHeight) {\n        newBottom = Math.max(0, newBottom);\n      }\n\n      dragElement.style.bottom = `${newBottom}px`;\n    };\n\n    const onDragEnd = (e) => {\n      if (!isDragging) return;\n\n      e.preventDefault();\n\n      isDragging = false;\n      handleElement.style.cursor = \"grab\";\n\n      document.removeEventListener(\"mousemove\", onDragMove, { capture: true });\n      document.removeEventListener(\"touchmove\", onDragMove, { capture: true });\n      document.removeEventListener(\"mouseup\", onDragEnd, { capture: true });\n      document.removeEventListener(\"touchend\", onDragEnd, { capture: true });\n\n      const finalBottomPx = dragElement.style.bottom;\n      setTimeout(() => {\n        dragElement.style.bottom = finalBottomPx;\n      }, 50);\n    };\n\n    handleElement.addEventListener(\"mousedown\", onDragStart);\n    handleElement.addEventListener(\"touchstart\", onDragStart, {\n      passive: false,\n    });\n  }\n\n  /**\n   * 绑定视频元素的 timeupdate 和 seeked 事件监听器。\n   */\n  #attachEventListeners() {\n    this.#videoEl.addEventListener(\"timeupdate\", this.onTimeUpdate);\n    this.#videoEl.addEventListener(\"seeked\", this.onSeek);\n  }\n\n  /**\n   * 移除事件监听器。\n   */\n  #removeEventListeners() {\n    this.#videoEl.removeEventListener(\"timeupdate\", this.onTimeUpdate);\n    this.#videoEl.removeEventListener(\"seeked\", this.onSeek);\n  }\n\n  /**\n   * 视频播放时间更新时的回调，负责更新字幕和触发预翻译。\n   */\n  onTimeUpdate() {\n    const currentTimeMs = this.#videoEl.currentTime * 1000;\n    const subtitleIndex = this.#findSubtitleIndexForTime(currentTimeMs);\n\n    if (subtitleIndex !== this.#currentSubtitleIndex) {\n      this.#currentSubtitleIndex = subtitleIndex;\n      const subtitle =\n        subtitleIndex !== -1 ? this.#formattedSubtitles[subtitleIndex] : null;\n      this.#updateCaptionDisplay(subtitle);\n    }\n\n    this.#throttledTriggerTranslations(currentTimeMs);\n  }\n\n  /**\n   * 用户拖动进度条后的回调。\n   */\n  onSeek() {\n    this.#currentSubtitleIndex = -1;\n    this.#throttledTriggerTranslations.cancel();\n    this.onTimeUpdate();\n  }\n\n  /**\n   * 根据时间（毫秒）查找对应的字幕索引。\n   * @param {number} currentTimeMs\n   * @returns {number} 找到的字幕索引，-1 表示没找到。\n   */\n  #findSubtitleIndexForTime(currentTimeMs) {\n    return this.#formattedSubtitles.findIndex(\n      (sub) => currentTimeMs >= sub.start && currentTimeMs <= sub.end\n    );\n  }\n\n  /**\n   * 更新字幕窗口的显示内容。\n   * @param {object | null} subtitle - 字幕对象，或 null 用于清空。\n   */\n  #updateCaptionDisplay(subtitle) {\n    if (!this.#paperEl || !this.#captionWindowEl) return;\n\n    if (this.#isAdPlaying) {\n      this.#paperEl.style.display = \"none\";\n      return;\n    }\n\n    if (subtitle) {\n      // 创建带有单词标记的字幕内容\n      const p1 = document.createElement(\"p\");\n      p1.style.cssText = this.#setting.originStyle;\n\n      const enhanceMode = this.#setting.enhanceMode ?? \"mobile_off\";\n      const isEnhance =\n        enhanceMode === \"on\" || (enhanceMode === \"mobile_off\" && !isMobile);\n\n      if (isEnhance) {\n        p1.innerHTML = trustedTypesHelper.createHTML(\n          this.#wrapWordsWithSpans(subtitle.text)\n        );\n      } else {\n        p1.textContent = truncateWords(subtitle.text);\n      }\n\n      const p2 = document.createElement(\"p\");\n      p2.style.cssText = this.#setting.translationStyle;\n      if (isEnhance) {\n        p2.innerHTML = trustedTypesHelper.createHTML(\n          this.#wrapWordsWithSpans(subtitle.translation || \"...\")\n        );\n      } else {\n        p2.textContent = truncateWords(subtitle.translation) || \"...\";\n      }\n\n      if (this.#setting.isBilingual) {\n        this.#captionWindowEl.replaceChildren(p1, p2);\n      } else {\n        this.#captionWindowEl.replaceChildren(p2);\n      }\n\n      if (isEnhance) {\n        this.#attachSpanListeners();\n      }\n\n      this.#paperEl.style.display = \"block\";\n    } else {\n      this.#paperEl.style.display = \"none\";\n    }\n  }\n\n  // 将句子中的每个单词包装在span标签中\n  #wrapWordsWithSpans(text) {\n    // 使用正则表达式分割单词，保留空格和标点符号\n    // 这个正则表达式匹配英文单词（包括带撇号的）\n    return text.replace(\n      /\\b([a-zA-Z]+(?:'[a-zA-Z]+)?)\\b/g,\n      '<span class=\"kiss-subtitle-word\" data-word=\"$1\">$1</span>'\n    );\n  }\n\n  /**\n   * 提前翻译指定时间范围内的字幕。\n   * @param {number} currentTimeMs\n   */\n  #triggerTranslations(currentTimeMs) {\n    const { preTrans = 90 } = this.#setting;\n    const lookAheadMs = preTrans * 1000;\n\n    for (const sub of this.#formattedSubtitles) {\n      const isCurrent = sub.start <= currentTimeMs && sub.end >= currentTimeMs;\n      const isUpcoming =\n        sub.start > currentTimeMs && sub.start <= currentTimeMs + lookAheadMs;\n      const needsTranslation = !sub.translation && !sub.isTranslating;\n\n      if ((isCurrent || isUpcoming) && needsTranslation) {\n        this.#translateAndStore(sub);\n      }\n    }\n  }\n\n  /**\n   * 执行单个字幕的翻译并更新其状态。\n   * @param {object} subtitle - 需要翻译的字幕对象。\n   */\n  async #translateAndStore(subtitle) {\n    subtitle.isTranslating = true;\n    try {\n      const { fromLang, toLang, apiSetting } = this.#setting;\n      const { trText } = await apiTranslate({\n        text: subtitle.text,\n        fromLang,\n        toLang,\n        apiSetting,\n      });\n      subtitle.translation = trText;\n    } catch (error) {\n      logger.info(\"Translation failed for:\", subtitle.text, error);\n      subtitle.translation = \"[Translation failed]\";\n    } finally {\n      subtitle.isTranslating = false;\n\n      const currentSubtitleIndexNow = this.#findSubtitleIndexForTime(\n        this.#videoEl.currentTime * 1000\n      );\n      if (this.#formattedSubtitles[currentSubtitleIndexNow] === subtitle) {\n        this.#updateCaptionDisplay(subtitle);\n      }\n\n      // 通知外部组件字幕已更新\n      if (this.onSubtitleUpdate) {\n        this.onSubtitleUpdate(this.#formattedSubtitles);\n      }\n    }\n  }\n\n  /**\n   * 追加新的字幕\n   * @param {Array<object>} newSubtitlesChunk - 新的、要追加的字幕数据块。\n   */\n  appendSubtitles(newSubtitlesChunk) {\n    if (!newSubtitlesChunk || newSubtitlesChunk.length === 0) {\n      return;\n    }\n\n    logger.info(\n      `Bilingual Subtitle Manager: Appending ${newSubtitlesChunk.length} new subtitles...`\n    );\n\n    // 同一个数组引用，此处无需重复添加和排序\n    // this.#formattedSubtitles.push(...newSubtitlesChunk);\n    // this.#formattedSubtitles.sort((a, b) => a.start - b.start);\n    this.#currentSubtitleIndex = -1;\n    this.onTimeUpdate();\n\n    // 通知外部组件字幕已更新\n    if (this.onSubtitleUpdate) {\n      this.onSubtitleUpdate(this.#formattedSubtitles);\n    }\n  }\n\n  updateSetting(obj) {\n    this.#setting = { ...this.#setting, ...obj };\n  }\n\n  // 获取当前字幕的开始时间（使用重新分段后的时间）\n  #getCurrentSubtitleStartTime() {\n    const currentTimeMs = this.#videoEl.currentTime * 1000;\n    // 查找当前时间对应的字幕\n    const currentSubtitle = this.#formattedSubtitles.find(\n      (sub) => currentTimeMs >= sub.start && currentTimeMs <= sub.end\n    );\n\n    // 返回重新分段后的字幕开始时间，如果没有找到则返回当前时间\n    return currentSubtitle ? currentSubtitle.start : currentTimeMs;\n  }\n}\n"
  },
  {
    "path": "src/subtitle/Menus.js",
    "content": "import { useCallback, useMemo, useState } from \"react\";\nimport { API_SPE_TYPES } from \"../config\";\n\nfunction Label({ children }) {\n  return (\n    <div\n      style={{\n        overflow: \"hidden\",\n        textOverflow: \"ellipsis\",\n        whiteSpace: \"nowrap\",\n      }}\n    >\n      {children}\n    </div>\n  );\n}\n\nfunction MenuItem({ children, onClick, disabled = false }) {\n  const [hover, setHover] = useState(false);\n\n  return (\n    <div\n      style={{\n        display: \"flex\",\n        justifyContent: \"space-between\",\n        alignItems: \"center\",\n        padding: \"0px 8px\",\n        opacity: hover ? 1 : 0.8,\n        background: `rgba(255, 255, 255, ${hover ? 0.1 : 0})`,\n        cursor: disabled ? \"default\" : \"pointer\",\n        transition: \"background 0.2s, opacity 0.2s\",\n        borderRadius: 5,\n      }}\n      onMouseEnter={() => setHover(true)}\n      onMouseLeave={() => setHover(false)}\n      onClick={onClick}\n    >\n      {children}\n    </div>\n  );\n}\n\nfunction Switch({ label, name, value, onChange, disabled }) {\n  const handleClick = useCallback(() => {\n    if (disabled) return;\n\n    onChange({ name, value: !value });\n  }, [disabled, onChange, name, value]);\n\n  return (\n    <MenuItem onClick={handleClick} disabled={disabled}>\n      <Label>{label}</Label>\n      <div\n        style={{\n          width: 40,\n          height: 24,\n          borderRadius: 12,\n          background: value ? \"rgba(32,156,238,.8)\" : \"rgba(255,255,255,.3)\",\n          position: \"relative\",\n        }}\n      >\n        <div\n          style={{\n            width: 20,\n            height: 20,\n            borderRadius: 10,\n            position: \"absolute\",\n            left: 2,\n            top: 2,\n            background: \"rgba(255,255,255,.9)\",\n            transform: `translateX(${value ? 16 : 0}px)`,\n          }}\n        ></div>\n      </div>\n    </MenuItem>\n  );\n}\n\nfunction Select({ label, name, value, options, onChange, disabled }) {\n  const [isOpen, setIsOpen] = useState(false);\n  const selectedOption = useMemo(\n    () => options.find((opt) => opt.value === value) || options[0],\n    [options, value]\n  );\n\n  const handleToggle = useCallback(() => {\n    if (disabled) return;\n    setIsOpen((prev) => !prev);\n  }, [disabled]);\n\n  const handleSelect = useCallback(\n    (optionValue) => {\n      onChange({ name, value: optionValue });\n      setIsOpen(false);\n    },\n    [onChange, name]\n  );\n\n  return (\n    <div style={{ position: \"relative\" }}>\n      <MenuItem onClick={handleToggle} disabled={disabled}>\n        <Label>{label}</Label>\n        <div\n          style={{\n            fontSize: 12,\n            opacity: 0.8,\n            maxWidth: 130,\n            overflow: \"hidden\",\n            textOverflow: \"ellipsis\",\n            whiteSpace: \"nowrap\",\n          }}\n        >\n          {selectedOption?.label || \"\"}\n        </div>\n      </MenuItem>\n      {isOpen && (\n        <div\n          style={{\n            position: \"absolute\",\n            right: 0,\n            top: \"100%\",\n            background: \"rgba(0,0,0,.8)\",\n            borderRadius: 5,\n            minWidth: 250,\n            maxHeight: 200,\n            overflow: \"auto\",\n            zIndex: 1000,\n            marginTop: 4,\n          }}\n        >\n          {options.map((option) => (\n            <div\n              key={option.value}\n              onClick={() => handleSelect(option.value)}\n              style={{\n                padding: \"8px 12px\",\n                cursor: \"pointer\",\n                background:\n                  option.value === value\n                    ? \"rgba(32,156,238,.3)\"\n                    : \"transparent\",\n                opacity: option.value === value ? 1 : 0.8,\n                transition: \"all 0.2s\",\n              }}\n              onMouseEnter={(e) => {\n                e.currentTarget.style.background = \"rgba(255,255,255,.1)\";\n              }}\n              onMouseLeave={(e) => {\n                e.currentTarget.style.background =\n                  option.value === value\n                    ? \"rgba(32,156,238,.3)\"\n                    : \"transparent\";\n              }}\n            >\n              {option.label}\n            </div>\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction Button({ label, onClick, disabled }) {\n  const handleClick = useCallback(() => {\n    if (disabled) return;\n\n    onClick();\n  }, [disabled, onClick]);\n\n  return (\n    <MenuItem onClick={handleClick} disabled={disabled}>\n      <Label>{label}</Label>\n    </MenuItem>\n  );\n}\n\nexport function Menus({\n  i18n,\n  formData,\n  progressed = 0,\n  updateSetting,\n  downloadSubtitle,\n  transApis,\n}) {\n  const handleChange = useCallback(\n    ({ name, value }) => {\n      updateSetting({ name, value });\n    },\n    [updateSetting]\n  );\n\n  // 过滤启用的API\n  const enabledApis = useMemo(\n    () => (transApis || []).filter((api) => !api.isDisabled),\n    [transApis]\n  );\n\n  // 过滤AI启用的API\n  const aiEnabledApis = useMemo(\n    () => enabledApis.filter((api) => API_SPE_TYPES.ai.has(api.apiType)),\n    [enabledApis]\n  );\n\n  // 构建断句服务选项\n  const segOptions = useMemo(() => {\n    const options = [{ value: \"-\", label: i18n(\"disable\") || \"禁用\" }];\n    aiEnabledApis.forEach((api) => {\n      options.push({ value: api.apiSlug, label: api.apiName });\n    });\n    return options;\n  }, [aiEnabledApis, i18n]);\n\n  const status = useMemo(() => {\n    if (progressed === 0) return i18n(\"waiting_subtitles\");\n    if (progressed === 100) return i18n(\"download_subtitles\");\n    return i18n(\"processing_subtitles\");\n  }, [progressed, i18n]);\n\n  const { segSlug, skipAd, isBilingual, showOrigin } = formData;\n\n  return (\n    <div\n      style={{\n        position: \"absolute\",\n        left: 0,\n        bottom: 100,\n        background: \"rgba(0,0,0,.6)\",\n        width: 250,\n        lineHeight: \"40px\",\n        fontSize: 16,\n        padding: 8,\n        borderRadius: 5,\n      }}\n    >\n      <Select\n        onChange={handleChange}\n        name=\"segSlug\"\n        value={segSlug || \"-\"}\n        options={segOptions}\n        label={i18n(\"ai_segmentation\")}\n        disabled={segOptions.length <= 1}\n      />\n      <Switch\n        onChange={handleChange}\n        name=\"isBilingual\"\n        value={isBilingual}\n        label={i18n(\"is_bilingual_view\")}\n      />\n      <Switch\n        onChange={handleChange}\n        name=\"showOrigin\"\n        value={showOrigin}\n        label={i18n(\"show_origin_subtitle\")}\n      />\n      <Switch\n        onChange={handleChange}\n        name=\"skipAd\"\n        value={skipAd}\n        label={i18n(\"is_skip_ad\")}\n      />\n      <Button\n        label={`${status} [${progressed}%] `}\n        onClick={downloadSubtitle}\n        disabled={progressed !== 100}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/subtitle/YouTubeCaptionProvider.js",
    "content": "import { logger } from \"../libs/log.js\";\nimport { apiSubtitle } from \"../apis/index.js\";\nimport { BilingualSubtitleManager } from \"./BilingualSubtitleManager.js\";\nimport { YouTubeSubtitleList } from \"./YouTubeSubtitleList.js\";\nimport {\n  MSG_XHR_DATA_YOUTUBE,\n  APP_NAME,\n  OPT_LANGS_TO_CODE,\n  OPT_TRANS_MICROSOFT,\n  OPT_LANGS_SPEC_DEFAULT,\n  OPT_ENHANCE_ON,\n  OPT_ENHANCE_MOBILE_OFF,\n} from \"../config\";\nimport { sleep, downloadBlobFile } from \"../libs/utils.js\";\nimport { createLogoSVG } from \"../libs/svg.js\";\nimport { randomBetween } from \"../libs/utils.js\";\nimport { newI18n } from \"../config\";\nimport DomManager from \"../libs/domManager.js\";\nimport { Menus } from \"./Menus.js\";\nimport { buildBilingualVtt } from \"./vtt.js\";\nimport { isMobile } from \"../libs/mobile.js\";\n\nconst VIDEO_SELECT = \"#container video\";\nconst CONTORLS_SELECT = \".ytp-right-controls\";\nconst YT_CAPTION_SELECT = \"#ytp-caption-window-container\";\nconst YT_AD_SELECT = \".video-ads\";\nconst YT_SUBTITLE_BTN_SELECT = \"button.ytp-subtitles-button\";\n\nclass YouTubeCaptionProvider {\n  #setting = {};\n\n  #subtitles = [];\n  #events = [];\n  #flatEvents = [];\n  #progressedNum = 0;\n  #fromLang = \"auto\";\n\n  #processingId = null;\n\n  #managerInstance = null;\n  #toggleButton = null;\n  #isMenuShow = false;\n  #notificationEl = null;\n  #notificationTimeout = null;\n  #i18n = () => \"\";\n  #menuManager = null; // 菜单管理器实例\n\n  // 新增：字幕列表管理器实例\n  #subtitleListManager = null;\n\n  constructor(setting = {}) {\n    this.#setting = { ...setting, showOrigin: false };\n    this.#i18n = newI18n(setting.uiLang || \"zh\");\n  }\n\n  get #videoId() {\n    const docUrl = new URL(document.location.href);\n    return docUrl.searchParams.get(\"v\");\n  }\n\n  get #videoEl() {\n    return document.querySelector(VIDEO_SELECT);\n  }\n\n  set #progressed(num) {\n    this.#progressedNum = num;\n    this.#updateMenuProps(); // 更新菜单 props\n  }\n\n  get #progressed() {\n    return this.#progressedNum;\n  }\n\n  initialize() {\n    window.addEventListener(\"message\", (event) => {\n      if (event.data?.type === MSG_XHR_DATA_YOUTUBE) {\n        const { url, response } = event.data;\n        if (url && response) {\n          this.#handleInterceptedRequest(url, response);\n        }\n      }\n    });\n\n    window.addEventListener(\"yt-navigate-finish\", () => {\n      logger.debug(\"Youtube Provider: yt-navigate-finish\", this.#videoId);\n\n      this.#destroyManager();\n\n      this.#subtitles = [];\n      this.#events = [];\n      this.#flatEvents = [];\n      this.#progressed = 0;\n      this.#fromLang = \"auto\";\n      this.#updateMenuProps(); // 更新菜单 props\n    });\n\n    this.#waitForElement(CONTORLS_SELECT, (ytControls) => {\n      const ytSubtitleBtn = ytControls.querySelector(YT_SUBTITLE_BTN_SELECT);\n      if (ytSubtitleBtn) {\n        ytSubtitleBtn.addEventListener(\"click\", () => {\n          if (ytSubtitleBtn.getAttribute(\"aria-pressed\") === \"true\") {\n            this.#startManager();\n          } else {\n            this.#destroyManager();\n          }\n        });\n      }\n\n      this.#injectToggleButton(ytControls);\n    });\n\n    this.#waitForElement(YT_AD_SELECT, (adContainer) => {\n      this.#moAds(adContainer);\n    });\n  }\n\n  #moAds(adContainer) {\n    const adLayoutSelector = \".ytp-ad-player-overlay-layout\";\n    const skipBtnSelector =\n      \".ytp-skip-ad-button, .ytp-ad-skip-button, .ytp-ad-skip-button-modern\";\n    const observer = new MutationObserver((mutations) => {\n      const { skipAd = false } = this.#setting;\n      for (const mutation of mutations) {\n        if (mutation.type === \"childList\") {\n          const videoEl = this.#videoEl;\n          mutation.addedNodes.forEach((node) => {\n            if (node.nodeType !== Node.ELEMENT_NODE) return;\n\n            if (node.matches(adLayoutSelector)) {\n              logger.debug(\"Youtube Provider: AD start playing!\", node);\n              // todo: 顺带把广告快速跳过\n              if (videoEl && skipAd) {\n                videoEl.playbackRate = 16;\n                videoEl.currentTime = videoEl.duration;\n              }\n              if (this.#managerInstance) {\n                this.#managerInstance.setIsAdPlaying(true);\n              }\n            } else if (node.matches(skipBtnSelector) && skipAd) {\n              logger.debug(\"Youtube Provider: AD skip button!\", node);\n              node.click();\n            }\n\n            if (skipAd) {\n              const skipBtn = node?.querySelector(skipBtnSelector);\n              if (skipBtn) {\n                logger.debug(\"Youtube Provider: AD skip button!!\", skipBtn);\n                skipBtn.click();\n              }\n            }\n          });\n          mutation.removedNodes.forEach((node) => {\n            if (node.nodeType !== Node.ELEMENT_NODE) return;\n\n            if (node.matches(adLayoutSelector)) {\n              logger.debug(\"Youtube Provider: Ad ends!\");\n\n              if (!this.#setting.showOrigin) {\n                this.#hideYtCaption();\n              }\n              if (videoEl && skipAd) {\n                videoEl.playbackRate = 1;\n              }\n              if (this.#managerInstance) {\n                this.#managerInstance.setIsAdPlaying(false);\n              }\n            }\n          });\n        }\n      }\n    });\n\n    observer.observe(adContainer, {\n      childList: true,\n      subtree: true,\n    });\n  }\n\n  #waitForElement(selector, callback) {\n    const element = document.querySelector(selector);\n    if (element) {\n      callback(element);\n      return;\n    }\n\n    const observer = new MutationObserver((mutations, obs) => {\n      const targetNode = document.querySelector(selector);\n      if (targetNode) {\n        obs.disconnect();\n        callback(targetNode);\n      }\n    });\n\n    observer.observe(document.body, {\n      childList: true,\n      subtree: true,\n    });\n  }\n\n  updateSetting({ name, value }) {\n    if (this.#setting[name] === value) return;\n\n    logger.debug(\"Youtube Provider: update setting\", name, value);\n    this.#setting[name] = value;\n\n    this.#updateMenuProps(); // 更新菜单 props\n\n    if (name === \"isBilingual\") {\n      this.#managerInstance?.updateSetting({ [name]: value });\n    } else if (name === \"segSlug\") {\n      this.#reProcessEvents();\n    } else if (name === \"showOrigin\") {\n      this.#toggleShowOrigin();\n    }\n  }\n\n  #toggleShowOrigin() {\n    if (this.#setting.showOrigin) {\n      this.#destroyManager();\n    } else {\n      this.#startManager();\n    }\n  }\n\n  downloadSubtitle() {\n    if (!this.#subtitles.length || this.#progressed !== 100) {\n      logger.debug(\"Youtube Provider: The subtitle is not yet ready.\");\n      return;\n    }\n\n    try {\n      const vtt = buildBilingualVtt(this.#subtitles);\n      downloadBlobFile(\n        vtt,\n        `kiss-subtitles-${this.#videoId}_${Date.now()}.vtt`\n      );\n    } catch (error) {\n      logger.info(\"Youtube Provider: download subtitles:\", error);\n    }\n  }\n\n  /**\n   * 获取菜单组件的 props\n   * @private\n   */\n  #getMenuProps() {\n    const { transApis, segSlug, skipAd, isBilingual, showOrigin } =\n      this.#setting;\n    return {\n      i18n: this.#i18n,\n      updateSetting: this.updateSetting.bind(this),\n      downloadSubtitle: this.downloadSubtitle.bind(this),\n      transApis,\n      progressed: this.#progressedNum,\n      formData: {\n        segSlug,\n        skipAd,\n        isBilingual,\n        showOrigin,\n      },\n    };\n  }\n\n  /**\n   * 更新菜单组件的 props\n   * @private\n   */\n  #updateMenuProps() {\n    if (this.#menuManager && this.#isMenuShow) {\n      this.#menuManager.updateProps(this.#getMenuProps());\n    }\n  }\n\n  #injectToggleButton(ytControls) {\n    const kissControls = document.createElement(\"div\");\n    kissControls.className = \"notranslate kiss-subtitle-controls\";\n    Object.assign(kissControls.style, {\n      height: \"100%\",\n      position: \"relative\",\n    });\n\n    const toggleButton = document.createElement(\"button\");\n    toggleButton.className = \"ytp-button kiss-subtitle-button\";\n    toggleButton.title = APP_NAME;\n\n    toggleButton.appendChild(createLogoSVG());\n    kissControls.appendChild(toggleButton);\n\n    // 使用新的 DomManager 替代 ShadowDomManager\n    this.#menuManager = new DomManager({\n      id: \"kiss-subtitle-menus\",\n      className: \"notranslate\",\n      reactComponent: Menus,\n      rootElement: kissControls,\n      props: this.#getMenuProps(), // 获取菜单 props\n    });\n\n    toggleButton.onclick = () => {\n      if (!this.#isMenuShow) {\n        this.#isMenuShow = true;\n        this.#toggleButton?.replaceChildren(\n          createLogoSVG({ isSelected: true })\n        );\n        this.#menuManager.show();\n        this.#updateMenuProps(); // 显示时更新 props\n      } else {\n        this.#isMenuShow = false;\n        this.#toggleButton?.replaceChildren(createLogoSVG());\n        this.#menuManager.hide();\n      }\n    };\n    this.#toggleButton = toggleButton;\n\n    ytControls?.prepend(kissControls);\n  }\n\n  #isSameLang(lang1, lang2) {\n    return lang1.slice(0, 2) === lang2.slice(0, 2);\n  }\n\n  // todo: 优化逻辑\n  #findCaptionTrack(captionTracks, lang) {\n    logger.debug(\"Youtube Provider: find caption track\", {\n      captionTracks,\n      lang,\n    });\n\n    if (!captionTracks?.length) {\n      return null;\n    }\n\n    // 优先返回用户选择的字幕轨\n    let captionTrack = captionTracks.find((item) => item.languageCode === lang);\n    if (!captionTrack) {\n      const asrTrack = captionTracks.find((item) => item.kind === \"asr\");\n      if (asrTrack) {\n        captionTrack = captionTracks.find(\n          (item) =>\n            item.kind !== \"asr\" &&\n            this.#isSameLang(item.languageCode, asrTrack.languageCode)\n        );\n        if (!captionTrack) {\n          captionTrack = asrTrack;\n        }\n      }\n    }\n\n    if (!captionTrack) {\n      captionTrack = captionTracks.pop();\n    }\n\n    return captionTrack;\n  }\n\n  async #getCaptionTracks(videoId) {\n    try {\n      const url = `https://www.youtube.com/watch?v=${videoId}`;\n      const html = await fetch(url).then((r) => r.text());\n      const match = html.match(/ytInitialPlayerResponse\\s*=\\s*(\\{.*?\\});/s);\n      if (!match) return [];\n      const data = JSON.parse(match[1]);\n      return data.captions?.playerCaptionsTracklistRenderer?.captionTracks;\n    } catch (err) {\n      logger.info(\"Youtube Provider: get captionTracks\", err);\n    }\n  }\n\n  async #getSubtitleEvents(capUrl, potUrl, responseText) {\n    if (\n      !potUrl.searchParams.get(\"tlang\") &&\n      potUrl.searchParams.get(\"kind\") === capUrl.searchParams.get(\"kind\") &&\n      this.#isSameLang(\n        potUrl.searchParams.get(\"lang\"),\n        capUrl.searchParams.get(\"lang\")\n      )\n    ) {\n      try {\n        const json = JSON.parse(responseText);\n        return json?.events;\n      } catch (err) {\n        logger.info(\"Youtube Provider: parse responseText\", err);\n        return null;\n      }\n    }\n\n    try {\n      potUrl.searchParams.delete(\"tlang\");\n      potUrl.searchParams.set(\"lang\", capUrl.searchParams.get(\"lang\"));\n      potUrl.searchParams.set(\"fmt\", \"json3\");\n      if (capUrl.searchParams.get(\"kind\")) {\n        potUrl.searchParams.set(\"kind\", capUrl.searchParams.get(\"kind\"));\n      } else {\n        potUrl.searchParams.delete(\"kind\");\n      }\n\n      const res = await fetch(potUrl.href);\n      if (res?.ok) {\n        const json = await res.json();\n        return json?.events;\n      }\n      logger.info(`Youtube Provider: Failed to fetch subtitles: ${res.status}`);\n      return null;\n    } catch (error) {\n      logger.info(\"Youtube Provider: fetching subtitles error\", error);\n      return null;\n    }\n  }\n\n  async #aiSegment({ videoId, fromLang, toLang, chunkEvents, segApiSetting }) {\n    try {\n      const events = chunkEvents.filter((item) => item.text);\n      const chunkSign = `${events[0].start} --> ${events[events.length - 1].end}`;\n      logger.debug(\"Youtube Provider: aiSegment events\", {\n        videoId,\n        chunkSign,\n        fromLang,\n        toLang,\n        events,\n      });\n      const subtitles = await apiSubtitle({\n        videoId,\n        chunkSign,\n        fromLang,\n        toLang,\n        events,\n        apiSetting: segApiSetting,\n      });\n      logger.debug(\"Youtube Provider: aiSegment subtitles\", subtitles);\n      if (Array.isArray(subtitles)) {\n        return subtitles;\n      }\n    } catch (err) {\n      logger.info(\"Youtube Provider: ai segmentation\", err);\n    }\n\n    return [];\n  }\n\n  #getFromLang(lang) {\n    if (lang === \"zh\") {\n      return \"zh-CN\";\n    }\n\n    return (\n      OPT_LANGS_SPEC_DEFAULT.get(lang) ||\n      OPT_LANGS_SPEC_DEFAULT.get(lang.slice(0, 2)) ||\n      OPT_LANGS_TO_CODE[OPT_TRANS_MICROSOFT].get(lang) ||\n      OPT_LANGS_TO_CODE[OPT_TRANS_MICROSOFT].get(lang.slice(0, 2)) ||\n      \"auto\"\n    );\n  }\n\n  async #handleInterceptedRequest(url, responseText) {\n    const videoId = this.#videoId;\n    if (!videoId) {\n      logger.debug(\"Youtube Provider: videoId not found.\");\n      return;\n    }\n\n    const potUrl = new URL(url);\n    if (videoId !== potUrl.searchParams.get(\"v\")) {\n      logger.debug(\"Youtube Provider: skip other timedtext:\", videoId);\n      return;\n    }\n\n    const lang = potUrl.searchParams.get(\"lang\");\n    const fromLang = this.#getFromLang(lang);\n    if (this.#flatEvents.length) {\n      if (this.#isSameLang(lang, this.#fromLang)) {\n        logger.debug(\"Youtube Provider: video was processed:\", videoId);\n        return;\n      }\n      this.#destroyManager();\n    }\n\n    if (videoId === this.#processingId) {\n      logger.debug(\"Youtube Provider: video is processing:\", videoId);\n      return;\n    }\n\n    this.#processingId = videoId;\n\n    try {\n      this.#showNotification(this.#i18n(\"starting_to_process_subtitle\"));\n\n      const { toLang } = this.#setting;\n      const captionTracks = await this.#getCaptionTracks(videoId);\n      const captionTrack = this.#findCaptionTrack(captionTracks, lang);\n      if (!captionTrack) {\n        logger.debug(\"Youtube Provider: CaptionTrack not found:\", videoId);\n        return;\n      }\n\n      const capUrl = new URL(captionTrack.baseUrl);\n      const events = await this.#getSubtitleEvents(\n        capUrl,\n        potUrl,\n        responseText\n      );\n      if (!events?.length) {\n        logger.debug(\"Youtube Provider: events not got:\", videoId);\n        return;\n      }\n\n      logger.debug(\n        `Youtube Provider: lang: ${lang}, fromLang: ${fromLang}, toLang: ${toLang}`\n      );\n      if (this.#isSameLang(fromLang, toLang)) {\n        logger.debug(\"Youtube Provider: skip same lang\", fromLang, toLang);\n        this.#showNotification(this.#i18n(\"subtitle_same_lang\"));\n        return;\n      }\n\n      const flatEvents = this.#genFlatEvents(events);\n      if (!flatEvents?.length) {\n        logger.debug(\"Youtube Provider: flatEvents not got:\", videoId);\n        return;\n      }\n\n      this.#events = events;\n      this.#flatEvents = flatEvents;\n      this.#fromLang = fromLang;\n\n      this.#processEvents({\n        videoId,\n        flatEvents,\n        fromLang,\n      });\n    } catch (error) {\n      logger.warn(\"Youtube Provider: handle subtitle\", error);\n      this.#showNotification(this.#i18n(\"subtitle_load_failed\"));\n    } finally {\n      this.#processingId = null;\n    }\n  }\n\n  async #processEvents({ videoId, flatEvents, fromLang }) {\n    try {\n      const [subtitles, progressed] = await this.#eventsToSubtitles({\n        videoId,\n        flatEvents,\n        fromLang,\n      });\n      if (!subtitles?.length) {\n        logger.debug(\n          \"Youtube Provider: events to subtitles got empty\",\n          videoId\n        );\n        return;\n      }\n\n      if (videoId !== this.#videoId) {\n        logger.debug(\n          \"Youtube Provider: videoId changed!\",\n          videoId,\n          this.#videoId\n        );\n        return;\n      }\n\n      this.#subtitles = subtitles;\n      this.#progressed = progressed;\n\n      this.#startManager();\n    } catch (error) {\n      logger.info(\"Youtube Provider: process events\", error);\n      this.#showNotification(this.#i18n(\"subtitle_load_failed\"));\n    }\n  }\n\n  #reProcessEvents() {\n    this.#progressed = 0;\n    this.#subtitles = [];\n\n    const videoId = this.#videoId;\n    const flatEvents = this.#flatEvents;\n    const fromLang = this.#fromLang;\n    if (!videoId || !flatEvents.length) {\n      return;\n    }\n\n    this.#showNotification(this.#i18n(\"starting_reprocess_events\"));\n\n    this.#destroyManager();\n\n    this.#processEvents({ videoId, flatEvents, fromLang });\n  }\n\n  async #eventsToSubtitles({ videoId, flatEvents, fromLang }) {\n    const { segSlug, transApis, chunkLength, toLang } = this.#setting;\n    const subtitlesFallback = () => [\n      this.#formatSubtitles(flatEvents, fromLang),\n      100,\n    ];\n\n    // 根据segSlug从transApis中查找对应的API设置\n    const segApiSetting = transApis?.find((api) => api.apiSlug === segSlug);\n\n    // potUrl.searchParams.get(\"kind\") === \"asr\"\n    // 当segSlug不为\"-\"且segApiSetting存在时，启用AI断句\n    if (segSlug && segSlug !== \"-\" && segApiSetting) {\n      logger.info(\"Youtube Provider: Starting AI ...\");\n      this.#showNotification(this.#i18n(\"ai_processing_pls_wait\"));\n\n      const eventChunks = this.#splitEventsIntoChunks(flatEvents, chunkLength);\n\n      if (eventChunks.length === 0) {\n        return subtitlesFallback();\n      }\n\n      const firstChunkEvents = eventChunks[0];\n      const firstBatchSubtitles = await this.#aiSegment({\n        videoId,\n        chunkEvents: firstChunkEvents,\n        fromLang,\n        toLang,\n        segApiSetting,\n      });\n\n      if (!firstBatchSubtitles?.length) {\n        return subtitlesFallback();\n      }\n\n      if (eventChunks.length > 1) {\n        const remainingChunks = eventChunks.slice(1);\n        this.#processRemainingChunksAsync({\n          chunks: remainingChunks,\n          videoId,\n          fromLang,\n          toLang,\n          segApiSetting,\n        });\n\n        const processed = Math.floor(100 / eventChunks.length);\n\n        return [firstBatchSubtitles, processed];\n      } else {\n        return [firstBatchSubtitles, 100];\n      }\n    }\n\n    return subtitlesFallback();\n  }\n\n  #startManager() {\n    if (this.#managerInstance) {\n      return;\n    }\n\n    if (this.#setting.showOrigin) {\n      return;\n    }\n\n    if (!this.#subtitles.length) {\n      this.#showNotification(this.#i18n(\"waitting_for_subtitle\"));\n      return;\n    }\n\n    const videoEl = this.#videoEl;\n    if (!videoEl) {\n      logger.warn(\"Youtube Provider: No video element found\");\n      return;\n    }\n\n    logger.info(\"Youtube Provider: Starting manager...\");\n\n    this.#managerInstance = new BilingualSubtitleManager({\n      videoEl,\n      formattedSubtitles: this.#subtitles,\n      setting: { ...this.#setting, fromLang: this.#fromLang },\n    });\n\n    // todo 移到菜单切换\n    // 监听字幕更新事件，将翻译后的字幕传递给字幕列表\n    const enhanceMode = this.#setting.enhanceMode ?? \"mobile_off\";\n    const isEnhance =\n      enhanceMode === OPT_ENHANCE_ON ||\n      (enhanceMode === OPT_ENHANCE_MOBILE_OFF && !isMobile);\n    const showList = this.#setting.showList ?? true;\n\n    if (isEnhance && showList && !this.#subtitleListManager) {\n      // 初始化字幕列表管理器\n      this.#subtitleListManager = new YouTubeSubtitleList(videoEl);\n      this.#subtitleListManager.initialize(this.#subtitles);\n\n      // todo: 将 subtitleListManager 实例传入 managerInstance\n      // 监听字幕更新事件，在字幕翻译完成后更新字幕列表\n      this.#managerInstance.onSubtitleUpdate = (updatedSubtitles) => {\n        this.#subtitleListManager.setBilingualSubtitles(updatedSubtitles);\n      };\n\n      // 创建包含翻译信息的双语字幕数据（初始可能没有翻译）\n      const bilingualSubtitles = this.#subtitles.map((sub) => ({\n        start: sub.start,\n        end: sub.end,\n        text: sub.text,\n        translation: sub.translation || \"\",\n      }));\n\n      // 将双语字幕数据传递给字幕列表\n      this.#subtitleListManager.setBilingualSubtitles(bilingualSubtitles);\n      // 启动字幕列表自动滚动\n      this.#subtitleListManager.turnOnAutoSub();\n    }\n\n    this.#managerInstance.start();\n\n    this.#showNotification(this.#i18n(\"subtitle_load_succeed\"));\n\n    this.#hideYtCaption();\n  }\n\n  #destroyManager() {\n    this.#showYtCaption();\n\n    if (!this.#managerInstance) {\n      return;\n    }\n\n    logger.info(\"Youtube Provider: Destroying manager...\");\n\n    this.#managerInstance.destroy();\n    this.#managerInstance = null;\n\n    // 销毁字幕列表\n    if (this.#subtitleListManager) {\n      this.#subtitleListManager.destroy();\n      this.#subtitleListManager = null;\n    }\n  }\n\n  #hideYtCaption() {\n    const ytCaption = document.querySelector(YT_CAPTION_SELECT);\n    ytCaption && (ytCaption.style.display = \"none\");\n  }\n\n  #showYtCaption() {\n    const ytCaption = document.querySelector(YT_CAPTION_SELECT);\n    ytCaption && (ytCaption.style.display = \"block\");\n  }\n\n  #formatSubtitles(flatEvents, lang) {\n    if (!flatEvents?.length) return [];\n\n    const noSpaceLanguages = [\n      \"zh\", // 中文\n      \"ja\", // 日文\n      \"ko\", // 韩文（现代用空格，但结构上仍可连写）\n      \"th\", // 泰文\n      \"lo\", // 老挝文\n      \"km\", // 高棉文\n      \"my\", // 缅文\n    ];\n\n    if (noSpaceLanguages.some((l) => lang?.startsWith(l))) {\n      const subtitles = [];\n\n      if (this.#isQualityPoor(flatEvents, 5, 0.5)) {\n        return flatEvents;\n      }\n\n      let currentLine = null;\n      const MAX_LENGTH = 30;\n\n      for (const segment of flatEvents) {\n        if (segment.text) {\n          if (!currentLine) {\n            currentLine = {\n              text: segment.text,\n              start: segment.start,\n              end: segment.end,\n            };\n          } else {\n            currentLine.text += segment.text;\n            currentLine.end = segment.end;\n          }\n\n          if (currentLine.text.length >= MAX_LENGTH) {\n            subtitles.push(currentLine);\n            currentLine = null;\n          }\n        } else {\n          if (currentLine) {\n            subtitles.push(currentLine);\n            currentLine = null;\n          }\n        }\n      }\n\n      if (currentLine) {\n        subtitles.push(currentLine);\n      }\n\n      return subtitles;\n    }\n\n    let subtitles = this.#processSubtitles({ flatEvents });\n    const isPoor = this.#isQualityPoor(subtitles);\n    logger.debug(\"Youtube Provider: isQualityPoor\", { isPoor, subtitles });\n    if (isPoor) {\n      subtitles = this.#processSubtitles({ flatEvents, usePause: true });\n    }\n\n    return subtitles;\n  }\n\n  #isQualityPoor(lines, lengthThreshold = 250, percentageThreshold = 0.2) {\n    if (lines.length === 0) return false;\n    const longLinesCount = lines.filter(\n      (line) => line.text.length > lengthThreshold\n    ).length;\n    return longLinesCount / lines.length > percentageThreshold;\n  }\n\n  #processSubtitles({\n    flatEvents,\n    usePause = false,\n    timeout = 1000,\n    maxWords = 15,\n  } = {}) {\n    const groupedPauseWords = {\n      1: new Set([\n        \"actually\",\n        \"also\",\n        \"although\",\n        \"and\",\n        \"anyway\",\n        \"as\",\n        \"basically\",\n        \"because\",\n        \"but\",\n        \"eventually\",\n        \"frankly\",\n        \"honestly\",\n        \"hopefully\",\n        \"however\",\n        \"if\",\n        \"instead\",\n        \"it's\",\n        \"just\",\n        \"let's\",\n        \"like\",\n        \"literally\",\n        \"maybe\",\n        \"meanwhile\",\n        \"nevertheless\",\n        \"nonetheless\",\n        \"now\",\n        \"okay\",\n        \"or\",\n        \"otherwise\",\n        \"perhaps\",\n        \"personally\",\n        \"probably\",\n        \"right\",\n        \"since\",\n        \"so\",\n        \"suddenly\",\n        \"that's\",\n        \"then\",\n        \"there's\",\n        \"therefore\",\n        \"though\",\n        \"thus\",\n        \"unless\",\n        \"until\",\n        \"well\",\n        \"while\",\n      ]),\n      2: new Set([\n        \"after all\",\n        \"at first\",\n        \"at least\",\n        \"even if\",\n        \"even though\",\n        \"for example\",\n        \"for instance\",\n        \"i believe\",\n        \"i guess\",\n        \"i mean\",\n        \"i suppose\",\n        \"i think\",\n        \"in fact\",\n        \"in the end\",\n        \"of course\",\n        \"then again\",\n        \"to be fair\",\n        \"you know\",\n        \"you see\",\n      ]),\n      3: new Set([\n        \"as a result\",\n        \"by the way\",\n        \"in other words\",\n        \"in that case\",\n        \"in this case\",\n        \"to be clear\",\n        \"to be honest\",\n      ]),\n    };\n\n    const sentences = [];\n    let currentBuffer = [];\n    let bufferWordCount = 0;\n\n    const flushBuffer = () => {\n      if (currentBuffer.length > 0) {\n        sentences.push({\n          text: currentBuffer\n            .map((s) => s.text)\n            .join(\" \")\n            .trim(),\n          start: currentBuffer[0].start,\n          end: currentBuffer[currentBuffer.length - 1].end,\n        });\n      }\n      currentBuffer = [];\n      bufferWordCount = 0;\n    };\n\n    flatEvents.forEach((segment) => {\n      if (!segment.text) return;\n\n      const lastSegment = currentBuffer[currentBuffer.length - 1];\n\n      if (lastSegment) {\n        const isEndOfSentence = /[.?!…\\])]$/.test(lastSegment.text);\n        const isPauseOfSentence = /[,]$/.test(lastSegment.text);\n        const isTimeout = segment.start - lastSegment.end > timeout;\n        const isWordLimitExceeded =\n          (usePause || isPauseOfSentence) && bufferWordCount >= maxWords;\n\n        const startsWithSign = /^[[(♪]/.test(segment.text);\n        const startsWithPauseWord =\n          usePause &&\n          groupedPauseWords[\"1\"].has(\n            segment.text.toLowerCase().split(\" \")[0]\n          ) &&\n          currentBuffer.length > 1;\n\n        if (\n          isEndOfSentence ||\n          isTimeout ||\n          isWordLimitExceeded ||\n          startsWithSign ||\n          startsWithPauseWord\n        ) {\n          flushBuffer();\n        }\n      }\n\n      currentBuffer.push(segment);\n      bufferWordCount += segment.text.split(/\\s+/).length;\n    });\n\n    flushBuffer();\n\n    return sentences;\n  }\n\n  #genFlatEvents(events = []) {\n    const segments = [];\n    let buffer = null;\n\n    events.forEach(({ segs = [], tStartMs = 0, dDurationMs = 0 }) => {\n      segs.forEach(({ utf8 = \"\", tOffsetMs = 0 }, j) => {\n        const text = utf8.trim().replace(/\\s+/g, \" \");\n        const start = tStartMs + tOffsetMs;\n\n        if (buffer) {\n          if (!buffer.end || buffer.end > start) {\n            buffer.end = start;\n          }\n          segments.push(buffer);\n          buffer = null;\n        }\n\n        buffer = {\n          text,\n          start,\n        };\n\n        if (j === segs.length - 1) {\n          buffer.end = tStartMs + dDurationMs;\n        }\n      });\n    });\n\n    segments.push(buffer);\n\n    return segments;\n  }\n\n  #splitEventsIntoChunks(flatEvents, chunkLength = 1000) {\n    if (!flatEvents || flatEvents.length === 0) {\n      return [];\n    }\n\n    const eventChunks = [];\n    let currentChunk = [];\n    let currentChunkTextLength = 0;\n    const MAX_CHUNK_LENGTH = chunkLength + 500;\n    const PAUSE_THRESHOLD_MS = 1000;\n\n    for (let i = 0; i < flatEvents.length; i++) {\n      const event = flatEvents[i];\n      currentChunk.push(event);\n      currentChunkTextLength += event.text.length;\n\n      const isLastEvent = i === flatEvents.length - 1;\n      if (isLastEvent) {\n        continue;\n      }\n\n      let shouldSplit = false;\n\n      if (currentChunkTextLength >= MAX_CHUNK_LENGTH) {\n        shouldSplit = true;\n      } else if (currentChunkTextLength >= chunkLength) {\n        const isEndOfSentence = /[.?!…\\])]$/.test(event.text);\n        const nextEvent = flatEvents[i + 1];\n        const pauseDuration = nextEvent.start - event.end;\n        if (isEndOfSentence || pauseDuration > PAUSE_THRESHOLD_MS) {\n          shouldSplit = true;\n        }\n      }\n\n      if (shouldSplit) {\n        eventChunks.push(currentChunk);\n        currentChunk = [];\n        currentChunkTextLength = 0;\n      }\n    }\n\n    if (currentChunk.length > 0) {\n      eventChunks.push(currentChunk);\n    }\n\n    return eventChunks;\n  }\n\n  async #processRemainingChunksAsync({\n    chunks,\n    videoId,\n    fromLang,\n    toLang,\n    segApiSetting,\n  }) {\n    logger.info(`Youtube Provider: Starting for ${chunks.length} chunks.`);\n\n    for (let i = 0; i < chunks.length; i++) {\n      const chunkEvents = chunks[i];\n      const chunkNum = i + 2;\n      logger.debug(\n        `Youtube Provider: Processing subtitle chunk ${chunkNum}/${chunks.length + 1}: ${chunkEvents[0]?.start} --> ${chunkEvents[chunkEvents.length - 1]?.start}`\n      );\n\n      let subtitlesForThisChunk = [];\n\n      try {\n        const aiSubtitles = await this.#aiSegment({\n          videoId,\n          chunkEvents,\n          fromLang,\n          toLang,\n          segApiSetting,\n        });\n\n        if (aiSubtitles?.length > 0) {\n          subtitlesForThisChunk = aiSubtitles;\n        } else {\n          logger.debug(\n            `Youtube Provider: AI segmentation for chunk ${chunkNum} returned no data.`\n          );\n          subtitlesForThisChunk = this.#formatSubtitles(chunkEvents, fromLang);\n        }\n      } catch (chunkError) {\n        subtitlesForThisChunk = this.#formatSubtitles(chunkEvents, fromLang);\n      }\n\n      if (videoId !== this.#videoId) {\n        logger.info(\n          \"Youtube Provider: videoId changed!!\",\n          videoId,\n          this.#videoId\n        );\n        break;\n      }\n\n      if (subtitlesForThisChunk.length > 0) {\n        const progressed = Math.floor((chunkNum * 100) / (chunks.length + 1));\n        this.#subtitles.push(...subtitlesForThisChunk);\n        this.#subtitles.sort((a, b) => a.start - b.start);\n        this.#progressed = progressed;\n\n        logger.debug(\n          `Youtube Provider: Appending ${subtitlesForThisChunk.length} subtitles from chunk ${chunkNum} (${this.#progressed}%).`\n        );\n\n        if (this.#managerInstance) {\n          this.#managerInstance.appendSubtitles(subtitlesForThisChunk);\n        }\n      } else {\n        logger.debug(`Youtube Provider: Chunk ${chunkNum} no subtitles.`);\n      }\n\n      await sleep(randomBetween(500, 1000));\n    }\n\n    logger.info(\"Youtube Provider: All subtitle chunks processed.\");\n  }\n\n  #createNotificationElement() {\n    const notificationEl = document.createElement(\"div\");\n    notificationEl.className = \"kiss-notification\";\n    Object.assign(notificationEl.style, {\n      position: \"absolute\",\n      top: \"40%\",\n      left: \"50%\",\n      transform: \"translateX(-50%)\",\n      background: \"rgba(0,0,0,0.7)\",\n      color: \"red\",\n      padding: \"0.5em 1em\",\n      borderRadius: \"4px\",\n      zIndex: \"2147483647\",\n      opacity: \"0\",\n      transition: \"opacity 0.3s ease-in-out\",\n      pointerEvents: \"none\",\n      fontSize: \"2em\",\n      width: \"50%\",\n      textAlign: \"center\",\n    });\n\n    const videoEl = this.#videoEl;\n    const videoContainer = videoEl?.parentElement?.parentElement;\n    if (videoContainer) {\n      videoContainer.appendChild(notificationEl);\n      this.#notificationEl = notificationEl;\n    }\n  }\n\n  #showNotification(message, duration = 2000) {\n    if (!this.#notificationEl) this.#createNotificationElement();\n    this.#notificationEl.textContent = message;\n    this.#notificationEl.style.opacity = \"1\";\n    clearTimeout(this.#notificationTimeout);\n    this.#notificationTimeout = setTimeout(() => {\n      this.#notificationEl.style.opacity = \"0\";\n    }, duration);\n  }\n}\n\nexport const YouTubeInitializer = (() => {\n  let initialized = false;\n\n  return async (setting) => {\n    if (initialized) {\n      return;\n    }\n    initialized = true;\n\n    logger.info(\"Bilingual Subtitle Extension: Initializing...\");\n    const provider = new YouTubeCaptionProvider(setting);\n    provider.initialize();\n  };\n})();\n"
  },
  {
    "path": "src/subtitle/YouTubeSubtitleList.js",
    "content": "import { logger } from \"../libs/log.js\";\nimport { downloadBlobFile } from \"../libs/utils.js\";\nimport { buildBilingualVtt } from \"./vtt.js\";\nimport { getSettingWithDefault } from \"../libs/storage.js\";\n\n/**\n * YouTube 字幕列表管理器\n * * 功能：\n * 1. 在 YouTube 视频侧边显示同步滚动的双语字幕列表。\n * 2. 支持字幕点击跳转视频进度。\n * 3. 提供字幕下载功能 (VTT)。\n * 4. 集成生词本功能，支持添加、查看及多种格式导出。\n */\nexport class YouTubeSubtitleList {\n  /**\n   * @param {HTMLVideoElement} videoElement YouTube 的视频播放器 DOM 元素\n   */\n  constructor(videoElement) {\n    this.videoEl = videoElement;\n\n    // --- 数据源 ---\n    // 统一字幕数据结构: { start: number, end: number, text: string, translation: string }\n    this.bilingualSubtitles = [];\n    // 生词数据结构: { word, phonetic, definition, examples: [], timestamp }\n    this.vocabulary = [];\n\n    // --- DOM 引用 ---\n    this.container = null; // 主容器\n    this.subtitleListEl = null; // 字幕列表面板\n    this.vocabularyListEl = null; // 生词本面板\n    this.subtitleScrollContainer = null; //\n    this._cachedSubtitleItems = []; // 缓存字幕列表项 DOM，减少 querySelector 调用，提升滚动性能\n\n    // --- 状态管理 ---\n    this.loopAutoScroll = null; // 自动滚动的定时器 ID\n    this.activeTab = \"subtitles\"; // 当前激活的 Tab: 'subtitles' 或 'vocabulary'\n    this._lastActiveIndex = -1; // 上一次高亮的字幕索引\n\n    // --- 事件绑定 ---\n    this.handleWordAdded = this.handleWordAdded.bind(this);\n    document.addEventListener(\"kiss-add-word\", this.handleWordAdded);\n\n    // 监听来自外部（如选项页面）的跳转指令\n    window.addEventListener(\"message\", (event) => {\n      if (event.data && event.data.type === \"KISS_TRANSLATOR_JUMP_TO_TIME\") {\n        this.jumpToTime(event.data.time);\n      }\n    });\n  }\n\n  // ==================================================================================\n  // Public API: 初始化与数据更新\n  // ==================================================================================\n\n  /**\n   * 初始化字幕列表\n   * @param {Array} subtitles 标准化的字幕数组\n   */\n  initialize(subtitles) {\n    this.bilingualSubtitles = subtitles || [];\n    if (this.bilingualSubtitles.length > 0) {\n      this.createSubtitleList();\n      this.setupEventListeners();\n    }\n  }\n\n  /**\n   * 更新字幕数据（例如切换语言或翻译完成后）\n   * @param {Array} subtitles 标准化的字幕数组\n   */\n  setBilingualSubtitles(subtitles) {\n    this.bilingualSubtitles = subtitles || [];\n\n    if (this.subtitleListEl) {\n      // 如果 UI 已存在，尝试增量更新以提升性能\n      this.updateBilingualSubtitles();\n    } else if (this.bilingualSubtitles.length > 0) {\n      // 如果 UI 不存在，创建 UI\n      this.createSubtitleList();\n      this.setupEventListeners();\n    }\n  }\n\n  /**\n   * 销毁实例，清理 DOM 和事件监听\n   */\n  destroy() {\n    this.turnOffAutoSub();\n    document.removeEventListener(\"kiss-add-word\", this.handleWordAdded);\n    if (this.container) {\n      this.container.remove();\n      this.container = null;\n    }\n    this.subtitleListEl = null;\n    this.vocabularyListEl = null;\n    this.bilingualSubtitles = [];\n    this._cachedSubtitleItems = [];\n    this.vocabulary = [];\n  }\n\n  // ==================================================================================\n  // Logic: 核心业务逻辑 (跳转、添加单词、下载)\n  // ==================================================================================\n\n  /**\n   * 跳转视频到指定时间\n   * @param {number} timeMs 毫秒时间戳\n   */\n  jumpToTime(timeMs) {\n    if (this.videoEl && Number.isFinite(timeMs)) {\n      this.videoEl.currentTime = timeMs / 1000;\n      if (this.videoEl.paused) {\n        this.videoEl.play();\n      }\n    }\n  }\n\n  /**\n   * 处理添加单词事件\n   */\n  handleWordAdded(event) {\n    if (event.detail && event.detail.word) {\n      this.addWord(\n        event.detail.word,\n        event.detail.phonetic || \"\",\n        event.detail.definition || \"\",\n        event.detail.examples || [],\n        event.detail.timestamp || null\n      );\n    }\n  }\n\n  /**\n   * 添加或更新单词到生词本\n   */\n  addWord(\n    word,\n    phonetic = \"\",\n    definition = \"\",\n    examples = [],\n    timestamp = null\n  ) {\n    if (!word) return;\n\n    const existingIndex = this.vocabulary.findIndex(\n      (item) => item.word === word\n    );\n\n    if (existingIndex !== -1) {\n      // 单词已存在，合并/更新信息\n      const currentItem = this.vocabulary[existingIndex];\n      if (phonetic) currentItem.phonetic = phonetic;\n      if (definition) currentItem.definition = definition;\n      if (examples.length > 0) currentItem.examples = examples;\n      if (timestamp) currentItem.timestamp = timestamp;\n    } else {\n      // 新增单词\n      this.vocabulary.push({ word, phonetic, definition, examples, timestamp });\n    }\n    // 重新渲染生词本界面\n    this._renderVocabulary();\n  }\n\n  /**\n   * 下载当前双语字幕为 VTT 文件\n   */\n  downloadSubtitles() {\n    if (!this.bilingualSubtitles || this.bilingualSubtitles.length === 0) {\n      logger.info(\"Youtube Provider: No subtitles to download\");\n      return;\n    }\n\n    try {\n      const videoId = this._getYouTubeVideoId() || \"video\";\n      const vttContent = buildBilingualVtt(this.bilingualSubtitles);\n\n      downloadBlobFile(\n        vttContent,\n        `kiss-subtitles-${videoId}_${Date.now()}.vtt`\n      );\n    } catch (error) {\n      logger.error(\"Youtube Provider: download subtitles error:\", error);\n    }\n  }\n\n  // ==================================================================================\n  // UI Rendering: 界面构建\n  // ==================================================================================\n\n  /**\n   * 创建主容器和列表结构\n   */\n  createSubtitleList() {\n    if (!this.videoEl) return;\n\n    // 1. 确保主容器存在\n    this._ensureContainer();\n\n    // 2. 如果容器为空，初始化 Tab 结构和面板\n    if (this.container.children.length === 0) {\n      this._renderTabsAndStructure();\n    }\n\n    // 3. 渲染字幕列表内容\n    const ul = this.subtitleListEl.querySelector(\"ul\");\n    ul.replaceChildren();\n    this._cachedSubtitleItems = []; // 重置缓存\n\n    // 使用 DocumentFragment 批量插入，减少重排\n    const fragment = document.createDocumentFragment();\n    this.bilingualSubtitles.forEach((sub, i) => {\n      const li = this._createSubtitleListItem(sub, i);\n      this._cachedSubtitleItems.push(li);\n      fragment.appendChild(li);\n    });\n    ul.appendChild(fragment);\n\n    // 4. 渲染生词本（初始为空或已有数据）\n    this._renderVocabulary();\n  }\n\n  /**\n   * 确保主悬浮容器存在并设置样式\n   */\n  _ensureContainer() {\n    this.container = document.getElementById(\n      \"kiss-youtube-subtitle-list-container\"\n    );\n    if (!this.container) {\n      this.container = document.createElement(\"div\");\n      this.container.id = \"kiss-youtube-subtitle-list-container\";\n      this.container.className = \"notranslate\";\n      Object.assign(this.container.style, {\n        height: \"calc(100vh - 220px)\",\n        maxHeight: \"none\",\n        zIndex: \"999\",\n        background: \"var(--kt-bg, rgba(255, 255, 255, 0.9))\",\n        backdropFilter: \"blur(10px)\",\n        top: \"60px\",\n        right: \"0\",\n        fontSize: \"14px\",\n        padding: \"0\",\n        border: \"var(--kt-border, 1px solid rgba(0, 0, 0, 0.1))\",\n        borderRadius: \"8px\",\n        minWidth: \"320px\",\n        maxWidth: \"400px\",\n        boxShadow: \"0 4px 20px rgba(0,0,0,0.15)\",\n        fontFamily:\n          \"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif\",\n        display: \"flex\",\n        flexDirection: \"column\",\n        marginBottom: \"12px\",\n      });\n      // 将容器插入到 YouTube 页面右侧栏 (secondary) 的顶部\n      const secondary = document.getElementById(\"secondary-inner\");\n      if (secondary) secondary.prepend(this.container);\n\n      (async () => {\n        try {\n          const setting = await getSettingWithDefault();\n          const darkMode = setting?.darkMode;\n          const prefersDark =\n            typeof window.matchMedia === \"function\" &&\n            window.matchMedia(\"(prefers-color-scheme: dark)\").matches;\n          const isDark =\n            darkMode === \"dark\" || (darkMode === \"auto\" && prefersDark);\n\n          const lightVars = {\n            \"--kt-bg\": \"rgba(255, 255, 255, 0.9)\",\n            \"--kt-border\": \"1px solid rgba(0, 0, 0, 0.1)\",\n            \"--kt-text\": \"#333\",\n            \"--kt-subtext\": \"#666\",\n            \"--kt-primary\": \"#1e88e5\",\n            \"--kt-time-bg\": \"rgba(30, 136, 229, 0.1)\",\n            \"--kt-divider\": \"rgba(240,240,240,0.6)\",\n            \"--kt-item-hover-bg\": \"rgba(30, 136, 229, 0.05)\",\n            \"--kt-active-bg\": \"rgba(30, 136, 229, 0.1)\",\n            \"--kt-btn-bg\": \"var(--kt-primary)\",\n            \"--kt-btn-color\": \"white\",\n            \"--kt-btn-border\": \"none\",\n            \"--kt-btn-hover-bg\": \"rgba(30,136,229,0.85)\",\n          };\n\n          const darkVars = {\n            \"--kt-bg\": \"rgba(18,18,18,0.85)\",\n            \"--kt-border\": \"1px solid rgba(255, 255, 255, 0.06)\",\n            \"--kt-text\": \"#e6e6e6\",\n            \"--kt-subtext\": \"#bdbdbd\",\n            \"--kt-primary\": \"#90caf9\",\n            \"--kt-time-bg\": \"rgba(144,202,249,0.08)\",\n            \"--kt-divider\": \"rgba(255,255,255,0.06)\",\n            \"--kt-item-hover-bg\": \"rgba(255,255,255,0.02)\",\n            \"--kt-active-bg\": \"rgba(144,202,249,0.12)\",\n            \"--kt-btn-bg\": \"linear-gradient(180deg,#0f0f0f,#1b1b1b)\",\n            \"--kt-btn-color\": \"#e6e6e6\",\n            \"--kt-btn-border\": \"1px solid rgba(255,255,255,0.04)\",\n            \"--kt-btn-hover-bg\": \"linear-gradient(180deg,#141414,#262626)\",\n          };\n\n          const vars = isDark ? darkVars : lightVars;\n          Object.keys(vars).forEach((k) =>\n            this.container.style.setProperty(k, vars[k])\n          );\n        } catch (err) {\n          logger.info(\"failed to apply subtitle list theme vars\", err);\n        }\n      })();\n    }\n  }\n\n  /**\n   * 渲染 Tabs 头部和内容区域结构\n   */\n  _renderTabsAndStructure() {\n    // --- Header & Tabs (保持不变) ---\n    const tabHeader = document.createElement(\"div\");\n    tabHeader.style.cssText = `display: flex; border-bottom: 1px solid var(--kt-divider); padding: 0 16px; flex-shrink: 0;`;\n\n    const subtitleTab = document.createElement(\"button\");\n    subtitleTab.textContent = \"双语字幕\";\n    const vocabularyTab = document.createElement(\"button\");\n    vocabularyTab.textContent = \"生词本\";\n\n    const styleTab = (tab, isActive) => {\n      tab.style.cssText = `padding: 12px 16px; cursor: pointer; border: none; background: transparent; font-size: 15px; font-weight: ${isActive ? \"600\" : \"500\"}; color: ${isActive ? \"var(--kt-primary)\" : \"var(--kt-text)\"}; border-bottom: 2px solid ${isActive ? \"var(--kt-primary)\" : \"transparent\"}; margin-bottom: -1px; outline: none;`;\n    };\n\n    const closeBtn = document.createElement(\"button\");\n    closeBtn.innerHTML = \"&times;\"; // 使用 HTML 实体 ×\n    closeBtn.title = \"Close\";\n\n    // 关键样式：margin-left: auto 将按钮推到最右侧\n    closeBtn.style.cssText = `\n      margin-left: auto; \n      background: transparent; \n      border: none; \n      color: var(--kt-subtext); \n      font-size: 22px; \n      line-height: 1;\n      cursor: pointer; \n      padding: 0 8px;\n      display: flex;\n      align-items: center;\n      transition: color 0.2s;\n    `;\n\n    // 绑定销毁事件\n    closeBtn.addEventListener(\"click\", () => {\n      this.destroy(); // 使用 destroy() 可以彻底清理 DOM、定时器和事件监听\n    });\n\n    //简单的 Hover 效果\n    closeBtn.addEventListener(\n      \"mouseenter\",\n      () => (closeBtn.style.color = \"var(--kt-text)\")\n    );\n    closeBtn.addEventListener(\n      \"mouseleave\",\n      () => (closeBtn.style.color = \"var(--kt-subtext)\")\n    );\n\n    // --- Content Area ---\n    const tabContentContainer = document.createElement(\"div\");\n    // 【修改点 1】这里改为 overflow: hidden，不再让外层滚动\n    tabContentContainer.style.cssText = `overflow: hidden; flex-grow: 1; display: flex; flex-direction: column; height: calc(100% - 40px);`;\n\n    // 1. Subtitle Panel\n    this.subtitleListEl = document.createElement(\"div\");\n    this.subtitleListEl.id = \"kiss-youtube-subtitle-list\";\n    // 【修改点 2】Subtitle Panel 改为 Flex Column 布局，高度 100%\n    this.subtitleListEl.style.cssText = `display: flex; flex-direction: column; height: 100%; overflow: hidden;`;\n\n    //    1.1 Subtitle Action Bar (固定在顶部)\n    const subActionBar = document.createElement(\"div\");\n    subActionBar.style.cssText = `padding: 10px 16px; border-bottom: 1px solid var(--kt-divider); display: flex; justify-content: center; flex-shrink: 0;`;\n\n    const downloadBtn = document.createElement(\"button\");\n    downloadBtn.textContent = \"下载字幕 (VTT)\";\n    downloadBtn.style.cssText = `padding: 6px 12px; background: var(--kt-btn-bg); color: var(--kt-btn-color); border: var(--kt-btn-border); border-radius: 4px; cursor: pointer; font-size: 12px; transition: background 220ms ease, color 200ms ease, transform 160ms ease;`;\n\n    downloadBtn.addEventListener(\"mouseenter\", () => {\n      try {\n        const hover = getComputedStyle(this.container).getPropertyValue(\n          \"--kt-btn-hover-bg\"\n        );\n        if (hover) downloadBtn.style.background = hover;\n        downloadBtn.style.transform = \"translateY(-1px)\";\n      } catch (e) {}\n    });\n    downloadBtn.addEventListener(\"mouseleave\", () => {\n      try {\n        const normal = getComputedStyle(this.container).getPropertyValue(\n          \"--kt-btn-bg\"\n        );\n        if (normal) downloadBtn.style.background = normal;\n        downloadBtn.style.transform = \"translateY(0)\";\n      } catch (e) {}\n    });\n    downloadBtn.addEventListener(\"click\", this.downloadSubtitles.bind(this));\n\n    subActionBar.appendChild(downloadBtn);\n    this.subtitleListEl.appendChild(subActionBar);\n\n    //    1.2 Subtitle Scroll Container (【新增】专门的滚动容器)\n    this.subtitleScrollContainer = document.createElement(\"div\");\n    this.subtitleScrollContainer.style.cssText = `overflow-y: auto; flex: 1; padding: 0 16px; position: relative;`;\n\n    //    1.3 Subtitle List UL\n    const subUl = document.createElement(\"ul\");\n    // padding 移到 scrollContainer 上或者保持在这里，这里 margin: 0 即可\n    subUl.style.cssText = `list-style-type: none; padding: 16px 0; margin: 0;`;\n\n    this.subtitleScrollContainer.appendChild(subUl);\n    this.subtitleListEl.appendChild(this.subtitleScrollContainer);\n\n    // 2. Vocabulary Panel (保持不变，它本来就是 Flex 结构)\n    this.vocabularyListEl = document.createElement(\"div\");\n    this.vocabularyListEl.id = \"kiss-youtube-vocabulary-list\";\n    this.vocabularyListEl.style.cssText = `display: none; flex-direction: column; height: 100%; overflow: hidden;`;\n\n    // --- Tab Switching Logic ---\n    subtitleTab.addEventListener(\"click\", () => {\n      this.activeTab = \"subtitles\";\n      styleTab(subtitleTab, true);\n      styleTab(vocabularyTab, false);\n      // 注意：这里用 flex 还是 block 取决于外层布局，display: flex 配合 flex-direction: column 更好\n      this.subtitleListEl.style.display = \"flex\";\n      this.vocabularyListEl.style.display = \"none\";\n    });\n    vocabularyTab.addEventListener(\"click\", () => {\n      this.activeTab = \"vocabulary\";\n      styleTab(subtitleTab, false);\n      styleTab(vocabularyTab, true);\n      this.subtitleListEl.style.display = \"none\";\n      this.vocabularyListEl.style.display = \"flex\";\n      this._renderVocabulary();\n    });\n\n    styleTab(subtitleTab, true);\n    styleTab(vocabularyTab, false);\n\n    tabHeader.append(subtitleTab, vocabularyTab, closeBtn);\n    tabContentContainer.append(this.subtitleListEl, this.vocabularyListEl);\n    this.container.append(tabHeader, tabContentContainer);\n  }\n\n  /**\n   * 创建单个字幕行元素\n   */\n  _createSubtitleListItem(sub, index) {\n    const li = document.createElement(\"li\");\n    li.id = `kiss-youtube-item-${index}`;\n    li.className = \"kiss-youtube-item\";\n    li.dataset.time = sub.start;\n    li.style.cssText = `cursor: pointer; padding: 12px 16px; border-bottom: 1px solid var(--kt-divider); transition: all 0.2s ease; border-radius: 6px; margin-bottom: 4px; display: flex; align-items: flex-start;`;\n\n    // 时间戳\n    const timeSpan = document.createElement(\"span\");\n    timeSpan.textContent = `${this.millisToMinutesAndSeconds(sub.start)} `;\n    timeSpan.style.cssText = `color: var(--kt-primary); font-weight: 600; margin-right: 10px; font-size: 12px; background: var(--kt-time-bg); padding: 2px 6px; border-radius: 4px; flex-shrink: 0; line-height: 20px;`;\n\n    // 文本容器\n    const textContainer = document.createElement(\"div\");\n    textContainer.style.cssText = `flex-grow: 1;`;\n\n    // 原文\n    const textSpan = document.createElement(\"div\");\n    textSpan.className = \"kiss-youtube-original\";\n    textSpan.textContent = sub.text || \"\";\n    textSpan.style.cssText = `color: var(--kt-text); font-size: 14px; line-height: 1.4; margin-bottom: 4px;`;\n\n    // 译文\n    const translationEl = document.createElement(\"div\");\n    translationEl.className = \"kiss-youtube-translation\";\n    translationEl.textContent = sub.translation || \"\";\n    translationEl.style.display = sub.translation ? \"block\" : \"none\";\n    translationEl.style.cssText = `color: var(--kt-subtext); font-size: 13px; line-height: 1.4; font-style: italic; min-height: 18px;`;\n\n    // 事件\n    li.addEventListener(\"click\", () => this.jumpToTime(sub.start));\n    li.addEventListener(\"mouseenter\", () => {\n      if (!li.classList.contains(\"active-subtitle\"))\n        li.style.backgroundColor = \"var(--kt-item-hover-bg)\";\n    });\n    li.addEventListener(\"mouseleave\", () => {\n      if (!li.classList.contains(\"active-subtitle\"))\n        li.style.backgroundColor = \"transparent\";\n    });\n\n    textContainer.appendChild(textSpan);\n    textContainer.appendChild(translationEl);\n    li.appendChild(timeSpan);\n    li.appendChild(textContainer);\n\n    return li;\n  }\n\n  /**\n   * 更新现有的双语字幕列表 (Diff Update)\n   * 策略：如果数据长度不变，仅更新文本内容以提高性能；如果长度变了，重建列表。\n   */\n  updateBilingualSubtitles() {\n    if (!this.subtitleListEl) return;\n\n    // 1. 结构变化检测\n    if (this.bilingualSubtitles.length !== this._cachedSubtitleItems.length) {\n      const ul = this.subtitleListEl.querySelector(\"ul\");\n      if (ul) {\n        ul.replaceChildren();\n        this._cachedSubtitleItems = [];\n        const fragment = document.createDocumentFragment();\n        this.bilingualSubtitles.forEach((sub, i) => {\n          const li = this._createSubtitleListItem(sub, i);\n          this._cachedSubtitleItems.push(li);\n          fragment.appendChild(li);\n        });\n        ul.appendChild(fragment);\n      }\n      return;\n    }\n\n    // 2. 内容更新 (DOM 复用)\n    for (let i = 0; i < this.bilingualSubtitles.length; i++) {\n      const sub = this.bilingualSubtitles[i];\n      const item = this._cachedSubtitleItems[i];\n\n      if (item && sub) {\n        // 更新时间绑定\n        item.dataset.time = sub.start;\n        // 更新时间显示\n        const timeSpan = item.firstElementChild;\n        if (timeSpan)\n          timeSpan.textContent = `${this.millisToMinutesAndSeconds(sub.start)} `;\n        // 更新原文\n        const textSpan = item.querySelector(\".kiss-youtube-original\");\n        if (textSpan) textSpan.textContent = sub.text || \"\";\n        // 更新译文\n        const translationEl = item.querySelector(\".kiss-youtube-translation\");\n        if (translationEl) {\n          translationEl.textContent = sub.translation || \"\";\n          translationEl.style.display = sub.translation ? \"block\" : \"none\";\n        }\n      }\n    }\n  }\n\n  // ==================================================================================\n  // Vocabulary Rendering & Export: 生词本相关\n  // ==================================================================================\n\n  /**\n   * 渲染整个生词本面板\n   */\n  _renderVocabulary() {\n    if (!this.vocabularyListEl) return;\n\n    this.vocabularyListEl.replaceChildren();\n    const exportContainer = this._createExportContainer();\n    const vocabListContainer = this._createVocabListContainer();\n\n    this.vocabularyListEl.appendChild(exportContainer);\n    this.vocabularyListEl.appendChild(vocabListContainer);\n  }\n\n  /**\n   * 创建生词本的导出按钮区域\n   */\n  _createExportContainer() {\n    const exportContainer = document.createElement(\"div\");\n    exportContainer.style.cssText = `padding: 10px 16px; border-bottom: 1px solid var(--kt-divider); display: flex; justify-content: center; flex-shrink: 0; gap: 8px;`;\n\n    if (this.vocabulary.length > 0) {\n      const createBtn = (text, handler) => {\n        const btn = document.createElement(\"button\");\n        btn.textContent = text;\n        btn.style.cssText = `padding: 6px 12px; background: var(--kt-primary); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;`;\n        if (handler) btn.addEventListener(\"click\", handler.bind(this));\n        return btn;\n      };\n\n      exportContainer.appendChild(\n        createBtn(\"导出JSON\", this.exportVocabularyAsJson)\n      );\n      exportContainer.appendChild(\n        createBtn(\"导出CSV\", this.exportVocabularyAsCsv)\n      );\n      exportContainer.appendChild(\n        createBtn(\"导出TXT\", this.exportVocabularyAsTxt)\n      );\n      exportContainer.appendChild(\n        createBtn(\"导出MD\", this.exportVocabularyAsMd)\n      );\n    } else {\n      const emptyTip = document.createElement(\"span\");\n      emptyTip.textContent = \"暂无生词，在字幕中添加\";\n      emptyTip.style.color = \"var(--kt-subtext)\";\n      emptyTip.style.fontSize = \"12px\";\n      exportContainer.appendChild(emptyTip);\n    }\n    return exportContainer;\n  }\n\n  /**\n   * 创建生词列表容器\n   */\n  _createVocabListContainer() {\n    const container = document.createElement(\"div\");\n    container.style.cssText = `overflow-y: auto; overflow-x: hidden; flex: 1; padding: 0 16px; min-height: 0;`;\n\n    const list = document.createElement(\"div\");\n    list.style.cssText = `display: flex; flex-direction: column; gap: 16px; padding: 16px 0; width: 100%;`;\n\n    this.vocabulary.forEach((item) => {\n      const itemEl = this._createVocabItemElement(item);\n      list.appendChild(itemEl);\n    });\n\n    container.appendChild(list);\n    return container;\n  }\n\n  /**\n   * 创建单个生词卡片元素\n   */\n  _createVocabItemElement(item) {\n    const vocabItem = document.createElement(\"div\");\n    vocabItem.style.cssText = `border-bottom: 1px solid var(--kt-divider); word-wrap: break-word; word-break: break-word;`;\n\n    // 1. 单词行 (单词 + 音标 + 时间跳转)\n    const wordLine = document.createElement(\"div\");\n    wordLine.style.cssText = `display: flex; align-items: center; gap: 10px; margin-bottom: 8px; flex-wrap: wrap;`;\n\n    const wordEl = document.createElement(\"div\");\n    wordEl.textContent = item.word;\n    wordEl.style.cssText = `color: var(--kt-text); font-weight: bold; font-size: 16px;`;\n    wordLine.appendChild(wordEl);\n\n    if (item.phonetic) {\n      const phEl = document.createElement(\"div\");\n      const cleanPhonetic = item.phonetic;\n      phEl.textContent = `[${cleanPhonetic}]`;\n      phEl.style.cssText = `color: var(--kt-subtext); font-style: italic; font-size: 14px;`;\n      wordLine.appendChild(phEl);\n    }\n\n    if (item.timestamp) {\n      const tsBtn = document.createElement(\"button\");\n      tsBtn.textContent = `${this.millisToMinutesAndSeconds(item.timestamp)}`;\n      tsBtn.style.cssText = `color: var(--kt-primary); background: none; border: none; padding: 0 4px; font-size: 14px; cursor: pointer;`;\n      tsBtn.addEventListener(\"click\", () => this.jumpToTime(item.timestamp));\n      wordLine.appendChild(tsBtn);\n    }\n    vocabItem.appendChild(wordLine);\n\n    // 2. 释义\n    if (item.definition) {\n      const defEl = document.createElement(\"div\");\n      defEl.textContent = item.definition;\n      defEl.style.cssText = `color: var(--kt-text); margin: 8px 0; font-size: 14px; line-height: 1.4;`;\n      vocabItem.appendChild(defEl);\n    }\n\n    // 3. 例句\n    if (item.examples && item.examples.length > 0) {\n      const exContainer = document.createElement(\"div\");\n      exContainer.style.cssText = `color: var(--kt-subtext); font-size: 13px; line-height: 1.4;`;\n      item.examples.forEach((ex) => {\n        const exItem = document.createElement(\"div\");\n        exItem.style.marginBottom = \"8px\";\n        const eng = document.createElement(\"div\");\n        eng.textContent = ex.eng;\n        exItem.appendChild(eng);\n        if (ex.chs) {\n          const chs = document.createElement(\"div\");\n          chs.textContent = ex.chs;\n          chs.style.cssText = `color: var(--kt-subtext); font-style: italic;`;\n          exItem.appendChild(chs);\n        }\n        exContainer.appendChild(exItem);\n      });\n      vocabItem.appendChild(exContainer);\n    }\n\n    return vocabItem;\n  }\n\n  // --- 导出实现 ---\n\n  exportVocabularyAsJson() {\n    if (this.vocabulary.length === 0) return;\n    const videoId = this._getYouTubeVideoId();\n\n    const processedVocabulary = this.vocabulary.map((item) => {\n      const newItem = { ...item };\n      // 清理音标格式\n      if (item.phonetic) {\n        const cleanPhonetic = item.phonetic;\n        newItem.phonetic = cleanPhonetic ? `[${cleanPhonetic}]` : \"\";\n      }\n      return newItem;\n    });\n\n    const exportData = {\n      videoInfo: {\n        title: this._getYouTubeVideoTitle(),\n        url: videoId ? `https://www.youtube.com/watch?v=${videoId}` : \"\",\n        exportTime: new Date().toISOString(),\n      },\n      vocabulary: processedVocabulary,\n    };\n\n    this._downloadFile(\n      JSON.stringify(exportData, null, 2),\n      \"application/json\",\n      \"json\"\n    );\n  }\n\n  exportVocabularyAsCsv() {\n    if (this.vocabulary.length === 0) return;\n    const videoId = this._getYouTubeVideoId();\n    const header =\n      \"Word,Phonetic,Definition,Example1,Translation1,Example2,Translation2,Video Link\";\n\n    const rows = this.vocabulary.map((item) => {\n      const escapeCSVField = (field) => {\n        if (!field) return '\"\"';\n        return `\"${field.toString().replace(/\"/g, '\"\"')}\"`;\n      };\n\n      const cleanPhonetic = item.phonetic;\n      const phonetic = cleanPhonetic ? `[${cleanPhonetic}]` : \"\";\n      const ex1 = item.examples?.[0];\n      const ex2 = item.examples?.[1];\n\n      let videoLink = \"\";\n      if (item.timestamp && videoId) {\n        videoLink = `https://www.youtube.com/watch?v=${videoId}&t=${Math.floor(item.timestamp / 1000)}s`;\n      }\n\n      return [\n        item.word,\n        phonetic,\n        item.definition,\n        ex1?.eng || \"\",\n        ex1?.chs || \"\",\n        ex2?.eng || \"\",\n        ex2?.chs || \"\",\n        videoLink,\n      ]\n        .map(escapeCSVField)\n        .join(\",\");\n    });\n\n    // 添加 BOM \\uFEFF 解决 Excel 中文乱码\n    const csvContent = [\n      `\"${this._getYouTubeVideoTitle()}\",,,,,,,`,\n      `\"${videoId ? `https://www.youtube.com/watch?v=${videoId}` : \"生词本\"}\",,,,,,,`,\n      `,,,,,,,,`,\n      header,\n      ...rows,\n    ].join(\"\\n\");\n\n    this._downloadFile(\"\\uFEFF\" + csvContent, \"text/csv;charset=utf-8;\", \"csv\");\n  }\n\n  exportVocabularyAsTxt() {\n    if (this.vocabulary.length === 0) return;\n    const videoId = this._getYouTubeVideoId();\n    const lines = [];\n\n    lines.push(\"生词本导出文件\");\n    lines.push(`视频标题: ${this._getYouTubeVideoTitle()}`);\n    if (videoId)\n      lines.push(`视频链接: https://www.youtube.com/watch?v=${videoId}`);\n    lines.push(`导出时间: ${new Date().toLocaleString(\"zh-CN\")}`);\n    lines.push(\"\");\n\n    this.vocabulary.forEach((item, index) => {\n      lines.push(`${index + 1}. ${item.word}`);\n      const cleanPhonetic = item.phonetic;\n      if (cleanPhonetic) lines.push(`   音标: [${cleanPhonetic}]`);\n      if (item.definition) lines.push(`   释义: ${item.definition}`);\n\n      if (item.examples && item.examples.length > 0) {\n        lines.push(\"   例句:\");\n        item.examples.slice(0, 2).forEach((ex, i) => {\n          lines.push(`   ${i + 1}. ${ex.eng}`);\n          if (ex.chs) lines.push(`      ${ex.chs}`);\n        });\n      }\n      if (item.timestamp && videoId) {\n        lines.push(\n          `   视频链接: https://www.youtube.com/watch?v=${videoId}&t=${Math.floor(item.timestamp / 1000)}s`\n        );\n      }\n      lines.push(\"\");\n    });\n\n    this._downloadFile(lines.join(\"\\n\"), \"text/plain;charset=utf-8;\", \"txt\");\n  }\n\n  exportVocabularyAsMd() {\n    if (this.vocabulary.length === 0) return;\n    const videoId = this._getYouTubeVideoId();\n    const videoLink = videoId\n      ? `https://www.youtube.com/watch?v=${videoId}`\n      : \"\";\n\n    const lines = [];\n    lines.push(\"# 生词本导出文件\");\n    lines.push(`**视频标题:** ${this._getYouTubeVideoTitle()}`);\n    if (videoLink) lines.push(`**视频链接:** [${videoLink}](${videoLink})`);\n    lines.push(`**导出时间:** ${new Date().toLocaleString(\"zh-CN\")}`);\n    lines.push(\"\");\n\n    this.vocabulary.forEach((item, index) => {\n      lines.push(`${index + 1}. **${item.word}**`);\n      const cleanPhonetic = item.phonetic;\n      if (cleanPhonetic) lines.push(`   *音标 Phonetic:* [${cleanPhonetic}]`);\n      if (item.definition)\n        lines.push(`   *释义 Definition:* ${item.definition}`);\n\n      if (item.examples && item.examples.length > 0) {\n        lines.push(\"   *例句 Examples:*\");\n        item.examples.slice(0, 2).forEach((ex, i) => {\n          lines.push(`   ${i + 1}. ${ex.eng}`);\n          if (ex.chs) lines.push(`      ${ex.chs}`);\n        });\n      }\n      if (item.timestamp && videoId) {\n        const link = `https://www.youtube.com/watch?v=${videoId}&t=${Math.floor(item.timestamp / 1000)}s`;\n        lines.push(`   *视频链接 Video Link:* [跳转到视频时间点](${link})`);\n      }\n      lines.push(\"\");\n    });\n\n    this._downloadFile(lines.join(\"\\n\"), \"text/markdown;charset=utf-8;\", \"md\");\n  }\n\n  // ==================================================================================\n  // Sync Logic: 字幕同步滚动\n  // ==================================================================================\n\n  setupEventListeners() {\n    if (!this.container || !this.videoEl) return;\n    // 鼠标悬停时停止自动滚动，提升用户体验\n    this.container.addEventListener(\"mouseenter\", () => this.turnOffAutoSub());\n    this.container.addEventListener(\"mouseleave\", () => this.turnOnAutoSub());\n\n    // 视频事件联动\n    this.videoEl.addEventListener(\"ended\", () => this.turnOffAutoSub());\n    this.videoEl.addEventListener(\"pause\", () => this.turnOffAutoSub());\n    this.videoEl.addEventListener(\"play\", () => this.turnOnAutoSub());\n  }\n\n  /**\n   * 启动自动滚动检测循环\n   */\n  turnOnAutoSub() {\n    this.turnOffAutoSub();\n    if (this.videoEl.paused) return;\n\n    this.loopAutoScroll = setInterval(() => {\n      if (\n        !this.videoEl ||\n        this.activeTab !== \"subtitles\" ||\n        this.bilingualSubtitles.length === 0\n      )\n        return;\n\n      const currentTimeMs = this.videoEl.currentTime * 1000;\n      let currentIndex = this._binarySearchSubtitle(currentTimeMs);\n\n      if (\n        this.subtitleListEl &&\n        currentIndex !== -1 &&\n        this._cachedSubtitleItems[currentIndex]\n      ) {\n        // 移除旧高亮\n        if (\n          this._lastActiveIndex !== -1 &&\n          this._cachedSubtitleItems[this._lastActiveIndex]\n        ) {\n          const lastEl = this._cachedSubtitleItems[this._lastActiveIndex];\n          lastEl.style.fontWeight = \"normal\";\n          lastEl.style.backgroundColor = \"transparent\";\n          lastEl.classList.remove(\"active-subtitle\");\n        }\n\n        // 添加新高亮\n        const currentEl = this._cachedSubtitleItems[currentIndex];\n        currentEl.style.fontWeight = \"600\";\n        currentEl.style.backgroundColor = \"var(--kt-active-bg)\";\n        currentEl.classList.add(\"active-subtitle\");\n        this._lastActiveIndex = currentIndex;\n\n        // 【修复点】：移除未使用的 targetScrollTop 变量，使用 clean 的居中计算逻辑\n        const container = this.subtitleScrollContainer;\n        if (container) {\n          const elementTop = currentEl.offsetTop;\n          const containerHeight = container.clientHeight;\n          const elementHeight = currentEl.clientHeight;\n\n          container.scrollTo({\n            // 计算公式：元素顶部位置 - 容器一半高度 + 元素一半高度 = 元素居中\n            top: elementTop - containerHeight / 2 + elementHeight / 2,\n            behavior: \"smooth\",\n          });\n        }\n      }\n    }, 200);\n  }\n\n  /**\n   * 二分查找：根据当前时间找到对应的字幕索引\n   * 复杂度 O(log n)，远优于线性查找\n   */\n  _binarySearchSubtitle(timeMs) {\n    let left = 0;\n    let right = this.bilingualSubtitles.length - 1;\n    let bestMatch = -1;\n\n    while (left <= right) {\n      const mid = Math.floor((left + right) / 2);\n      const sub = this.bilingualSubtitles[mid];\n\n      if (timeMs >= sub.start && timeMs <= sub.end) {\n        return mid; // 精确命中时间区间\n      } else if (timeMs < sub.start) {\n        right = mid - 1;\n      } else {\n        left = mid + 1;\n        bestMatch = mid; // 如果没有精确命中，记录最接近的上一句\n      }\n    }\n    return bestMatch;\n  }\n\n  turnOffAutoSub() {\n    if (this.loopAutoScroll) {\n      clearInterval(this.loopAutoScroll);\n      this.loopAutoScroll = null;\n    }\n  }\n\n  // ==================================================================================\n  // Helpers: 工具函数\n  // ==================================================================================\n\n  /**\n   * 将文件内容转为 Blob 并触发下载\n   */\n  _downloadFile(content, mimeType, extension) {\n    const blob = new Blob([content], { type: mimeType });\n    downloadBlobFile(\n      blob,\n      `kiss-vocabulary-${new Date().toISOString().slice(0, 10)}.${extension}`\n    );\n  }\n\n  /**\n   * 格式化时间：毫秒 -> MM:SS\n   */\n  millisToMinutesAndSeconds(millis) {\n    if (!Number.isFinite(millis)) return \"0:00\";\n    const minutes = Math.floor(millis / 60000);\n    const seconds = ((millis % 60000) / 1000).toFixed(0);\n    return minutes + \":\" + (seconds < 10 ? \"0\" : \"\") + seconds;\n  }\n\n  _getYouTubeVideoId() {\n    try {\n      const urlParams = new URLSearchParams(window.location.search);\n      return urlParams.get(\"v\");\n    } catch (e) {\n      return null;\n    }\n  }\n\n  _getYouTubeVideoTitle() {\n    try {\n      const titleElement = document.querySelector(\"h1 yt-formatted-string\");\n      return titleElement ? titleElement.textContent : \"YouTube Video\";\n    } catch (e) {\n      return \"YouTube Video\";\n    }\n  }\n}\n"
  },
  {
    "path": "src/subtitle/subtitle.js",
    "content": "import { YouTubeInitializer } from \"./YouTubeCaptionProvider.js\";\nimport { isMatch } from \"../libs/utils.js\";\nimport { DEFAULT_API_SETTING } from \"../config/api.js\";\nimport { DEFAULT_SUBTITLE_SETTING } from \"../config/setting.js\";\nimport { logger } from \"../libs/log.js\";\nimport { injectJs, INJECTOR } from \"../injectors/index.js\";\n\nconst providers = [\n  { pattern: \"https://www.youtube.com\", start: YouTubeInitializer },\n];\n\nexport function runSubtitle({ href, setting }) {\n  try {\n    const subtitleSetting = setting.subtitleSetting || DEFAULT_SUBTITLE_SETTING;\n    if (!subtitleSetting.enabled) {\n      return;\n    }\n\n    const provider = providers.find((item) => isMatch(href, item.pattern));\n    if (provider) {\n      const id = \"kiss-translator-inject-subtitle-js\";\n      injectJs(INJECTOR.subtitle, id);\n\n      const apiSetting =\n        setting.transApis.find(\n          (api) => api.apiSlug === subtitleSetting.apiSlug\n        ) || DEFAULT_API_SETTING;\n      provider.start({\n        ...subtitleSetting,\n        apiSetting,\n        transApis: setting.transApis,\n        uiLang: setting.uiLang,\n      });\n    }\n  } catch (err) {\n    logger.error(\"start subtitle provider\", err);\n  }\n}\n"
  },
  {
    "path": "src/subtitle/vtt.js",
    "content": "/**\n * 将多种格式的VTT时间戳字符串转换为毫秒数。\n * 兼容以下格式：\n * - mmm (e.g., \"291040\")\n * - MM:SS (e.g., \"00:03\")\n * - HH:MM:SS (e.g., \"01:02:03\")\n * - MM:SS.mmm (e.g., \"00:07.980\")\n * - HH:MM:SS.mmm (e.g., \"01:02:03.456\")\n * - MM:SS:mmm (e.g., \"00:07:536\")\n *\n * @param {string} timestamp - VTT时间戳字符串.\n * @returns {number} - 转换后的总毫秒数.\n */\nfunction parseTimestampToMilliseconds(timestamp) {\n  const ts = timestamp.trim();\n\n  if (!ts.includes(\":\") && !ts.includes(\".\")) {\n    return parseInt(ts, 10) || 0;\n  }\n\n  let timePart = ts;\n  let msPart = \"0\";\n\n  if (ts.includes(\".\")) {\n    const parts = ts.split(\".\");\n    timePart = parts[0];\n    msPart = parts[1];\n  } else {\n    const colonParts = ts.split(\":\");\n    if (\n      colonParts.length > 1 &&\n      colonParts[colonParts.length - 1].length === 3\n    ) {\n      msPart = colonParts.pop();\n      timePart = colonParts.join(\":\");\n    }\n  }\n\n  const timeComponents = timePart.split(\":\").map((p) => parseInt(p, 10) || 0);\n  let hours = 0,\n    minutes = 0,\n    seconds = 0;\n\n  if (timeComponents.length === 3) {\n    [hours, minutes, seconds] = timeComponents;\n  } else if (timeComponents.length === 2) {\n    [minutes, seconds] = timeComponents;\n  } else if (timeComponents.length === 1) {\n    [seconds] = timeComponents;\n  }\n\n  const milliseconds = parseInt(msPart.padEnd(3, \"0\"), 10) || 0;\n\n  return (hours * 3600 + minutes * 60 + seconds) * 1000 + milliseconds;\n}\n\n/**\n * 将毫秒数转换为VTT时间戳字符串 (HH:MM:SS.mmm).\n *\n * @param {number} ms - 总毫秒数.\n * @returns {string} - 格式化的VTT时间戳 (HH:MM:SS.mmm).\n */\nfunction formatMillisecondsToTimestamp(ms) {\n  const totalSeconds = Math.floor(ms / 1000);\n  const milliseconds = String(ms % 1000).padStart(3, \"0\");\n\n  const totalMinutes = Math.floor(totalSeconds / 60);\n  const seconds = String(totalSeconds % 60).padStart(2, \"0\");\n\n  const hours = String(Math.floor(totalMinutes / 60)).padStart(2, \"0\");\n  const minutes = String(totalMinutes % 60).padStart(2, \"0\");\n\n  return `${hours}:${minutes}:${seconds}.${milliseconds}`;\n}\n\n/**\n * 解析包含双语字幕的VTT文件内容。\n * @param {string} vttText - VTT文件的文本内容。\n * @returns {Array<Object>} 一个包含字幕对象的数组，每个对象包含 start, end, text, 和 translation.\n */\nexport function parseBilingualVtt(vttText) {\n  const cleanText = vttText.replace(/^\\uFEFF/, \"\").trim();\n  if (!cleanText) {\n    return [];\n  }\n\n  const cues = cleanText.split(/\\n\\n+/);\n  const result = [];\n\n  const startIndex = cues[0].toUpperCase().includes(\"WEBVTT\") ? 1 : 0;\n\n  for (let i = startIndex; i < cues.length; i++) {\n    const cue = cues[i];\n    if (!cue.includes(\"-->\")) continue;\n\n    const lines = cue.split(\"\\n\");\n    const timestampLineIndex = lines.findIndex((line) => line.includes(\"-->\"));\n    if (timestampLineIndex === -1) continue;\n\n    const [startTimeString, endTimeString] =\n      lines[timestampLineIndex].split(\"-->\");\n    const textLines = lines.slice(timestampLineIndex + 1);\n\n    if (startTimeString && endTimeString && textLines.length > 0) {\n      const originalText = textLines[0]?.trim() || \"\";\n      const translatedText = textLines[1]?.trim() || \"\";\n\n      result.push({\n        start: parseTimestampToMilliseconds(startTimeString),\n        end: parseTimestampToMilliseconds(endTimeString),\n        text: originalText,\n        translation: translatedText,\n      });\n    }\n  }\n\n  return result;\n}\n\n/**\n * 将 parseBilingualVtt 生成的JSON数据转换回标准的VTT字幕字符串。\n * @param {Array<Object>} cues - 字幕对象数组，\n * @returns {string} - 格式化的VTT文件内容字符串。\n */\nexport function buildBilingualVtt(cues) {\n  if (!Array.isArray(cues)) {\n    return \"WEBVTT\";\n  }\n\n  const header = \"WEBVTT\";\n\n  const cueBlocks = cues.map((cue, index) => {\n    const startTime = formatMillisecondsToTimestamp(cue.start);\n    const endTime = formatMillisecondsToTimestamp(cue.end);\n\n    const cueIndex = index + 1;\n    const timestampLine = `${startTime} --> ${endTime}`;\n\n    const textLine = cue.text || \"\";\n    const translationLine = cue.translation || \"\";\n\n    return `${cueIndex}\\n${timestampLine}\\n${textLine}\\n${translationLine}`;\n  });\n\n  return [header, ...cueBlocks].join(\"\\n\\n\");\n}\n"
  },
  {
    "path": "src/userscript.js",
    "content": "import { run } from \"./common\";\n\nrun(true);\n"
  },
  {
    "path": "src/views/Action/ContentFab.js",
    "content": "import Fab from \"@mui/material/Fab\";\nimport TranslateIcon from \"@mui/icons-material/Translate\";\nimport ThemeProvider from \"../../hooks/Theme\";\nimport Draggable from \"./Draggable\";\nimport { useState, useMemo, useCallback } from \"react\";\nimport { SettingProvider } from \"../../hooks/Setting\";\nimport { MSG_TRANS_TOGGLE, MSG_POPUP_TOGGLE } from \"../../config\";\nimport useWindowSize from \"../../hooks/WindowSize\";\n\nexport default function ContentFab({\n  fabConfig: { x: fabX, y: fabY, fabClickAction = 0 } = {},\n  processActions,\n}) {\n  const fabWidth = 40;\n  const windowSize = useWindowSize();\n  const [moved, setMoved] = useState(false);\n\n  const handleStart = useCallback(() => {\n    setMoved(false);\n  }, []);\n\n  const handleMove = useCallback(() => {\n    setMoved(true);\n  }, []);\n\n  const handleClick = useCallback(() => {\n    if (!moved) {\n      if (fabClickAction === 1) {\n        processActions({ action: MSG_TRANS_TOGGLE });\n      } else {\n        processActions({ action: MSG_POPUP_TOGGLE });\n      }\n    }\n  }, [moved, fabClickAction, processActions]);\n\n  const fabProps = useMemo(\n    () => ({\n      windowSize,\n      width: fabWidth,\n      height: fabWidth,\n      left: fabX ?? -fabWidth,\n      top: fabY ?? windowSize.h / 2,\n    }),\n    [windowSize, fabWidth, fabX, fabY]\n  );\n\n  return (\n    <SettingProvider context=\"fab\">\n      <ThemeProvider>\n        <Draggable\n          key=\"fab\"\n          snapEdge\n          {...fabProps}\n          onStart={handleStart}\n          onMove={handleMove}\n          handler={\n            <Fab size=\"small\" color=\"primary\" onClick={handleClick}>\n              <TranslateIcon\n                sx={{\n                  width: 24,\n                  height: 24,\n                }}\n              />\n            </Fab>\n          }\n        />\n      </ThemeProvider>\n    </SettingProvider>\n  );\n}\n"
  },
  {
    "path": "src/views/Action/Draggable.js",
    "content": "import { useEffect, useMemo, useState } from \"react\";\nimport { limitNumber } from \"../../libs/utils\";\nimport { isMobile } from \"../../libs/mobile\";\nimport { putFab } from \"../../libs/storage\";\nimport { debounce } from \"../../libs/utils\";\nimport Paper from \"@mui/material/Paper\";\n\nconst getEdgePosition = ({\n  x: left,\n  y: top,\n  width,\n  height,\n  windowWidth,\n  windowHeight,\n  hover,\n}) => {\n  const right = windowWidth - left - width;\n  const bottom = windowHeight - top - height;\n  const min = Math.min(left, top, right, bottom);\n  switch (min) {\n    case right:\n      left = hover ? windowWidth - width : windowWidth - width / 2;\n      break;\n    case left:\n      left = hover ? 0 : -width / 2;\n      break;\n    case bottom:\n      top = hover ? windowHeight - height : windowHeight - height / 2;\n      break;\n    default:\n      top = hover ? 0 : -height / 2;\n  }\n  return { x: left, y: top };\n};\n\nfunction DraggableWrapper({ children, usePaper, ...props }) {\n  if (usePaper) {\n    return (\n      <Paper {...props} elevation={4}>\n        {children}\n      </Paper>\n    );\n  }\n  return <div {...props}>{children}</div>;\n}\n\nexport default function Draggable({\n  windowSize: { w: windowWidth, h: windowHeight },\n  width,\n  height,\n  left,\n  top,\n  show = true,\n  snapEdge,\n  onStart,\n  onMove,\n  handler,\n  children,\n  usePaper,\n}) {\n  const [hover, setHover] = useState(false);\n  const [origin, setOrigin] = useState(null);\n  const [position, setPosition] = useState({ x: left, y: top });\n  const setFabPosition = useMemo(() => debounce(putFab, 500), []);\n\n  const handlePointerDown = (e) => {\n    !isMobile && e.target.setPointerCapture(e.pointerId);\n    onStart && onStart();\n    const { x, y } = position;\n    const { clientX, clientY } = isMobile ? e.targetTouches[0] : e;\n    setOrigin({ x, y, clientX, clientY });\n  };\n\n  const handlePointerMove = (e) => {\n    onMove && onMove();\n    const { clientX, clientY } = isMobile ? e.targetTouches[0] : e;\n    if (origin) {\n      const dx = clientX - origin.clientX;\n      const dy = clientY - origin.clientY;\n      let x = origin.x + dx;\n      let y = origin.y + dy;\n      x = limitNumber(x, -width / 2, windowWidth - width / 2);\n      y = limitNumber(y, 0, windowHeight - height / 2);\n      setPosition({ x, y });\n    }\n  };\n\n  const handlePointerUp = (e) => {\n    e.stopPropagation();\n    setOrigin(null);\n  };\n\n  const handleClick = (e) => {\n    e.stopPropagation();\n  };\n\n  const handleMouseEnter = (e) => {\n    e.stopPropagation();\n    setHover(true);\n  };\n\n  const handleMouseLeave = (e) => {\n    e.stopPropagation();\n    setHover(false);\n  };\n\n  useEffect(() => {\n    if (!snapEdge || !!origin) {\n      return;\n    }\n\n    setPosition((pre) => {\n      const edgePosition = getEdgePosition({\n        ...pre,\n        width,\n        height,\n        windowWidth,\n        windowHeight,\n        hover,\n      });\n      setFabPosition(edgePosition);\n      return edgePosition;\n    });\n  }, [\n    origin,\n    hover,\n    width,\n    height,\n    windowWidth,\n    windowHeight,\n    snapEdge,\n    setFabPosition,\n  ]);\n\n  const opacity = useMemo(() => {\n    if (snapEdge) {\n      return hover || origin ? 1 : 0.2;\n    }\n    return origin ? 0.8 : 1;\n  }, [origin, snapEdge, hover]);\n\n  const touchProps = isMobile\n    ? {\n        onTouchStart: handlePointerDown,\n        onTouchMove: handlePointerMove,\n        onTouchEnd: handlePointerUp,\n      }\n    : {\n        onPointerDown: handlePointerDown,\n        onPointerMove: handlePointerMove,\n        onPointerUp: handlePointerUp,\n      };\n\n  return (\n    <DraggableWrapper\n      usePaper={usePaper}\n      style={{\n        opacity,\n        position: \"fixed\",\n        left: position.x,\n        top: position.y,\n        zIndex: 2147483647,\n        display: show ? \"block\" : \"none\",\n      }}\n      onMouseEnter={handleMouseEnter}\n      onMouseLeave={handleMouseLeave}\n      onClick={handleClick}\n    >\n      <div\n        style={{\n          touchAction: \"none\",\n        }}\n        {...touchProps}\n      >\n        {handler}\n      </div>\n      <div>{children}</div>\n    </DraggableWrapper>\n  );\n}\n"
  },
  {
    "path": "src/views/Action/index.js",
    "content": "import ThemeProvider from \"../../hooks/Theme\";\nimport Draggable from \"./Draggable\";\nimport { useEffect, useMemo, useCallback, useState } from \"react\";\nimport { SettingProvider } from \"../../hooks/Setting\";\nimport Header from \"../Popup/Header\";\nimport Box from \"@mui/material/Box\";\nimport Divider from \"@mui/material/Divider\";\nimport useWindowSize from \"../../hooks/WindowSize\";\nimport {\n  EVENT_KISS_INNER,\n  MSG_OPEN_OPTIONS,\n  MSG_POPUP_TOGGLE,\n} from \"../../config\";\nimport PopupCont from \"../Popup/PopupCont\";\nimport { isExt } from \"../../libs/client\";\nimport { sendBgMsg } from \"../../libs/msg\";\n\nexport default function Action({ translator, processActions }) {\n  const [showPopup, setShowPopup] = useState(true);\n  const [rule, setRule] = useState(translator.rule);\n  const [setting, setSetting] = useState(translator.setting);\n  const windowSize = useWindowSize();\n\n  const handleOpenSetting = useCallback(() => {\n    if (isExt) {\n      sendBgMsg(MSG_OPEN_OPTIONS);\n    } else {\n      window.open(process.env.REACT_APP_OPTIONSPAGE, \"_blank\");\n    }\n  }, []);\n\n  useEffect(() => {\n    const handleWindowClick = () => {\n      setShowPopup(false);\n    };\n    window.addEventListener(\"click\", handleWindowClick);\n    return () => {\n      window.removeEventListener(\"click\", handleWindowClick);\n    };\n  }, []);\n\n  useEffect(() => {\n    const handleStatusUpdate = (event) => {\n      if (event.detail?.action === MSG_POPUP_TOGGLE) {\n        setShowPopup((pre) => !pre);\n      }\n    };\n\n    document.addEventListener(EVENT_KISS_INNER, handleStatusUpdate);\n    return () => {\n      document.removeEventListener(EVENT_KISS_INNER, handleStatusUpdate);\n    };\n  }, []);\n\n  useEffect(() => {\n    if (showPopup) {\n      setRule(translator.rule);\n      setSetting(translator.setting);\n    }\n  }, [showPopup, translator]);\n\n  const popProps = useMemo(() => {\n    const width = Math.min(windowSize.w, 360);\n    const height = Math.min(windowSize.h, 442);\n    const left = (windowSize.w - width) / 2;\n    const top = (windowSize.h - height) / 2;\n    return {\n      windowSize,\n      width,\n      height,\n      left,\n      top,\n    };\n  }, [windowSize]);\n\n  return (\n    <SettingProvider context=\"contentPopup\">\n      <ThemeProvider>\n        {showPopup && (\n          <Draggable\n            key=\"pop\"\n            {...popProps}\n            usePaper\n            handler={\n              <Box style={{ cursor: \"move\" }}>\n                <Header\n                  onClose={() => {\n                    setShowPopup(false);\n                  }}\n                />\n                <Divider />\n              </Box>\n            }\n          >\n            <Box width={360}>\n              <PopupCont\n                rule={rule}\n                setting={setting}\n                setRule={setRule}\n                setSetting={setSetting}\n                handleOpenSetting={handleOpenSetting}\n                processActions={processActions}\n                isContent={true}\n              />\n            </Box>\n          </Draggable>\n        )}\n      </ThemeProvider>\n    </SettingProvider>\n  );\n}\n"
  },
  {
    "path": "src/views/Options/About.js",
    "content": "import Box from \"@mui/material/Box\";\nimport CircularProgress from \"@mui/material/CircularProgress\";\nimport ReactMarkdown from \"react-markdown\";\nimport { useI18n, useI18nMd } from \"../../hooks/I18n\";\n\nexport default function About() {\n  const i18n = useI18n();\n  const { data, loading, error } = useI18nMd(\"about_md\");\n  return (\n    <Box>\n      {loading ? (\n        <center>\n          <CircularProgress />\n        </center>\n      ) : (\n        <ReactMarkdown children={error ? i18n(\"about_md_local\") : data} />\n      )}\n    </Box>\n  );\n}\n"
  },
  {
    "path": "src/views/Options/Apis.js",
    "content": "import { useState, useEffect, useMemo } from \"react\";\nimport Stack from \"@mui/material/Stack\";\nimport TextField from \"@mui/material/TextField\";\nimport Button from \"@mui/material/Button\";\nimport LoadingButton from \"@mui/lab/LoadingButton\";\nimport MenuItem from \"@mui/material/MenuItem\";\nimport FormControlLabel from \"@mui/material/FormControlLabel\";\nimport Switch from \"@mui/material/Switch\";\nimport { useI18n } from \"../../hooks/I18n\";\nimport Typography from \"@mui/material/Typography\";\nimport Accordion from \"@mui/material/Accordion\";\nimport AccordionSummary from \"@mui/material/AccordionSummary\";\nimport AccordionDetails from \"@mui/material/AccordionDetails\";\nimport ExpandMoreIcon from \"@mui/icons-material/ExpandMore\";\nimport AddIcon from \"@mui/icons-material/Add\";\nimport Alert from \"@mui/material/Alert\";\nimport Menu from \"@mui/material/Menu\";\nimport Grid from \"@mui/material/Grid\";\nimport KeyboardArrowDownIcon from \"@mui/icons-material/KeyboardArrowDown\";\nimport Link from \"@mui/material/Link\";\nimport { useAlert } from \"../../hooks/Alert\";\nimport { useApiList, useApiItem } from \"../../hooks/Api\";\nimport { useConfirm } from \"../../hooks/Confirm\";\nimport { apiTranslate } from \"../../apis\";\nimport Box from \"@mui/material/Box\";\nimport ReusableAutocomplete from \"./ReusableAutocomplete\";\nimport ShowMoreButton from \"./ShowMoreButton\";\nimport {\n  OPT_TRANS_DEEPLX,\n  // OPT_TRANS_OLLAMA,\n  OPT_TRANS_CUSTOMIZE,\n  OPT_TRANS_NIUTRANS,\n  OPT_TRANS_BUILTINAI,\n  DEFAULT_FETCH_LIMIT,\n  DEFAULT_FETCH_INTERVAL,\n  DEFAULT_HTTP_TIMEOUT,\n  DEFAULT_BATCH_INTERVAL,\n  DEFAULT_BATCH_SIZE,\n  DEFAULT_BATCH_LENGTH,\n  DEFAULT_CONTEXT_SIZE,\n  OPT_ALL_TRANS_TYPES,\n  API_SPE_TYPES,\n  BUILTIN_STONES,\n  BUILTIN_PLACEHOLDERS,\n  BUILTIN_PLACETAGS,\n  OPT_TRANS_AZUREAI,\n  defaultNobatchPrompt,\n  defaultNobatchUserPrompt,\n  defaultSystemPrompt,\n  defaultSystemPromptXml,\n  defaultSystemPromptLines,\n} from \"../../config\";\nimport ValidationInput from \"../../hooks/ValidationInput\";\n\nfunction TestButton({ api }) {\n  const i18n = useI18n();\n  const alert = useAlert();\n  const [loading, setLoading] = useState(false);\n  const handleApiTest = async () => {\n    try {\n      setLoading(true);\n      const text = \"The quick brown fox jumps over the lazy dog.\";\n      const { trText } = await apiTranslate({\n        text,\n        fromLang: \"en\",\n        toLang: \"zh-CN\",\n        apiSetting: { ...api },\n        useCache: false,\n        usePool: false,\n      });\n      if (!trText) {\n        throw new Error(\"empty result\");\n      }\n      alert.success(\n        <>\n          <div>{i18n(\"test_success\")}</div>\n          <div>{text}</div>\n          <div>{trText}</div>\n        </>\n      );\n    } catch (err) {\n      // alert.error(`${i18n(\"test_failed\")}: ${err.message}`);\n      let msg = err.message;\n      try {\n        msg = JSON.stringify(JSON.parse(err.message), null, 2);\n      } catch (err) {\n        // skip\n      }\n      alert.error(\n        <>\n          <div>{i18n(\"test_failed\")}</div>\n          {msg === err.message ? <div>{msg}</div> : <pre>{msg}</pre>}\n        </>\n      );\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  return (\n    <LoadingButton\n      size=\"small\"\n      variant=\"outlined\"\n      onClick={handleApiTest}\n      loading={loading}\n    >\n      {i18n(\"click_test\")}\n    </LoadingButton>\n  );\n}\n\nfunction ApiFields({ apiSlug, isUserApi, deleteApi, copyApi }) {\n  const { api, update, reset } = useApiItem(apiSlug);\n  const i18n = useI18n();\n  const [formData, setFormData] = useState({});\n  const [isModified, setIsModified] = useState(false);\n  const [showMore, setShowMore] = useState(false);\n  const confirm = useConfirm();\n\n  useEffect(() => {\n    if (api) {\n      setFormData(api);\n    }\n  }, [api]);\n\n  useEffect(() => {\n    if (!api) return;\n    const hasChanged = JSON.stringify(api) !== JSON.stringify(formData);\n    setIsModified(hasChanged);\n  }, [api, formData]);\n\n  const handleChange = (e) => {\n    e?.preventDefault();\n    let { name, value, type, checked } = e.target;\n\n    if (type === \"checkbox\" || type === \"switch\") {\n      value = checked;\n    }\n\n    setFormData((prevData) => {\n      const newData = {\n        ...prevData,\n        [name]: value,\n      };\n\n      // 关闭聚合翻译时，自动关闭流式传输\n      if (name === \"useBatchFetch\" && value === false) {\n        newData.useStream = false;\n      }\n\n      return newData;\n    });\n  };\n\n  const handleUpdateSystemPrompt = (e) => {\n    const promptMap = {\n      json: defaultSystemPrompt,\n      xml: defaultSystemPromptXml,\n      textlines: defaultSystemPromptLines,\n    };\n    const systemPrompt =\n      promptMap[e.target.dataset.output] || defaultSystemPromptXml;\n    setFormData((prevData) => ({\n      ...prevData,\n      systemPrompt,\n    }));\n  };\n\n  const handleSave = () => {\n    // 过滤掉 api 对象中不存在的字段\n    // const updatedFields = Object.keys(formData).reduce((acc, key) => {\n    //   if (api && Object.keys(api).includes(key)) {\n    //     acc[key] = formData[key];\n    //   }\n    //   return acc;\n    // }, {});\n    // update(updatedFields);\n    update(formData);\n  };\n\n  const handleReset = () => {\n    reset();\n  };\n\n  const handleCopy = () => {\n    copyApi(formData);\n  };\n\n  const handleDelete = async () => {\n    const isConfirmed = await confirm({\n      confirmText: i18n(\"delete\"),\n      cancelText: i18n(\"cancel\"),\n    });\n\n    if (isConfirmed) {\n      deleteApi(apiSlug);\n    }\n  };\n\n  const {\n    url = \"\",\n    key = \"\",\n    model = \"\",\n    apiType,\n    systemPrompt = \"\",\n    nobatchPrompt = defaultNobatchPrompt,\n    nobatchUserPrompt = defaultNobatchUserPrompt,\n    subtitlePrompt = \"\",\n    // userPrompt = \"\",\n    customHeader = \"\",\n    customBody = \"\",\n    // think = false,\n    // thinkIgnore = \"\",\n    fetchLimit = DEFAULT_FETCH_LIMIT,\n    fetchInterval = DEFAULT_FETCH_INTERVAL,\n    httpTimeout = DEFAULT_HTTP_TIMEOUT,\n    dictNo = \"\",\n    memoryNo = \"\",\n    reqHook = \"\",\n    resHook = \"\",\n    temperature = 0,\n    maxTokens = 20480,\n    apiName = \"\",\n    isDisabled = false,\n    useBatchFetch = false,\n    useStream = false,\n    batchInterval = DEFAULT_BATCH_INTERVAL,\n    batchSize = DEFAULT_BATCH_SIZE,\n    batchLength = DEFAULT_BATCH_LENGTH,\n    useContext = false,\n    contextSize = DEFAULT_CONTEXT_SIZE,\n    tone = \"neutral\",\n    placeholder = BUILTIN_PLACEHOLDERS[0],\n    placetag = BUILTIN_PLACETAGS[0],\n    placetagFormat = \"compact\",\n    region = \"\",\n    sortOrder = 0,\n    // aiTerms = false,\n  } = formData;\n\n  const keyHelper = useMemo(\n    () => (API_SPE_TYPES.mulkeys.has(apiType) ? i18n(\"mulkeys_help\") : \"\"),\n    [apiType, i18n]\n  );\n\n  return (\n    <Stack spacing={3}>\n      <Box>\n        <Grid container spacing={2} columns={12}>\n          <Grid item xs={12} sm={12} md={6} lg={6}>\n            <TextField\n              size=\"small\"\n              fullWidth\n              label={i18n(\"api_name\")}\n              name=\"apiName\"\n              value={apiName}\n              onChange={handleChange}\n            />\n          </Grid>\n          <Grid item xs={12} sm={12} md={6} lg={6}>\n            <TextField\n              size=\"small\"\n              fullWidth\n              type=\"number\"\n              label={i18n(\"sort_order\") || \"排序权重\"}\n              name=\"sortOrder\"\n              value={sortOrder}\n              onChange={handleChange}\n              helperText={i18n(\"sort_order_help\") || \"数值越小越靠前\"}\n            />\n          </Grid>\n        </Grid>\n      </Box>\n\n      {!API_SPE_TYPES.machine.has(apiType) &&\n        apiType !== OPT_TRANS_BUILTINAI && (\n          <>\n            <TextField\n              size=\"small\"\n              label={\"URL\"}\n              name=\"url\"\n              value={url}\n              onChange={handleChange}\n              multiline={apiType === OPT_TRANS_DEEPLX}\n              maxRows={10}\n              helperText={\n                apiType === OPT_TRANS_DEEPLX ? i18n(\"mulkeys_help\") : \"\"\n              }\n            />\n            <TextField\n              size=\"small\"\n              label={\"Key\"}\n              name=\"key\"\n              value={key}\n              onChange={handleChange}\n              multiline={API_SPE_TYPES.mulkeys.has(apiType)}\n              maxRows={10}\n              helperText={keyHelper}\n            />\n          </>\n        )}\n\n      {apiType === OPT_TRANS_AZUREAI && (\n        <TextField\n          size=\"small\"\n          label={\"Region\"}\n          name=\"region\"\n          value={region}\n          onChange={handleChange}\n        />\n      )}\n\n      {API_SPE_TYPES.ai.has(apiType) && (\n        <>\n          <Box>\n            <Grid container spacing={2} columns={12}>\n              <Grid item xs={12} sm={12} md={6} lg={3}>\n                {/* todo： 改成 ReusableAutocomplete 可选择和填写模型 */}\n                <TextField\n                  size=\"small\"\n                  fullWidth\n                  label={\"Model\"}\n                  name=\"model\"\n                  value={model}\n                  onChange={handleChange}\n                />\n              </Grid>\n              <Grid item xs={12} sm={12} md={6} lg={3}>\n                <ReusableAutocomplete\n                  freeSolo\n                  size=\"small\"\n                  fullWidth\n                  options={BUILTIN_STONES}\n                  name=\"tone\"\n                  label={i18n(\"translation_style\")}\n                  value={tone}\n                  onChange={handleChange}\n                />\n              </Grid>\n              <Grid item xs={12} sm={12} md={6} lg={3}>\n                <ValidationInput\n                  size=\"small\"\n                  fullWidth\n                  label={\"Temperature (0.0-2.0)\"}\n                  type=\"number\"\n                  name=\"temperature\"\n                  value={temperature}\n                  onChange={handleChange}\n                  min={0.0}\n                  max={2.0}\n                  isFloat={true}\n                />\n              </Grid>\n              <Grid item xs={12} sm={12} md={6} lg={3}>\n                <ValidationInput\n                  size=\"small\"\n                  fullWidth\n                  label={\"Max Tokens (0-1000000)\"}\n                  type=\"number\"\n                  name=\"maxTokens\"\n                  value={maxTokens}\n                  onChange={handleChange}\n                  min={0}\n                  max={1000000}\n                />\n              </Grid>\n              <Grid item xs={12} sm={12} md={6} lg={3}></Grid>\n            </Grid>\n          </Box>\n\n          {useBatchFetch ? (\n            <TextField\n              size=\"small\"\n              label={\"Batch System Prompt\"}\n              name=\"systemPrompt\"\n              value={systemPrompt}\n              onChange={handleChange}\n              multiline\n              maxRows={10}\n              helperText={\n                <>\n                  {i18n(\"system_prompt_helper_1\")}\n                  <Link\n                    component=\"button\"\n                    sx={{ margin: \"0 1em\" }}\n                    data-output=\"json\"\n                    onClick={handleUpdateSystemPrompt}\n                  >\n                    {i18n(\"json_output\")}\n                  </Link>\n                  <Link\n                    component=\"button\"\n                    sx={{ margin: \"0 1em\" }}\n                    data-output=\"xml\"\n                    onClick={handleUpdateSystemPrompt}\n                  >\n                    {i18n(\"xml_output\")}\n                  </Link>\n                  <Link\n                    component=\"button\"\n                    sx={{ margin: \"0 1em\" }}\n                    data-output=\"textlines\"\n                    onClick={handleUpdateSystemPrompt}\n                  >\n                    {i18n(\"textlines_output\")}\n                  </Link>\n                  <br />\n                  {i18n(\"system_prompt_helper_2\")}\n                </>\n              }\n            />\n          ) : (\n            <>\n              <TextField\n                size=\"small\"\n                label={\"System Prompt\"}\n                name=\"nobatchPrompt\"\n                value={nobatchPrompt}\n                onChange={handleChange}\n                multiline\n                maxRows={10}\n              />\n              <TextField\n                size=\"small\"\n                label={\"User Prompt\"}\n                name=\"nobatchUserPrompt\"\n                value={nobatchUserPrompt}\n                onChange={handleChange}\n                multiline\n                maxRows={10}\n              />\n            </>\n          )}\n\n          <TextField\n            size=\"small\"\n            label={\"Subtitle Prompt\"}\n            name=\"subtitlePrompt\"\n            value={subtitlePrompt}\n            onChange={handleChange}\n            multiline\n            maxRows={10}\n            helperText={i18n(\"system_prompt_helper\")}\n          />\n          {/* <TextField\n            size=\"small\"\n            label={\"USER PROMPT\"}\n            name=\"userPrompt\"\n            value={userPrompt}\n            onChange={handleChange}\n            multiline\n            maxRows={10}\n          /> */}\n        </>\n      )}\n\n      {/* {apiType === OPT_TRANS_OLLAMA && (\n        <>\n          <TextField\n            select\n            size=\"small\"\n            name=\"think\"\n            value={think}\n            label={i18n(\"if_think\")}\n            onChange={handleChange}\n          >\n            <MenuItem value={false}>{i18n(\"nothink\")}</MenuItem>\n            <MenuItem value={true}>{i18n(\"think\")}</MenuItem>\n          </TextField>\n          <TextField\n            size=\"small\"\n            label={i18n(\"think_ignore\")}\n            name=\"thinkIgnore\"\n            value={thinkIgnore}\n            onChange={handleChange}\n          />\n        </>\n      )} */}\n\n      {apiType === OPT_TRANS_NIUTRANS && (\n        <>\n          <TextField\n            size=\"small\"\n            label={\"DictNo\"}\n            name=\"dictNo\"\n            value={dictNo}\n            onChange={handleChange}\n          />\n          <TextField\n            size=\"small\"\n            label={\"MemoryNo\"}\n            name=\"memoryNo\"\n            value={memoryNo}\n            onChange={handleChange}\n          />\n        </>\n      )}\n\n      {apiType === OPT_TRANS_CUSTOMIZE && (\n        <>\n          <TextField\n            size=\"small\"\n            label={\"Request Hook\"}\n            name=\"reqHook\"\n            value={reqHook}\n            onChange={handleChange}\n            multiline\n            maxRows={10}\n            FormHelperTextProps={{\n              component: \"div\",\n            }}\n            helperText={\n              <Box component=\"pre\" sx={{ overflowX: \"auto\" }}>\n                {i18n(\"request_hook_helper\")}\n              </Box>\n            }\n          />\n          <TextField\n            size=\"small\"\n            label={\"Response Hook\"}\n            name=\"resHook\"\n            value={resHook}\n            onChange={handleChange}\n            multiline\n            maxRows={10}\n            FormHelperTextProps={{\n              component: \"div\",\n            }}\n            helperText={\n              <Box component=\"pre\" sx={{ overflowX: \"auto\" }}>\n                {i18n(\"response_hook_helper\")}\n              </Box>\n            }\n          />\n        </>\n      )}\n\n      {API_SPE_TYPES.batch.has(api.apiType) && (\n        <Box>\n          <Grid container spacing={2} columns={12}>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                fullWidth\n                size=\"small\"\n                name=\"useBatchFetch\"\n                value={useBatchFetch}\n                label={i18n(\"use_batch_fetch\")}\n                onChange={handleChange}\n              >\n                <MenuItem value={false}>{i18n(\"disable\")}</MenuItem>\n                <MenuItem value={true}>{i18n(\"enable\")}</MenuItem>\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <ValidationInput\n                size=\"small\"\n                fullWidth\n                label={i18n(\"batch_interval\")}\n                type=\"number\"\n                name=\"batchInterval\"\n                value={batchInterval}\n                onChange={handleChange}\n                min={10}\n                max={10000}\n              />\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <ValidationInput\n                size=\"small\"\n                fullWidth\n                label={i18n(\"batch_size\")}\n                type=\"number\"\n                name=\"batchSize\"\n                value={batchSize}\n                onChange={handleChange}\n                min={1}\n                max={100}\n              />\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <ValidationInput\n                size=\"small\"\n                fullWidth\n                label={i18n(\"batch_length\")}\n                type=\"number\"\n                name=\"batchLength\"\n                value={batchLength}\n                onChange={handleChange}\n                min={1000}\n                max={100000}\n              />\n            </Grid>\n          </Grid>\n        </Box>\n      )}\n\n      <Box>\n        <Grid container spacing={2} columns={12}>\n          {API_SPE_TYPES.stream.has(api.apiType) && useBatchFetch && (\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                fullWidth\n                size=\"small\"\n                name=\"useStream\"\n                value={useStream}\n                label={i18n(\"use_stream\")}\n                onChange={handleChange}\n              >\n                <MenuItem value={false}>{i18n(\"disable\")}</MenuItem>\n                <MenuItem value={true}>{i18n(\"enable\")}</MenuItem>\n              </TextField>\n            </Grid>\n          )}\n\n          {API_SPE_TYPES.context.has(api.apiType) && (\n            <>\n              <Grid item xs={12} sm={12} md={6} lg={3}>\n                {\" \"}\n                <TextField\n                  select\n                  size=\"small\"\n                  fullWidth\n                  name=\"useContext\"\n                  value={useContext}\n                  label={i18n(\"use_context\")}\n                  onChange={handleChange}\n                >\n                  <MenuItem value={false}>{i18n(\"disable\")}</MenuItem>\n                  <MenuItem value={true}>{i18n(\"enable\")}</MenuItem>\n                </TextField>\n              </Grid>\n              <Grid item xs={12} sm={12} md={6} lg={3}>\n                {\" \"}\n                <TextField\n                  size=\"small\"\n                  fullWidth\n                  label={i18n(\"context_size\")}\n                  type=\"number\"\n                  name=\"contextSize\"\n                  value={contextSize}\n                  onChange={handleChange}\n                  min={1}\n                  max={20}\n                />\n              </Grid>\n            </>\n          )}\n        </Grid>\n      </Box>\n\n      <Box>\n        <Grid container spacing={2} columns={12}>\n          <Grid item xs={12} sm={12} md={6} lg={3}>\n            <ValidationInput\n              size=\"small\"\n              fullWidth\n              label={i18n(\"fetch_limit\")}\n              type=\"number\"\n              name=\"fetchLimit\"\n              value={fetchLimit}\n              onChange={handleChange}\n              min={1}\n              max={100}\n            />\n          </Grid>\n          <Grid item xs={12} sm={12} md={6} lg={3}>\n            <ValidationInput\n              size=\"small\"\n              fullWidth\n              label={i18n(\"fetch_interval\")}\n              type=\"number\"\n              name=\"fetchInterval\"\n              value={fetchInterval}\n              onChange={handleChange}\n              min={0}\n              max={5000}\n            />\n          </Grid>\n          <Grid item xs={12} sm={12} md={6} lg={3}>\n            <ValidationInput\n              size=\"small\"\n              fullWidth\n              label={i18n(\"http_timeout\")}\n              type=\"number\"\n              name=\"httpTimeout\"\n              value={httpTimeout}\n              onChange={handleChange}\n              min={100}\n              max={600000}\n            />\n          </Grid>\n          <Grid item xs={12} sm={12} md={6} lg={3}></Grid>\n        </Grid>\n      </Box>\n\n      {showMore && (\n        <>\n          <Box>\n            <Grid container spacing={2} columns={12}>\n              <Grid item xs={12} sm={12} md={6} lg={3}>\n                <TextField\n                  select\n                  fullWidth\n                  size=\"small\"\n                  name=\"placeholder\"\n                  value={placeholder}\n                  label={i18n(\"api_placeholder\")}\n                  onChange={handleChange}\n                >\n                  {BUILTIN_PLACEHOLDERS.map((item) => (\n                    <MenuItem key={item} value={item}>\n                      {item}\n                    </MenuItem>\n                  ))}\n                </TextField>\n              </Grid>\n              <Grid item xs={12} sm={12} md={6} lg={3}>\n                <TextField\n                  select\n                  fullWidth\n                  size=\"small\"\n                  name=\"placetag\"\n                  value={placetag}\n                  label={i18n(\"api_placetag\")}\n                  onChange={handleChange}\n                >\n                  {BUILTIN_PLACETAGS.map((item) => (\n                    <MenuItem key={item} value={item}>\n                      {`<${item}>`}\n                    </MenuItem>\n                  ))}\n                </TextField>\n              </Grid>\n              <Grid item xs={12} sm={12} md={6} lg={3}>\n                <TextField\n                  select\n                  fullWidth\n                  size=\"small\"\n                  name=\"placetagFormat\"\n                  value={placetagFormat}\n                  label={i18n(\"placetag_format\") || \"占位符格式\"}\n                  onChange={handleChange}\n                >\n                  <MenuItem value=\"compact\">\n                    {i18n(\"format_compact\") || \"简洁格式 <a1>\"}\n                  </MenuItem>\n                  <MenuItem value=\"attribute\">\n                    {i18n(\"format_attribute\") || \"属性格式 <a i=1>\"}\n                  </MenuItem>\n                </TextField>\n              </Grid>\n            </Grid>\n          </Box>\n\n          {apiType !== OPT_TRANS_BUILTINAI && (\n            <>\n              {\" \"}\n              <TextField\n                size=\"small\"\n                label={i18n(\"custom_header\")}\n                name=\"customHeader\"\n                value={customHeader}\n                onChange={handleChange}\n                multiline\n                maxRows={10}\n                helperText={i18n(\"custom_header_help\")}\n              />\n              <TextField\n                size=\"small\"\n                label={i18n(\"custom_body\")}\n                name=\"customBody\"\n                value={customBody}\n                onChange={handleChange}\n                multiline\n                maxRows={10}\n                helperText={i18n(\"custom_body_help\")}\n              />\n            </>\n          )}\n\n          {apiType !== OPT_TRANS_CUSTOMIZE &&\n            apiType !== OPT_TRANS_BUILTINAI && (\n              <>\n                <TextField\n                  size=\"small\"\n                  label={\"Request Hook\"}\n                  name=\"reqHook\"\n                  value={reqHook}\n                  onChange={handleChange}\n                  multiline\n                  maxRows={10}\n                  FormHelperTextProps={{\n                    component: \"div\",\n                  }}\n                  helperText={\n                    <Box component=\"pre\" sx={{ overflowX: \"auto\" }}>\n                      {i18n(\"request_hook_helper\")}\n                    </Box>\n                  }\n                />\n                <TextField\n                  size=\"small\"\n                  label={\"Response Hook\"}\n                  name=\"resHook\"\n                  value={resHook}\n                  onChange={handleChange}\n                  multiline\n                  maxRows={10}\n                  FormHelperTextProps={{\n                    component: \"div\",\n                  }}\n                  helperText={\n                    <Box component=\"pre\" sx={{ overflowX: \"auto\" }}>\n                      {i18n(\"response_hook_helper\")}\n                    </Box>\n                  }\n                />\n              </>\n            )}\n        </>\n      )}\n\n      <Stack\n        direction=\"row\"\n        alignItems=\"center\"\n        spacing={2}\n        useFlexGap\n        flexWrap=\"wrap\"\n      >\n        <Button\n          size=\"small\"\n          variant=\"contained\"\n          onClick={handleSave}\n          disabled={!isModified}\n        >\n          {i18n(\"save\")}\n        </Button>\n        <TestButton api={formData} />\n        <Button size=\"small\" variant=\"outlined\" onClick={handleReset}>\n          {i18n(\"restore_default\")}\n        </Button>\n        <Button size=\"small\" variant=\"outlined\" onClick={handleCopy}>\n          {i18n(\"copy_api\")}\n        </Button>\n        {isUserApi && (\n          <Button\n            size=\"small\"\n            variant=\"outlined\"\n            color=\"error\"\n            onClick={handleDelete}\n          >\n            {i18n(\"delete\")}\n          </Button>\n        )}\n\n        <FormControlLabel\n          control={\n            <Switch\n              size=\"small\"\n              name=\"isDisabled\"\n              checked={isDisabled}\n              onChange={handleChange}\n            />\n          }\n          label={i18n(\"is_disabled\")}\n        />\n\n        <ShowMoreButton showMore={showMore} onChange={setShowMore} />\n      </Stack>\n\n      {/* {apiType === OPT_TRANS_CUSTOMIZE && <pre>{i18n(\"custom_api_help\")}</pre>} */}\n    </Stack>\n  );\n}\n\nfunction ApiAccordion({ api, isUserApi, deleteApi, copyApi }) {\n  const [expanded, setExpanded] = useState(false);\n\n  const handleChange = (e) => {\n    setExpanded((pre) => !pre);\n  };\n\n  return (\n    <Accordion expanded={expanded} onChange={handleChange}>\n      <AccordionSummary expandIcon={<ExpandMoreIcon />}>\n        <Typography\n          sx={{\n            opacity: api.isDisabled ? 0.5 : 1,\n            overflowWrap: \"anywhere\",\n          }}\n        >\n          {`[${api.apiType}] ${api.apiName}`}\n        </Typography>\n      </AccordionSummary>\n      <AccordionDetails>\n        {expanded && (\n          <ApiFields\n            apiSlug={api.apiSlug}\n            isUserApi={isUserApi}\n            deleteApi={deleteApi}\n            copyApi={copyApi}\n          />\n        )}\n      </AccordionDetails>\n    </Accordion>\n  );\n}\n\nexport default function Apis() {\n  const i18n = useI18n();\n  const { userApis, builtinApis, addApi, deleteApi, copyApi } = useApiList();\n\n  const apiTypes = useMemo(\n    () =>\n      OPT_ALL_TRANS_TYPES.map((type) => ({\n        type,\n        label: type,\n      })),\n    []\n  );\n\n  const [anchorEl, setAnchorEl] = useState(null);\n  const open = Boolean(anchorEl);\n\n  const handleClick = (event) => {\n    setAnchorEl(event.currentTarget);\n  };\n\n  const handleClose = () => {\n    setAnchorEl(null);\n  };\n\n  const handleMenuItemClick = (apiType) => {\n    addApi(apiType);\n    handleClose();\n  };\n\n  return (\n    <Box>\n      <Stack spacing={3}>\n        <Alert severity=\"info\">\n          {i18n(\"about_api\")}\n          <br />\n          {i18n(\"about_api_2\")}\n          <br />\n          {i18n(\"about_api_3\")}\n          <Link\n            href=\"https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md\"\n            target=\"_blank\"\n          >\n            {i18n(\"goto_custom_api_example\")}\n          </Link>\n        </Alert>\n\n        <Box>\n          <Button\n            size=\"small\"\n            id=\"add-api-button\"\n            variant=\"contained\"\n            onClick={handleClick}\n            aria-controls={open ? \"add-api-menu\" : undefined}\n            aria-haspopup=\"true\"\n            aria-expanded={open ? \"true\" : undefined}\n            endIcon={<KeyboardArrowDownIcon />}\n            startIcon={<AddIcon />}\n          >\n            {i18n(\"add\")}\n          </Button>\n          <Menu\n            id=\"add-api-menu\"\n            anchorEl={anchorEl}\n            open={open}\n            onClose={handleClose}\n            MenuListProps={{\n              \"aria-labelledby\": \"add-api-button\",\n            }}\n          >\n            {apiTypes.map((apiOption) => (\n              <MenuItem\n                key={apiOption.type}\n                onClick={() => handleMenuItemClick(apiOption.type)}\n              >\n                {apiOption.label}\n              </MenuItem>\n            ))}\n          </Menu>\n        </Box>\n\n        <Box>\n          {userApis.map((api) => (\n            <ApiAccordion\n              key={api.apiSlug}\n              api={api}\n              isUserApi={true}\n              deleteApi={deleteApi}\n              copyApi={copyApi}\n            />\n          ))}\n        </Box>\n        <Box>\n          {builtinApis.map((api) => (\n            <ApiAccordion key={api.apiSlug} api={api} copyApi={copyApi} />\n          ))}\n        </Box>\n      </Stack>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "src/views/Options/DarkModeButton.js",
    "content": "import IconButton from \"@mui/material/IconButton\";\nimport { useDarkMode } from \"../../hooks/ColorMode\";\nimport LightModeIcon from \"@mui/icons-material/LightMode\";\nimport DarkModeIcon from \"@mui/icons-material/DarkMode\";\nimport BrightnessAutoIcon from \"@mui/icons-material/BrightnessAuto\";\n\nexport default function DarkModeButton() {\n  const { darkMode, toggleDarkMode } = useDarkMode();\n  return (\n    <IconButton onClick={toggleDarkMode} color=\"inherit\">\n      {darkMode === \"dark\" ? (\n        <DarkModeIcon />\n      ) : darkMode === \"light\" ? (\n        <LightModeIcon />\n      ) : (\n        <BrightnessAutoIcon />\n      )}\n    </IconButton>\n  );\n}\n"
  },
  {
    "path": "src/views/Options/DownloadButton.js",
    "content": "import FileDownloadIcon from \"@mui/icons-material/FileDownload\";\nimport LoadingButton from \"@mui/lab/LoadingButton\";\nimport { useState } from \"react\";\nimport { kissLog } from \"../../libs/log\";\nimport { downloadBlobFile } from \"../../libs/utils\";\n\nexport default function DownloadButton({ handleData, text, fileName }) {\n  const [loading, setLoading] = useState(false);\n  const handleClick = async (e) => {\n    e.preventDefault();\n    try {\n      setLoading(true);\n      const data = await handleData();\n      downloadBlobFile(data, fileName);\n    } catch (err) {\n      kissLog(\"download\", err);\n    } finally {\n      setLoading(false);\n    }\n  };\n  return (\n    <LoadingButton\n      size=\"small\"\n      variant=\"outlined\"\n      onClick={handleClick}\n      loading={loading}\n      startIcon={<FileDownloadIcon />}\n    >\n      {text}\n    </LoadingButton>\n  );\n}\n"
  },
  {
    "path": "src/views/Options/FavWords.js",
    "content": "import Stack from \"@mui/material/Stack\";\nimport { useState } from \"react\";\nimport Typography from \"@mui/material/Typography\";\nimport Accordion from \"@mui/material/Accordion\";\nimport AccordionSummary from \"@mui/material/AccordionSummary\";\nimport AccordionDetails from \"@mui/material/AccordionDetails\";\nimport ExpandMoreIcon from \"@mui/icons-material/ExpandMore\";\nimport { useI18n } from \"../../hooks/I18n\";\nimport Box from \"@mui/material/Box\";\nimport { useFavWords } from \"../../hooks/FavWords\";\nimport DictCont from \"../Selection/DictCont\";\nimport SugCont from \"../Selection/SugCont\";\nimport DownloadButton from \"./DownloadButton\";\nimport UploadButton from \"./UploadButton\";\nimport Button from \"@mui/material/Button\";\nimport ClearAllIcon from \"@mui/icons-material/ClearAll\";\nimport Alert from \"@mui/material/Alert\";\nimport { isValidWord } from \"../../libs/utils\";\nimport { kissLog } from \"../../libs/log\";\nimport { useConfirm } from \"../../hooks/Confirm\";\nimport { useSetting } from \"../../hooks/Setting\";\nimport { dictHandlers } from \"../Selection/DictHandler\";\n\nfunction FavAccordion({ word, index, createdAt, timestamp }) {\n  const [expanded, setExpanded] = useState(false);\n  const { setting } = useSetting();\n  const { enDict, enSug } = setting?.tranboxSetting || {};\n\n  const handleChange = (e) => {\n    setExpanded((pre) => !pre);\n  };\n\n  // 格式化时间为 MM:SS 格式\n  const formatTime = (milliseconds) => {\n    if (!milliseconds) return \"\";\n    const totalSeconds = Math.floor(milliseconds / 1000);\n    const minutes = Math.floor(totalSeconds / 60);\n    const seconds = totalSeconds % 60;\n    return `${minutes}:${seconds.toString().padStart(2, \"0\")}`;\n  };\n\n  // 跳转到视频时间点\n  const jumpToTime = (e) => {\n    e.stopPropagation();\n    if (timestamp) {\n      // 发送消息到内容脚本，让视频跳转到指定时间\n      window.postMessage(\n        {\n          type: \"KISS_TRANSLATOR_JUMP_TO_TIME\",\n          time: timestamp,\n        },\n        \"*\"\n      );\n    }\n  };\n\n  return (\n    <Accordion expanded={expanded} onChange={handleChange}>\n      <AccordionSummary expandIcon={<ExpandMoreIcon />}>\n        <Typography>\n          {`${index + 1}. ${word}`}\n          {timestamp && (\n            <Button\n              size=\"small\"\n              onClick={jumpToTime}\n              style={{\n                minWidth: \"auto\",\n                padding: \"0 4px\",\n                marginLeft: \"10px\",\n                fontSize: \"0.9rem\",\n                color: \"#1e88e5\",\n                textTransform: \"none\",\n              }}\n            >\n              {formatTime(timestamp)}\n            </Button>\n          )}\n        </Typography>\n      </AccordionSummary>\n      <AccordionDetails>\n        {expanded && (\n          <Stack spacing={2}>\n            <DictCont text={word} enDict={enDict} />\n            <SugCont text={word} enSug={enSug} />\n          </Stack>\n        )}\n      </AccordionDetails>\n    </Accordion>\n  );\n}\n\nexport default function FavWords() {\n  const i18n = useI18n();\n  const { favList, wordList, mergeWords, clearWords } = useFavWords();\n  const { setting } = useSetting();\n  const confirm = useConfirm();\n\n  const handleImport = (data) => {\n    try {\n      const newWords = data\n        .split(\"\\n\")\n        .map((line) => line.split(\",\")[0].trim())\n        .filter(isValidWord);\n      mergeWords(newWords);\n    } catch (err) {\n      kissLog(\"import rules\", err);\n    }\n  };\n\n  const handleClearWords = async () => {\n    const isConfirmed = await confirm({\n      confirmText: i18n(\"confirm_title\"),\n      cancelText: i18n(\"cancel\"),\n    });\n    if (isConfirmed) {\n      clearWords();\n    }\n  };\n\n  // 导出为纯文本格式\n  const handleExportTxt = async () => {\n    // 获取完整的单词信息\n    const fullWordData = [];\n\n    // 由于选项页面无法直接访问 YouTube 字幕列表中的完整数据，\n    // 我们只能导出已存储在收藏夹中的信息\n    for (const [word, data] of favList) {\n      fullWordData.push({\n        word,\n        phonetic: data.phonetic || \"\",\n        definition: data.definition || \"\",\n        examples: data.examples || [],\n        timestamp: data.timestamp || null,\n      });\n    }\n\n    const lines = [];\n    lines.push(\"生词本导出文件\");\n    lines.push(`导出时间: ${new Date().toLocaleString(\"zh-CN\")}`);\n    lines.push(\"\");\n\n    fullWordData.forEach((item, index) => {\n      lines.push(`${index + 1}. ${item.word}`);\n\n      // 清理音标，去除\"US\"标签和其他方括号，只保留音标本身，并用方括号包裹\n      const cleanPhonetic = item.phonetic;\n      if (cleanPhonetic) {\n        lines.push(`   音标: [${cleanPhonetic}]`);\n      }\n\n      if (item.definition) {\n        lines.push(`   释义: ${item.definition}`);\n      }\n\n      if (item.examples && item.examples.length > 0) {\n        lines.push(\"   例句:\");\n        item.examples.slice(0, 2).forEach((example, exIndex) => {\n          lines.push(`   ${exIndex + 1}. ${example.eng}`);\n          if (example.chs) {\n            lines.push(`      ${example.chs}`);\n          }\n        });\n      }\n\n      // 如果有时间戳，也导出时间信息\n      if (item.timestamp) {\n        const totalSeconds = Math.floor(item.timestamp / 1000);\n        const videoLink = `https://www.youtube.com/watch?t=${totalSeconds}`;\n        lines.push(`   视频链接: ${videoLink}`);\n      }\n\n      lines.push(\"\"); // 空行分隔\n    });\n\n    return lines.join(\"\\n\");\n  };\n\n  // 导出为 CSV 格式\n  const handleExportCsv = async () => {\n    // 获取完整的单词信息（包括音标、释义、例句等）\n    const fullWordData = [];\n\n    // 由于选项页面无法直接访问 YouTube 字幕列表中的完整数据，\n    // 我们只能导出已存储在收藏夹中的信息\n    for (const [word, data] of favList) {\n      fullWordData.push({\n        word,\n        phonetic: data.phonetic || \"\",\n        definition: data.definition || \"\",\n        examples: data.examples || [],\n        timestamp: data.timestamp || null,\n      });\n    }\n\n    // 创建包含多个例句列的表头\n    const header =\n      \"Word,Phonetic,Definition,Example1,Translation1,Example2,Translation2,Video Link\";\n    const rows = fullWordData.map((item) => {\n      // 转义特殊字符，特别是双引号\n      const escapeCSVField = (field) => {\n        if (!field) return '\"\"';\n        // 替换双引号为两个双引号，然后用双引号包围整个字段\n        return `\"${field.toString().replace(/\"/g, '\"\"')}\"`;\n      };\n\n      // 清理音标，去除\"US\"标签和其他方括号，只保留音标本身，并用方括号包裹\n      const cleanPhonetic = item.phonetic;\n      const phonetic = cleanPhonetic ? `[${cleanPhonetic}]` : \"\";\n      const definition = item.definition || \"\";\n\n      // 获取前两个例句及其翻译\n      let example1 = \"\";\n      let translation1 = \"\";\n      let example2 = \"\";\n      let translation2 = \"\";\n\n      if (item.examples && item.examples.length > 0) {\n        example1 = item.examples[0].eng || \"\";\n        translation1 = item.examples[0].chs || \"\";\n      }\n\n      if (item.examples && item.examples.length > 1) {\n        example2 = item.examples[1].eng || \"\";\n        translation2 = item.examples[1].chs || \"\";\n      }\n\n      // 创建YouTube链接\n      let videoLink = \"\";\n      if (item.timestamp) {\n        // 由于在选项页面无法获取具体的视频ID，我们只能提供时间参数\n        const totalSeconds = Math.floor(item.timestamp / 1000);\n        videoLink = `https://www.youtube.com/watch?t=${totalSeconds}`;\n      }\n\n      return `${escapeCSVField(item.word)},${escapeCSVField(phonetic)},${escapeCSVField(definition)},${escapeCSVField(example1)},${escapeCSVField(translation1)},${escapeCSVField(example2)},${escapeCSVField(translation2)},${escapeCSVField(videoLink)}`;\n    });\n\n    // 创建CSV内容，添加说明行和表头\n    const csvContent = [\n      // 添加文件信息（在实际使用中，这应该是视频标题和链接）\n      `\"生词本导出文件\",,,,,,,`,\n      `,,,,,,,,`,\n      // 表头\n      header,\n      // 数据行\n      ...rows,\n    ].join(\"\\n\");\n\n    // 添加 BOM 头以支持 Excel 正确显示中文\n    return \"\\uFEFF\" + csvContent;\n  };\n\n  // 导出为 Markdown 格式\n  const handleExportMd = async () => {\n    // 获取完整的单词信息\n    const fullWordData = [];\n\n    // 由于选项页面无法直接访问 YouTube 字幕列表中的完整数据，\n    // 我们只能导出已存储在收藏夹中的信息\n    for (const [word, data] of favList) {\n      fullWordData.push({\n        word,\n        phonetic: data.phonetic || \"\",\n        definition: data.definition || \"\",\n        examples: data.examples || [],\n        timestamp: data.timestamp || null,\n      });\n    }\n\n    const lines = [];\n    lines.push(\"# 生词本导出文件\");\n    lines.push(`_导出时间: ${new Date().toLocaleString(\"zh-CN\")}_`);\n    lines.push(\"\");\n\n    fullWordData.forEach((item, index) => {\n      lines.push(`${index + 1}. **${item.word}**`);\n\n      // 清理音标，去除\"US\"标签和其他方括号，只保留音标本身，并用方括号包裹\n      const cleanPhonetic = item.phonetic;\n      if (cleanPhonetic) {\n        lines.push(`   *音标 Phonetic:* [${cleanPhonetic}]`);\n      }\n\n      if (item.definition) {\n        lines.push(`   *释义 Definition:* ${item.definition}`);\n      }\n\n      if (item.examples && item.examples.length > 0) {\n        lines.push(\"   *例句 Examples:*\");\n        item.examples.slice(0, 2).forEach((example, exIndex) => {\n          lines.push(`   ${exIndex + 1}. ${example.eng}`);\n          if (example.chs) {\n            lines.push(`      ${example.chs}`);\n          }\n        });\n      }\n\n      // 如果有时间戳，也导出时间信息\n      if (item.timestamp) {\n        const totalSeconds = Math.floor(item.timestamp / 1000);\n        const videoLink = `https://www.youtube.com/watch?t=${totalSeconds}`;\n        lines.push(\n          `   *视频链接 Video Link:* [跳转到视频时间点](${videoLink})`\n        );\n      }\n\n      lines.push(\"\"); // 空行分隔\n    });\n\n    return lines.join(\"\\n\");\n  };\n\n  // 导出翻译\n  const handleTranslation = async () => {\n    const { enDict } = setting?.tranboxSetting;\n    const dict = dictHandlers[enDict];\n    if (!dict) return \"\";\n\n    const tranList = [];\n    for (const word of wordList) {\n      try {\n        const data = await dict.apiFn(word);\n        const title = `## ${dict.reWord(data) || word}`;\n        const tran = dict\n          .toText(data)\n          .map((line) => `- ${line}`)\n          .join(\"\\n\");\n        tranList.push([title, tran].join(\"\\n\"));\n      } catch (err) {\n        kissLog(\"export translation\", err);\n      }\n    }\n\n    return tranList.join(\"\\n\\n\");\n  };\n\n  return (\n    <Box>\n      <Stack spacing={3}>\n        <Alert severity=\"info\">{i18n(\"favorite_words_helper\")}</Alert>\n\n        <Stack\n          direction=\"row\"\n          alignItems=\"center\"\n          spacing={2}\n          useFlexGap\n          flexWrap=\"wrap\"\n        >\n          <UploadButton\n            text={i18n(\"import\")}\n            handleImport={handleImport}\n            fileType=\"text\"\n            fileExts={[\".txt\", \".csv\"]}\n          />\n\n          <DownloadButton\n            handleData={() => wordList.join(\"\\n\")}\n            text={i18n(\"export\")}\n            fileName={`kiss-words_${Date.now()}.txt`}\n          />\n\n          {/* 导出为 TXT 格式 */}\n          <DownloadButton\n            handleData={handleExportTxt}\n            text={i18n(\"export\") + \" (TXT)\"}\n            fileName={`kiss-words_${Date.now()}.txt`}\n          />\n\n          {/* 导出为 CSV 格式 */}\n          <DownloadButton\n            handleData={handleExportCsv}\n            text={i18n(\"export\") + \" (CSV)\"}\n            fileName={`kiss-words_${Date.now()}.csv`}\n          />\n\n          {/* 导出为 Markdown 格式 */}\n          <DownloadButton\n            handleData={handleExportMd}\n            text={i18n(\"export\") + \" (MD)\"}\n            fileName={`kiss-words_${Date.now()}.md`}\n          />\n\n          <DownloadButton\n            handleData={handleTranslation}\n            text={i18n(\"export_translation\")}\n            fileName={`kiss-words_${Date.now()}.md`}\n          />\n          <Button\n            size=\"small\"\n            variant=\"outlined\"\n            onClick={handleClearWords}\n            startIcon={<ClearAllIcon />}\n          >\n            {i18n(\"clear_all\")}\n          </Button>\n        </Stack>\n\n        <Box>\n          {favList.map(([word, { createdAt, timestamp }], index) => (\n            <FavAccordion\n              key={word}\n              index={index}\n              word={word}\n              createdAt={createdAt}\n              timestamp={timestamp}\n            />\n          ))}\n        </Box>\n      </Stack>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "src/views/Options/Header.js",
    "content": "import AppBar from \"@mui/material/AppBar\";\nimport IconButton from \"@mui/material/IconButton\";\nimport MenuIcon from \"@mui/icons-material/Menu\";\nimport Toolbar from \"@mui/material/Toolbar\";\nimport Box from \"@mui/material/Box\";\nimport Link from \"@mui/material/Link\";\nimport { useI18n } from \"../../hooks/I18n\";\nimport DarkModeButton from \"./DarkModeButton\";\nimport Typography from \"@mui/material/Typography\";\n\nfunction Header(props) {\n  const i18n = useI18n();\n  const { onDrawerToggle } = props;\n\n  return (\n    <AppBar\n      color=\"primary\"\n      position=\"sticky\"\n      sx={{\n        zIndex: 1300,\n      }}\n    >\n      <Toolbar variant=\"dense\">\n        <Box sx={{ display: { sm: \"none\", xs: \"block\" } }}>\n          <IconButton\n            color=\"inherit\"\n            aria-label=\"open drawer\"\n            onClick={onDrawerToggle}\n            edge=\"start\"\n          >\n            <MenuIcon />\n          </IconButton>\n        </Box>\n        <Typography component=\"div\" sx={{ flexGrow: 1, fontWeight: \"bold\" }}>\n          <Link\n            underline=\"none\"\n            color=\"inherit\"\n            href={process.env.REACT_APP_HOMEPAGE}\n            target=\"_blank\"\n          >{`${i18n(\"app_name\")} v${process.env.REACT_APP_VERSION}`}</Link>\n        </Typography>\n        <DarkModeButton />\n      </Toolbar>\n    </AppBar>\n  );\n}\n\nexport default Header;\n"
  },
  {
    "path": "src/views/Options/HelpButton.js",
    "content": "import Button from \"@mui/material/Button\";\nimport { useI18n } from \"../../hooks/I18n\";\nimport HelpIcon from \"@mui/icons-material/Help\";\n\nexport default function HelpButton({ url }) {\n  const i18n = useI18n();\n  return (\n    <Button\n      size=\"small\"\n      variant=\"outlined\"\n      onClick={() => {\n        window.open(url, \"_blank\");\n      }}\n      startIcon={<HelpIcon />}\n    >\n      {i18n(\"help\")}\n    </Button>\n  );\n}\n"
  },
  {
    "path": "src/views/Options/InputSetting.js",
    "content": "import Box from \"@mui/material/Box\";\nimport Stack from \"@mui/material/Stack\";\nimport TextField from \"@mui/material/TextField\";\nimport MenuItem from \"@mui/material/MenuItem\";\nimport { useI18n } from \"../../hooks/I18n\";\nimport {\n  OPT_LANGS_FROM,\n  OPT_LANGS_TO,\n  OPT_INPUT_TRANS_SIGNS,\n  OPT_INPUT_DOT_DISABLE,\n  OPT_INPUT_DOT_MOBILE,\n  OPT_INPUT_DOT_ALWAYS,\n} from \"../../config\";\nimport ShortcutInput from \"./ShortcutInput\";\nimport FormControlLabel from \"@mui/material/FormControlLabel\";\nimport Switch from \"@mui/material/Switch\";\nimport { useInputRule } from \"../../hooks/InputRule\";\nimport { useCallback } from \"react\";\nimport Grid from \"@mui/material/Grid\";\nimport { useApiList } from \"../../hooks/Api\";\nimport ValidationInput from \"../../hooks/ValidationInput\";\n\nexport default function InputSetting() {\n  const i18n = useI18n();\n  const { inputRule, updateInputRule } = useInputRule();\n  const { enabledApis } = useApiList();\n\n  const handleChange = (e) => {\n    e.preventDefault();\n    let { name, value } = e.target;\n    updateInputRule({\n      [name]: value,\n    });\n  };\n\n  const handleShortcutInput = useCallback(\n    (val) => {\n      updateInputRule({ triggerShortcut: val });\n    },\n    [updateInputRule]\n  );\n\n  const {\n    transOpen,\n    apiSlug,\n    fromLang,\n    toLang,\n    triggerShortcut,\n    triggerCount,\n    triggerTime,\n    transSign,\n    showDot,\n  } = inputRule;\n\n  return (\n    <Box>\n      <Stack spacing={3}>\n        <FormControlLabel\n          control={\n            <Switch\n              size=\"small\"\n              name=\"transOpen\"\n              checked={transOpen}\n              onChange={() => {\n                updateInputRule({ transOpen: !transOpen });\n              }}\n            />\n          }\n          label={i18n(\"use_input_box_translation\")}\n          sx={{ width: \"fit-content\" }}\n        />\n\n        <Box>\n          <Grid container spacing={2} columns={12}>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                fullWidth\n                size=\"small\"\n                name=\"apiSlug\"\n                value={apiSlug}\n                label={i18n(\"translate_service\")}\n                onChange={handleChange}\n              >\n                {enabledApis.map((api) => (\n                  <MenuItem key={api.apiSlug} value={api.apiSlug}>\n                    {api.apiName}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                fullWidth\n                size=\"small\"\n                name=\"fromLang\"\n                value={fromLang}\n                label={i18n(\"from_lang\")}\n                onChange={handleChange}\n              >\n                {OPT_LANGS_FROM.map(([lang, name]) => (\n                  <MenuItem key={lang} value={lang}>\n                    {name}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                fullWidth\n                size=\"small\"\n                name=\"toLang\"\n                value={toLang}\n                label={i18n(\"to_lang\")}\n                onChange={handleChange}\n              >\n                {OPT_LANGS_TO.map(([lang, name]) => (\n                  <MenuItem key={lang} value={lang}>\n                    {name}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                fullWidth\n                size=\"small\"\n                name=\"transSign\"\n                value={transSign}\n                label={i18n(\"input_trans_start_sign\")}\n                onChange={handleChange}\n                helperText={i18n(\"input_trans_start_sign_help\")}\n              >\n                <MenuItem value={\"\"}>{i18n(\"style_none\")}</MenuItem>\n                {OPT_INPUT_TRANS_SIGNS.map((item) => (\n                  <MenuItem key={item} value={item}>\n                    {item}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n          </Grid>\n        </Box>\n\n        <Box>\n          <Grid container spacing={2} columns={12}>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <ShortcutInput\n                value={triggerShortcut}\n                onChange={handleShortcutInput}\n                label={i18n(\"trigger_trans_shortcut\")}\n                helperText={i18n(\"trigger_trans_shortcut_help\")}\n              />\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                fullWidth\n                size=\"small\"\n                name=\"triggerCount\"\n                value={triggerCount}\n                label={i18n(\"shortcut_press_count\")}\n                onChange={handleChange}\n              >\n                {[1, 2, 3, 4, 5].map((val) => (\n                  <MenuItem key={val} value={val}>\n                    {val}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <ValidationInput\n                fullWidth\n                size=\"small\"\n                label={i18n(\"combo_timeout\")}\n                type=\"number\"\n                name=\"triggerTime\"\n                value={triggerTime}\n                onChange={handleChange}\n                min={10}\n                max={1000}\n              />\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                fullWidth\n                size=\"small\"\n                name=\"showDot\"\n                value={showDot || OPT_INPUT_DOT_MOBILE}\n                label={i18n(\"show_translation_dot\")}\n                onChange={handleChange}\n              >\n                <MenuItem value={OPT_INPUT_DOT_MOBILE}>\n                  {i18n(\"show_dot_mobile\")}\n                </MenuItem>\n                <MenuItem value={OPT_INPUT_DOT_ALWAYS}>\n                  {i18n(\"show_dot_always\")}\n                </MenuItem>\n                <MenuItem value={OPT_INPUT_DOT_DISABLE}>\n                  {i18n(\"show_dot_disable\")}\n                </MenuItem>\n              </TextField>\n            </Grid>\n          </Grid>\n        </Box>\n      </Stack>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "src/views/Options/Layout.js",
    "content": "import { useEffect, useState } from \"react\";\nimport { Outlet, useLocation } from \"react-router-dom\";\nimport useMediaQuery from \"@mui/material/useMediaQuery\";\nimport CssBaseline from \"@mui/material/CssBaseline\";\nimport Box from \"@mui/material/Box\";\nimport Navigator from \"./Navigator\";\nimport Header from \"./Header\";\nimport { useTheme } from \"@mui/material/styles\";\n\nexport default function Layout() {\n  const navWidth = 256;\n  const location = useLocation();\n  const theme = useTheme();\n  const [open, setOpen] = useState(false);\n  const isSm = useMediaQuery(theme.breakpoints.up(\"sm\"));\n\n  const handleDrawerToggle = () => {\n    setOpen(!open);\n  };\n\n  useEffect(() => {\n    setOpen(false);\n  }, [location]);\n\n  return (\n    <Box>\n      <CssBaseline />\n      <Header onDrawerToggle={handleDrawerToggle} />\n\n      <Box sx={{ display: \"flex\" }}>\n        <Box\n          component=\"nav\"\n          sx={{ width: { sm: navWidth }, flexShrink: { sm: 0 } }}\n        >\n          <Navigator\n            PaperProps={{ style: { width: navWidth } }}\n            variant={isSm ? \"permanent\" : \"temporary\"}\n            open={isSm ? true : open}\n            onClose={handleDrawerToggle}\n          />\n        </Box>\n\n        <Box component=\"main\" sx={{ flex: 1, p: 2, width: \"100%\" }}>\n          <Outlet />\n        </Box>\n      </Box>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "src/views/Options/MouseHover.js",
    "content": "import Box from \"@mui/material/Box\";\nimport Stack from \"@mui/material/Stack\";\nimport { useI18n } from \"../../hooks/I18n\";\nimport ShortcutInput from \"./ShortcutInput\";\nimport FormControlLabel from \"@mui/material/FormControlLabel\";\nimport Switch from \"@mui/material/Switch\";\nimport { useMouseHoverSetting } from \"../../hooks/MouseHover\";\nimport { useCallback } from \"react\";\nimport Grid from \"@mui/material/Grid\";\nimport { DEFAULT_MOUSEHOVER_KEY } from \"../../config\";\n\nexport default function MouseHoverSetting() {\n  const i18n = useI18n();\n  const { mouseHoverSetting, updateMouseHoverSetting } = useMouseHoverSetting();\n\n  const handleShortcutInput = useCallback(\n    (val) => {\n      updateMouseHoverSetting({ mouseHoverKey: val });\n    },\n    [updateMouseHoverSetting]\n  );\n\n  const { useMouseHover = true, mouseHoverKey = DEFAULT_MOUSEHOVER_KEY } =\n    mouseHoverSetting;\n\n  return (\n    <Box>\n      <Stack spacing={3}>\n        <FormControlLabel\n          control={\n            <Switch\n              size=\"small\"\n              name=\"useMouseHover\"\n              checked={useMouseHover}\n              onChange={() => {\n                updateMouseHoverSetting({ useMouseHover: !useMouseHover });\n              }}\n            />\n          }\n          label={i18n(\"use_mousehover_translation\")}\n          sx={{ width: \"fit-content\" }}\n        />\n\n        <Box>\n          <Grid container spacing={2} columns={12}>\n            <Grid item xs={12} sm={12} md={4} lg={4}>\n              <ShortcutInput\n                value={mouseHoverKey}\n                onChange={handleShortcutInput}\n                label={i18n(\"trigger_trans_shortcut\")}\n                helperText={i18n(\"mousehover_key_help\")}\n              />\n            </Grid>\n          </Grid>\n        </Box>\n      </Stack>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "src/views/Options/Navigator.js",
    "content": "import Drawer from \"@mui/material/Drawer\";\nimport List from \"@mui/material/List\";\nimport ListItemButton from \"@mui/material/ListItemButton\";\nimport ListItemIcon from \"@mui/material/ListItemIcon\";\nimport ListItemText from \"@mui/material/ListItemText\";\nimport Toolbar from \"@mui/material/Toolbar\";\nimport { NavLink, useMatch } from \"react-router-dom\";\nimport SettingsIcon from \"@mui/icons-material/Settings\";\nimport InfoIcon from \"@mui/icons-material/Info\";\nimport DesignServicesIcon from \"@mui/icons-material/DesignServices\";\nimport { useI18n } from \"../../hooks/I18n\";\nimport SyncIcon from \"@mui/icons-material/Sync\";\nimport ApiIcon from \"@mui/icons-material/Api\";\nimport InputIcon from \"@mui/icons-material/Input\";\nimport SelectAllIcon from \"@mui/icons-material/SelectAll\";\nimport EventNoteIcon from \"@mui/icons-material/EventNote\";\nimport MouseIcon from \"@mui/icons-material/Mouse\";\nimport SubtitlesIcon from \"@mui/icons-material/Subtitles\";\nimport FormatColorText from \"@mui/icons-material/FormatColorText\";\nimport BugReportIcon from \"@mui/icons-material/BugReport\";\n\nfunction LinkItem({ label, url, icon }) {\n  const match = useMatch(url);\n  return (\n    <ListItemButton component={NavLink} to={url} selected={!!match}>\n      <ListItemIcon>{icon}</ListItemIcon>\n      <ListItemText>{label}</ListItemText>\n    </ListItemButton>\n  );\n}\n\nexport default function Navigator(props) {\n  const i18n = useI18n();\n  const memus = [\n    {\n      id: \"basic_setting\",\n      label: i18n(\"basic_setting\"),\n      url: \"/\",\n      icon: <SettingsIcon />,\n    },\n    {\n      id: \"rules_setting\",\n      label: i18n(\"rules_setting\"),\n      url: \"/rules\",\n      icon: <DesignServicesIcon />,\n    },\n    {\n      id: \"apis_setting\",\n      label: i18n(\"apis_setting\"),\n      url: \"/apis\",\n      icon: <ApiIcon />,\n    },\n    {\n      id: \"styles_setting\",\n      label: i18n(\"styles_setting\"),\n      url: \"/styles\",\n      icon: <FormatColorText />,\n    },\n    {\n      id: \"sync\",\n      label: i18n(\"sync_setting\"),\n      url: \"/sync\",\n      icon: <SyncIcon />,\n    },\n    {\n      id: \"input_translate\",\n      label: i18n(\"input_translate\"),\n      url: \"/input\",\n      icon: <InputIcon />,\n    },\n    {\n      id: \"selection_translate\",\n      label: i18n(\"selection_translate\"),\n      url: \"/tranbox\",\n      icon: <SelectAllIcon />,\n    },\n    {\n      id: \"mousehover_translate\",\n      label: i18n(\"mousehover_translate\"),\n      url: \"/mousehover\",\n      icon: <MouseIcon />,\n    },\n    {\n      id: \"subtitle_translate\",\n      label: i18n(\"subtitle_translate\"),\n      url: \"/subtitle\",\n      icon: <SubtitlesIcon />,\n    },\n    {\n      id: \"words\",\n      label: i18n(\"favorite_words\"),\n      url: \"/words\",\n      icon: <EventNoteIcon />,\n    },\n    {\n      id: \"playground\",\n      label: \"Playground\",\n      url: \"/playground\",\n      icon: <BugReportIcon />,\n    },\n    { id: \"about\", label: i18n(\"about\"), url: \"/about\", icon: <InfoIcon /> },\n  ];\n  return (\n    <Drawer {...props}>\n      <Toolbar variant=\"dense\" />\n      <List component=\"nav\">\n        {memus.map(({ id, label, url, icon }) => (\n          <LinkItem key={id} label={label} url={url} icon={icon} />\n        ))}\n      </List>\n    </Drawer>\n  );\n}\n"
  },
  {
    "path": "src/views/Options/Playground.js",
    "content": "import { useState } from \"react\";\nimport TranForm from \"../Selection/TranForm\";\nimport { DEFAULT_SETTING, DEFAULT_TRANBOX_SETTING } from \"../../config\";\nimport { useSetting } from \"../../hooks/Setting\";\n\nexport default function Playgound() {\n  const [text, setText] = useState(\"\");\n  const { setting } = useSetting();\n  const { transApis, langDetector, tranboxSetting } =\n    setting || DEFAULT_SETTING;\n  const { apiSlugs, fromLang, toLang, toLang2, enDict, enSug } =\n    tranboxSetting || DEFAULT_TRANBOX_SETTING;\n  return (\n    <TranForm\n      text={text}\n      setText={setText}\n      apiSlugs={apiSlugs}\n      fromLang={fromLang}\n      toLang={toLang}\n      toLang2={toLang2}\n      transApis={transApis}\n      simpleStyle={false}\n      langDetector={langDetector}\n      enDict={enDict}\n      enSug={enSug}\n      isPlaygound={true}\n    />\n  );\n}\n"
  },
  {
    "path": "src/views/Options/ReusableAutocomplete.js",
    "content": "import { useState, useEffect, useRef } from \"react\";\nimport Autocomplete from \"@mui/material/Autocomplete\";\nimport TextField from \"@mui/material/TextField\";\n\n/**\n * 一个可复用的 Autocomplete 组件，增加了 name 属性和标准化的 onChange 事件\n * @param {object} props - 组件的 props\n * @param {string} props.name - 表单字段的名称，会包含在 onChange 的 event.target 中\n * @param {string} props.label - TextField 的标签\n * @param {any} props.value - 受控组件的当前值\n * @param {function} props.onChange - 值改变时的回调函数 (event) => {}\n * @param {Array} props.options - Autocomplete 的选项列表\n */\nexport default function ReusableAutocomplete({\n  name,\n  label,\n  value,\n  onChange,\n  ...rest\n}) {\n  const [inputValue, setInputValue] = useState(value || \"\");\n  const isChangeCommitted = useRef(false);\n\n  useEffect(() => {\n    setInputValue(value || \"\");\n  }, [value]);\n\n  const triggerOnChange = (newValue) => {\n    if (onChange) {\n      const syntheticEvent = {\n        target: {\n          name: name,\n          value: newValue,\n        },\n        preventDefault: () => {},\n      };\n      onChange(syntheticEvent);\n    }\n  };\n\n  const handleBlur = () => {\n    if (isChangeCommitted.current) {\n      isChangeCommitted.current = false;\n      return;\n    }\n\n    if (inputValue !== value) {\n      triggerOnChange(inputValue);\n    }\n  };\n\n  const handleChange = (event, newValue) => {\n    isChangeCommitted.current = true;\n    triggerOnChange(newValue);\n  };\n\n  const handleInputChange = (event, newInputValue) => {\n    isChangeCommitted.current = false;\n    setInputValue(newInputValue);\n  };\n\n  return (\n    <Autocomplete\n      value={value}\n      onChange={handleChange}\n      inputValue={inputValue}\n      onInputChange={handleInputChange}\n      onBlur={handleBlur}\n      {...rest}\n      renderInput={(params) => (\n        <TextField {...params} name={name} label={label} />\n      )}\n    />\n  );\n}\n"
  },
  {
    "path": "src/views/Options/Rules.js",
    "content": "import Box from \"@mui/material/Box\";\nimport Stack from \"@mui/material/Stack\";\nimport TextField from \"@mui/material/TextField\";\nimport Button from \"@mui/material/Button\";\nimport CircularProgress from \"@mui/material/CircularProgress\";\nimport Alert from \"@mui/material/Alert\";\nimport {\n  GLOBAL_KEY,\n  DEFAULT_RULE,\n  GLOBLA_RULE,\n  OPT_LANGS_FROM,\n  OPT_LANGS_TO,\n  URL_KISS_RULES_NEW_ISSUE,\n  OPT_SYNCTYPE_WORKER,\n  DEFAULT_TRANS_TAG,\n  OPT_SPLIT_PARAGRAPH_DISABLE,\n  OPT_HIGHLIGHT_WORDS_DISABLE,\n  OPT_SPLIT_PARAGRAPH_ALL,\n  OPT_HIGHLIGHT_WORDS_ALL,\n} from \"../../config\";\nimport { useState, useEffect, useMemo } from \"react\";\nimport { useI18n } from \"../../hooks/I18n\";\nimport Typography from \"@mui/material/Typography\";\nimport Accordion from \"@mui/material/Accordion\";\nimport AccordionSummary from \"@mui/material/AccordionSummary\";\nimport AccordionDetails from \"@mui/material/AccordionDetails\";\nimport ExpandMoreIcon from \"@mui/icons-material/ExpandMore\";\nimport { useRules } from \"../../hooks/Rules\";\nimport MenuItem from \"@mui/material/MenuItem\";\nimport Grid from \"@mui/material/Grid\";\nimport { useSetting } from \"../../hooks/Setting\";\nimport FormControlLabel from \"@mui/material/FormControlLabel\";\nimport Switch from \"@mui/material/Switch\";\nimport Tabs from \"@mui/material/Tabs\";\nimport Tab from \"@mui/material/Tab\";\nimport Radio from \"@mui/material/Radio\";\nimport RadioGroup from \"@mui/material/RadioGroup\";\nimport DeleteIcon from \"@mui/icons-material/Delete\";\nimport IconButton from \"@mui/material/IconButton\";\nimport ShareIcon from \"@mui/icons-material/Share\";\nimport SyncIcon from \"@mui/icons-material/Sync\";\nimport { useSubRules } from \"../../hooks/SubRules\";\nimport { syncSubRules } from \"../../libs/subRules\";\nimport { loadOrFetchSubRules } from \"../../libs/subRules\";\nimport { useAlert } from \"../../hooks/Alert\";\nimport { syncShareRules } from \"../../libs/sync\";\nimport { debounce } from \"../../libs/utils\";\nimport { delSubRules, getSyncWithDefault } from \"../../libs/storage\";\nimport ClearAllIcon from \"@mui/icons-material/ClearAll\";\nimport HelpButton from \"./HelpButton\";\nimport { useSyncCaches } from \"../../hooks/Sync\";\nimport DownloadButton from \"./DownloadButton\";\nimport UploadButton from \"./UploadButton\";\nimport AddIcon from \"@mui/icons-material/Add\";\nimport EditIcon from \"@mui/icons-material/Edit\";\nimport CancelIcon from \"@mui/icons-material/Cancel\";\nimport SaveIcon from \"@mui/icons-material/Save\";\nimport ValidationInput from \"../../hooks/ValidationInput\";\nimport { kissLog } from \"../../libs/log\";\nimport { useApiList } from \"../../hooks/Api\";\nimport ShowMoreButton from \"./ShowMoreButton\";\nimport { useConfirm } from \"../../hooks/Confirm\";\nimport { useAllTextStyles } from \"../../hooks/CustomStyles\";\n\nconst calculateInitialValues = (rule) => {\n  const base = rule?.pattern === \"*\" ? GLOBLA_RULE : DEFAULT_RULE;\n  return { ...base, ...(rule || {}) };\n};\n\nfunction RuleFields({ rule, rules, setShow, setKeyword }) {\n  const editMode = useMemo(() => !!rule, [rule]);\n\n  const i18n = useI18n();\n  const [disabled, setDisabled] = useState(editMode);\n  const [errors, setErrors] = useState({});\n  const [initialFormValues, setInitialFormValues] = useState(() =>\n    calculateInitialValues(rule)\n  );\n  const [formValues, setFormValues] = useState(initialFormValues);\n  const [showMore, setShowMore] = useState(!rules);\n  const { enabledApis } = useApiList();\n  const { allTextStyles } = useAllTextStyles();\n\n  useEffect(() => {\n    const newInitialValues = calculateInitialValues(rule);\n    setInitialFormValues(newInitialValues);\n    setFormValues(newInitialValues);\n  }, [rule]);\n\n  const {\n    pattern,\n    selector,\n    keepSelector = \"\",\n    rootsSelector = \"\",\n    ignoreSelector = \"\",\n    terms = \"\",\n    aiTerms = \"\",\n    termsStyle = \"\",\n    highlightStyle = \"color: red;\",\n    textExtStyle = \"\",\n    selectStyle = \"\",\n    parentStyle = \"\",\n    grandStyle = \"\",\n    injectJs = \"\",\n    injectCss = \"\",\n    apiSlug,\n    fromLang,\n    toLang,\n    textStyle,\n    transOpen,\n    // bgColor,\n    // textDiyStyle,\n    transOnly = \"false\",\n    autoScan = \"true\",\n    hasRichText = \"true\",\n    hasShadowroot = \"false\",\n    scanAll = \"false\",\n    // transTiming = OPT_TIMING_PAGESCROLL,\n    transTag = DEFAULT_TRANS_TAG,\n    transTitle = \"false\",\n    // detectRemote = \"true\",\n    // skipLangs = [],\n    // fixerSelector = \"\",\n    // fixerFunc = \"-\",\n    transStartHook = \"\",\n    transEndHook = \"\",\n    // transRemoveHook = \"\",\n    splitParagraph = OPT_SPLIT_PARAGRAPH_DISABLE,\n    splitLength = 0,\n    highlightWords = OPT_HIGHLIGHT_WORDS_DISABLE,\n  } = formValues;\n\n  const isModified = useMemo(() => {\n    return JSON.stringify(initialFormValues) !== JSON.stringify(formValues);\n  }, [initialFormValues, formValues]);\n\n  const hasSamePattern = (str) => {\n    for (const item of rules.list) {\n      if (item.pattern === str && rule?.pattern !== str) {\n        return true;\n      }\n    }\n    return false;\n  };\n\n  const handleFocus = (e) => {\n    e.preventDefault();\n    const { name } = e.target;\n    setErrors((pre) => ({ ...pre, [name]: \"\" }));\n  };\n\n  const handlePatternChange = useMemo(\n    () =>\n      debounce(async (patterns) => {\n        setKeyword(patterns.trim());\n      }, 500),\n    [setKeyword]\n  );\n\n  const handleChange = (e) => {\n    e.preventDefault();\n    const { name, value } = e.target;\n    setFormValues((pre) => ({ ...pre, [name]: value }));\n    if (name === \"pattern\" && !editMode) {\n      handlePatternChange(value);\n    }\n  };\n\n  const handleCancel = (e) => {\n    e.preventDefault();\n    if (editMode) {\n      setDisabled(true);\n    } else {\n      setShow(false);\n    }\n    setErrors({});\n    setFormValues(initialFormValues);\n  };\n\n  const handleRestore = (e) => {\n    e.preventDefault();\n    setFormValues(({ pattern }) => ({\n      ...(pattern === \"*\" ? GLOBLA_RULE : DEFAULT_RULE),\n      pattern,\n    }));\n  };\n\n  const handleSubmit = (e) => {\n    e.preventDefault();\n    const errors = {};\n    if (!pattern.trim()) {\n      errors.pattern = i18n(\"error_cant_be_blank\");\n    }\n    if (hasSamePattern(pattern)) {\n      errors.pattern = i18n(\"error_duplicate_values\");\n    }\n    if (pattern === \"*\" && !errors.pattern && !selector.trim()) {\n      errors.selector = i18n(\"error_cant_be_blank\");\n    }\n    if (Object.keys(errors).length > 0) {\n      setErrors(errors);\n      return;\n    }\n\n    if (editMode) {\n      // 编辑\n      setDisabled(true);\n      rules.put(rule.pattern, formValues);\n    } else {\n      // 添加\n      rules.add(formValues);\n      setShow(false);\n      setFormValues(initialFormValues);\n    }\n  };\n\n  const GlobalItem = rule?.pattern !== \"*\" && (\n    <MenuItem key={GLOBAL_KEY} value={GLOBAL_KEY}>\n      {GLOBAL_KEY}\n    </MenuItem>\n  );\n\n  return (\n    <form onSubmit={handleSubmit}>\n      <Stack spacing={2}>\n        <TextField\n          size=\"small\"\n          label={i18n(\"pattern\")}\n          error={!!errors.pattern}\n          helperText={errors.pattern || i18n(\"pattern_helper\")}\n          name=\"pattern\"\n          value={pattern}\n          disabled={rule?.pattern === \"*\" || disabled}\n          onChange={handleChange}\n          onFocus={handleFocus}\n          multiline\n        />\n        <TextField\n          size=\"small\"\n          label={i18n(\"root_selector\")}\n          helperText={i18n(\"root_selector_helper\")}\n          name=\"rootsSelector\"\n          value={rootsSelector}\n          disabled={disabled}\n          onChange={handleChange}\n          multiline\n        />\n        <TextField\n          size=\"small\"\n          label={i18n(\"ignore_selector\")}\n          helperText={i18n(\"ignore_selector_helper\")}\n          name=\"ignoreSelector\"\n          value={ignoreSelector}\n          disabled={disabled}\n          onChange={handleChange}\n          multiline\n        />\n        <TextField\n          size=\"small\"\n          label={i18n(\"target_selector\")}\n          error={!!errors.selector}\n          helperText={errors.selector || i18n(\"selector_helper\")}\n          name=\"selector\"\n          value={selector}\n          disabled={autoScan === \"true\" || disabled}\n          onChange={handleChange}\n          onFocus={handleFocus}\n          multiline\n        />\n        <TextField\n          size=\"small\"\n          label={i18n(\"keep_selector\")}\n          helperText={i18n(\"keep_selector_helper\")}\n          name=\"keepSelector\"\n          value={keepSelector}\n          disabled={disabled}\n          onChange={handleChange}\n          multiline\n        />\n\n        <Box>\n          <Grid container spacing={2} columns={12}>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                size=\"small\"\n                fullWidth\n                name=\"transOpen\"\n                value={transOpen}\n                label={i18n(\"translate_switch\")}\n                disabled={disabled}\n                onChange={handleChange}\n              >\n                {GlobalItem}\n                <MenuItem value={\"true\"}>{i18n(\"default_enabled\")}</MenuItem>\n                <MenuItem value={\"false\"}>{i18n(\"default_disabled\")}</MenuItem>\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                size=\"small\"\n                fullWidth\n                name=\"apiSlug\"\n                value={apiSlug}\n                label={i18n(\"translate_service\")}\n                disabled={disabled}\n                onChange={handleChange}\n              >\n                {GlobalItem}\n                {enabledApis.map((api) => (\n                  <MenuItem key={api.apiSlug} value={api.apiSlug}>\n                    {api.apiName}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                size=\"small\"\n                fullWidth\n                name=\"fromLang\"\n                value={fromLang}\n                label={i18n(\"from_lang\")}\n                disabled={disabled}\n                onChange={handleChange}\n              >\n                {GlobalItem}\n                {OPT_LANGS_FROM.map(([lang, name]) => (\n                  <MenuItem key={lang} value={lang}>\n                    {name}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                size=\"small\"\n                fullWidth\n                name=\"toLang\"\n                value={toLang}\n                label={i18n(\"to_lang\")}\n                disabled={disabled}\n                onChange={handleChange}\n              >\n                {GlobalItem}\n                {OPT_LANGS_TO.map(([lang, name]) => (\n                  <MenuItem key={lang} value={lang}>\n                    {name}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                size=\"small\"\n                fullWidth\n                name=\"autoScan\"\n                value={autoScan}\n                label={i18n(\"auto_scan_page\")}\n                disabled={disabled}\n                onChange={handleChange}\n              >\n                {GlobalItem}\n                <MenuItem value={\"false\"}>{i18n(\"disable\")}</MenuItem>\n                <MenuItem value={\"true\"}>{i18n(\"enable\")}</MenuItem>\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                size=\"small\"\n                fullWidth\n                name=\"hasRichText\"\n                value={hasRichText}\n                label={i18n(\"has_rich_text\")}\n                disabled={disabled}\n                onChange={handleChange}\n              >\n                {GlobalItem}\n                <MenuItem value={\"false\"}>{i18n(\"disable\")}</MenuItem>\n                <MenuItem value={\"true\"}>{i18n(\"enable\")}</MenuItem>\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                size=\"small\"\n                fullWidth\n                name=\"hasShadowroot\"\n                value={hasShadowroot}\n                label={i18n(\"has_shadowroot\")}\n                disabled={disabled}\n                onChange={handleChange}\n              >\n                {GlobalItem}\n                <MenuItem value={\"false\"}>{i18n(\"disable\")}</MenuItem>\n                <MenuItem value={\"true\"}>{i18n(\"enable\")}</MenuItem>\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                size=\"small\"\n                fullWidth\n                name=\"scanAll\"\n                value={scanAll}\n                label={i18n(\"scan_all_nodes\")}\n                disabled={disabled}\n                onChange={handleChange}\n              >\n                {GlobalItem}\n                <MenuItem value={\"false\"}>{i18n(\"disable\")}</MenuItem>\n                <MenuItem value={\"true\"}>{i18n(\"enable\")}</MenuItem>\n              </TextField>\n            </Grid>\n\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                size=\"small\"\n                fullWidth\n                name=\"transOnly\"\n                value={transOnly}\n                label={i18n(\"show_only_translations\")}\n                disabled={disabled}\n                onChange={handleChange}\n              >\n                {GlobalItem}\n                <MenuItem value={\"false\"}>{i18n(\"disable\")}</MenuItem>\n                <MenuItem value={\"true\"}>{i18n(\"enable\")}</MenuItem>\n              </TextField>\n            </Grid>\n\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                size=\"small\"\n                fullWidth\n                name=\"splitParagraph\"\n                value={splitParagraph}\n                label={i18n(\"split_paragraph\")}\n                disabled={disabled}\n                onChange={handleChange}\n              >\n                {GlobalItem}\n                {OPT_SPLIT_PARAGRAPH_ALL.map((item) => (\n                  <MenuItem key={item} value={item}>\n                    {i18n(item)}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <ValidationInput\n                fullWidth\n                size=\"small\"\n                label={i18n(\"split_length\")}\n                type=\"number\"\n                name=\"splitLength\"\n                value={splitLength}\n                disabled={disabled}\n                onChange={handleChange}\n                min={0}\n                max={1000}\n              />\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                size=\"small\"\n                fullWidth\n                name=\"highlightWords\"\n                value={highlightWords}\n                label={i18n(\"highlight_words\")}\n                disabled={disabled}\n                onChange={handleChange}\n              >\n                {GlobalItem}\n                {OPT_HIGHLIGHT_WORDS_ALL.map((item) => (\n                  <MenuItem key={item} value={item}>\n                    {i18n(item)}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                size=\"small\"\n                fullWidth\n                name=\"transTitle\"\n                value={transTitle}\n                label={i18n(\"translate_page_title\")}\n                disabled={disabled}\n                onChange={handleChange}\n              >\n                {GlobalItem}\n                <MenuItem value={\"false\"}>{i18n(\"disable\")}</MenuItem>\n                <MenuItem value={\"true\"}>{i18n(\"enable\")}</MenuItem>\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                size=\"small\"\n                fullWidth\n                name=\"transTag\"\n                value={transTag}\n                label={i18n(\"translation_element_tag\")}\n                disabled={disabled}\n                onChange={handleChange}\n              >\n                {GlobalItem}\n                <MenuItem value={\"span\"}>{`<span>`}</MenuItem>\n                <MenuItem value={\"font\"}>{`<font>`}</MenuItem>\n              </TextField>\n            </Grid>\n\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                size=\"small\"\n                fullWidth\n                name=\"textStyle\"\n                value={textStyle}\n                label={i18n(\"text_style\")}\n                disabled={disabled}\n                onChange={handleChange}\n              >\n                {GlobalItem}\n                {allTextStyles.map((item) => (\n                  <MenuItem key={item.styleSlug} value={item.styleSlug}>\n                    {item.styleName}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n          </Grid>\n        </Box>\n\n        {showMore && (\n          <>\n            <TextField\n              size=\"small\"\n              label={i18n(\"terms\")}\n              helperText={i18n(\"terms_helper\")}\n              name=\"terms\"\n              value={terms}\n              disabled={disabled}\n              onChange={handleChange}\n              multiline\n              maxRows={10}\n            />\n            <TextField\n              size=\"small\"\n              label={i18n(\"ai_terms\")}\n              helperText={i18n(\"ai_terms_helper\")}\n              name=\"aiTerms\"\n              value={aiTerms}\n              disabled={disabled}\n              onChange={handleChange}\n              multiline\n              maxRows={10}\n            />\n\n            <TextField\n              size=\"small\"\n              label={i18n(\"terms_style\")}\n              name=\"termsStyle\"\n              value={termsStyle}\n              disabled={disabled}\n              onChange={handleChange}\n              maxRows={10}\n              multiline\n            />\n            <TextField\n              size=\"small\"\n              label={i18n(\"highlight_style\")}\n              name=\"highlightStyle\"\n              value={highlightStyle}\n              disabled={disabled}\n              onChange={handleChange}\n              maxRows={10}\n              multiline\n            />\n            <TextField\n              size=\"small\"\n              label={i18n(\"text_ext_style\")}\n              name=\"textExtStyle\"\n              value={textExtStyle}\n              disabled={disabled}\n              onChange={handleChange}\n              maxRows={10}\n              multiline\n            />\n            <TextField\n              size=\"small\"\n              label={i18n(\"selector_style\")}\n              name=\"selectStyle\"\n              value={selectStyle}\n              disabled={disabled}\n              onChange={handleChange}\n              maxRows={10}\n              multiline\n            />\n            <TextField\n              size=\"small\"\n              label={i18n(\"selector_parent_style\")}\n              name=\"parentStyle\"\n              value={parentStyle}\n              disabled={disabled}\n              onChange={handleChange}\n              maxRows={10}\n              multiline\n            />\n            <TextField\n              size=\"small\"\n              label={i18n(\"selector_grand_style\")}\n              name=\"grandStyle\"\n              value={grandStyle}\n              disabled={disabled}\n              onChange={handleChange}\n              maxRows={10}\n              multiline\n            />\n\n            <TextField\n              size=\"small\"\n              label={i18n(\"translate_start_hook\")}\n              helperText={i18n(\"translate_start_hook_helper\")}\n              name=\"transStartHook\"\n              value={transStartHook}\n              disabled={disabled}\n              onChange={handleChange}\n              multiline\n              maxRows={10}\n            />\n            <TextField\n              size=\"small\"\n              label={i18n(\"translate_end_hook\")}\n              helperText={i18n(\"translate_end_hook_helper\")}\n              name=\"transEndHook\"\n              value={transEndHook}\n              disabled={disabled}\n              onChange={handleChange}\n              multiline\n              maxRows={10}\n            />\n            {/* <TextField\n              size=\"small\"\n              label={i18n(\"translate_remove_hook\")}\n              helperText={i18n(\"translate_remove_hook_helper\")}\n              name=\"transRemoveHook\"\n              value={transRemoveHook}\n              disabled={disabled}\n              onChange={handleChange}\n              multiline\n              maxRows={10}\n            /> */}\n\n            <TextField\n              size=\"small\"\n              label={i18n(\"inject_css\")}\n              helperText={i18n(\"inject_css_helper\")}\n              name=\"injectCss\"\n              value={injectCss}\n              disabled={disabled}\n              onChange={handleChange}\n              maxRows={10}\n              multiline\n            />\n            <TextField\n              size=\"small\"\n              label={i18n(\"inject_js\")}\n              helperText={i18n(\"inject_js_helper\")}\n              name=\"injectJs\"\n              value={injectJs}\n              disabled={disabled}\n              onChange={handleChange}\n              maxRows={10}\n              multiline\n            />\n          </>\n        )}\n\n        {rules &&\n          (editMode ? (\n            // 编辑\n            <Stack direction=\"row\" spacing={2}>\n              {disabled ? (\n                <>\n                  <Button\n                    size=\"small\"\n                    variant=\"contained\"\n                    onClick={(e) => {\n                      e.preventDefault();\n                      setDisabled(false);\n                    }}\n                    startIcon={<EditIcon />}\n                  >\n                    {i18n(\"edit\")}\n                  </Button>\n                  {rule?.pattern !== \"*\" && (\n                    <Button\n                      size=\"small\"\n                      variant=\"outlined\"\n                      onClick={(e) => {\n                        e.preventDefault();\n                        rules.del(rule.pattern);\n                      }}\n                      startIcon={<DeleteIcon />}\n                    >\n                      {i18n(\"delete\")}\n                    </Button>\n                  )}\n                </>\n              ) : (\n                <>\n                  <Button\n                    size=\"small\"\n                    variant=\"contained\"\n                    type=\"submit\"\n                    startIcon={<SaveIcon />}\n                    disabled={!isModified}\n                  >\n                    {i18n(\"save\")}\n                  </Button>\n                  <Button\n                    size=\"small\"\n                    variant=\"outlined\"\n                    onClick={handleCancel}\n                    startIcon={<CancelIcon />}\n                  >\n                    {i18n(\"cancel\")}\n                  </Button>\n                  <Button\n                    size=\"small\"\n                    variant=\"outlined\"\n                    onClick={handleRestore}\n                  >\n                    {i18n(\"restore_default\")}\n                  </Button>\n                </>\n              )}\n              <ShowMoreButton showMore={showMore} onChange={setShowMore} />\n            </Stack>\n          ) : (\n            // 添加\n            <Stack direction=\"row\" spacing={2}>\n              <Button\n                size=\"small\"\n                variant=\"contained\"\n                type=\"submit\"\n                startIcon={<SaveIcon />}\n              >\n                {i18n(\"save\")}\n              </Button>\n              <Button\n                size=\"small\"\n                variant=\"outlined\"\n                onClick={handleCancel}\n                startIcon={<CancelIcon />}\n              >\n                {i18n(\"cancel\")}\n              </Button>\n              <ShowMoreButton showMore={showMore} onChange={setShowMore} />\n            </Stack>\n          ))}\n      </Stack>\n    </form>\n  );\n}\n\nfunction RuleAccordion({ rule, rules, isExpanded = false }) {\n  const i18n = useI18n();\n  const [expanded, setExpanded] = useState(isExpanded);\n\n  const handleChange = (e) => {\n    setExpanded((pre) => !pre);\n  };\n\n  return (\n    <Accordion expanded={expanded} onChange={handleChange}>\n      <AccordionSummary expandIcon={<ExpandMoreIcon />}>\n        <Typography\n          sx={{\n            opacity: rules ? 1 : 0.5,\n            overflowWrap: \"anywhere\",\n          }}\n        >\n          {rule.pattern === GLOBAL_KEY\n            ? `[${i18n(\"global_rule\")}] ${rule.pattern}`\n            : rule.pattern}\n        </Typography>\n      </AccordionSummary>\n      <AccordionDetails>\n        {expanded && <RuleFields rule={rule} rules={rules} />}\n      </AccordionDetails>\n    </Accordion>\n  );\n}\n\nfunction ShareButton({ rules, injectRules, selectedUrl }) {\n  const alert = useAlert();\n  const i18n = useI18n();\n  const handleClick = async () => {\n    try {\n      const { syncType, syncUrl, syncKey } = await getSyncWithDefault();\n      if (syncType !== OPT_SYNCTYPE_WORKER || !syncUrl || !syncKey) {\n        alert.warning(i18n(\"error_sync_setting\"));\n        return;\n      }\n\n      const shareRules = [...rules.list];\n      if (injectRules) {\n        const subRules = await loadOrFetchSubRules(selectedUrl);\n        shareRules.splice(-1, 0, ...subRules);\n      }\n\n      const url = await syncShareRules({\n        rules: shareRules,\n        syncUrl,\n        syncKey,\n      });\n\n      window.open(url, \"_blank\");\n    } catch (err) {\n      alert.warning(i18n(\"error_got_some_wrong\"));\n      kissLog(\"share rules\", err);\n    }\n  };\n\n  return (\n    <Button\n      size=\"small\"\n      variant=\"outlined\"\n      onClick={handleClick}\n      startIcon={<ShareIcon />}\n    >\n      {i18n(\"share\")}\n    </Button>\n  );\n}\n\nfunction UserRules({ subRules, rules }) {\n  const i18n = useI18n();\n  const [showAdd, setShowAdd] = useState(false);\n  const { setting, updateSetting } = useSetting();\n  const [keyword, setKeyword] = useState(\"\");\n  const confirm = useConfirm();\n\n  const injectRules = !!setting?.injectRules;\n  const { selectedUrl, selectedRules } = subRules;\n\n  const handleImport = async (data) => {\n    try {\n      await rules.merge(JSON.parse(data));\n    } catch (err) {\n      kissLog(\"import rules\", err);\n    }\n  };\n\n  const handleInject = () => {\n    updateSetting({\n      injectRules: !injectRules,\n    });\n  };\n\n  const handleClearAll = async () => {\n    const isConfirmed = await confirm({\n      confirmText: i18n(\"confirm_title\"),\n      cancelText: i18n(\"cancel\"),\n    });\n    if (isConfirmed) {\n      rules.clear();\n    }\n  };\n\n  useEffect(() => {\n    if (!showAdd) {\n      setKeyword(\"\");\n    }\n  }, [showAdd]);\n\n  if (!rules.list) {\n    return;\n  }\n\n  return (\n    <Stack spacing={3}>\n      <Stack\n        direction=\"row\"\n        alignItems=\"center\"\n        spacing={2}\n        useFlexGap\n        flexWrap=\"wrap\"\n      >\n        <Button\n          size=\"small\"\n          variant=\"contained\"\n          disabled={showAdd}\n          onClick={(e) => {\n            e.preventDefault();\n            setShowAdd(true);\n          }}\n          startIcon={<AddIcon />}\n        >\n          {i18n(\"add\")}\n        </Button>\n\n        <UploadButton text={i18n(\"import\")} handleImport={handleImport} />\n        <DownloadButton\n          handleData={() => JSON.stringify([...rules.list], null, 2)}\n          text={i18n(\"export\")}\n          fileName={`kiss-rules_v2_${Date.now()}.json`}\n        />\n\n        <ShareButton\n          rules={rules}\n          injectRules={injectRules}\n          selectedUrl={selectedUrl}\n        />\n\n        <Button\n          size=\"small\"\n          variant=\"outlined\"\n          onClick={handleClearAll}\n          startIcon={<ClearAllIcon />}\n        >\n          {i18n(\"clear_all\")}\n        </Button>\n\n        <HelpButton url={URL_KISS_RULES_NEW_ISSUE} />\n\n        <FormControlLabel\n          control={\n            <Switch\n              size=\"small\"\n              checked={injectRules}\n              onChange={handleInject}\n            />\n          }\n          label={i18n(\"inject_rules\")}\n        />\n      </Stack>\n\n      {showAdd && (\n        <RuleFields\n          rules={rules}\n          setShow={setShowAdd}\n          setKeyword={setKeyword}\n        />\n      )}\n\n      <Box>\n        {rules.list\n          .filter(\n            (rule) =>\n              rule.pattern !== \"*\" &&\n              (rule.pattern.includes(keyword) || keyword.includes(rule.pattern))\n          )\n          .map((rule) => (\n            <RuleAccordion key={rule.pattern} rule={rule} rules={rules} />\n          ))}\n      </Box>\n\n      {injectRules && (\n        <Box>\n          {selectedRules\n            .filter(\n              (rule) =>\n                rule.pattern.includes(keyword) || keyword.includes(rule.pattern)\n            )\n            .map((rule) => (\n              <RuleAccordion key={rule.pattern} rule={rule} />\n            ))}\n        </Box>\n      )}\n    </Stack>\n  );\n}\n\nfunction SubRulesItem({\n  index,\n  url,\n  syncAt,\n  selectedUrl,\n  delSub,\n  setSelectedRules,\n  updateDataCache,\n  deleteDataCache,\n}) {\n  const [loading, setLoading] = useState(false);\n  const alert = useAlert();\n\n  const handleDel = async () => {\n    try {\n      await delSub(url);\n      await delSubRules(url);\n      await deleteDataCache(url);\n    } catch (err) {\n      kissLog(\"del subrules\", err);\n    }\n  };\n\n  const handleSync = async () => {\n    try {\n      setLoading(true);\n      const rules = await syncSubRules(url);\n      if (rules.length > 0 && url === selectedUrl) {\n        setSelectedRules(rules);\n      }\n      await updateDataCache(url);\n    } catch (err) {\n      kissLog(\"sync sub rules\", err);\n      alert.error(\n        <>\n          <p>Sync Error:</p>\n          <pre>{err.message}</pre>\n        </>\n      );\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  return (\n    <Stack direction=\"row\" alignItems=\"center\" spacing={2}>\n      <FormControlLabel\n        value={url}\n        control={<Radio />}\n        sx={{\n          overflowWrap: \"anywhere\",\n        }}\n        label={url}\n      />\n\n      {syncAt && (\n        <span style={{ marginLeft: \"0.5em\", opacity: 0.5 }}>\n          [{new Date(syncAt).toLocaleString()}]\n        </span>\n      )}\n\n      {loading ? (\n        <CircularProgress size={16} />\n      ) : (\n        <IconButton size=\"small\" onClick={handleSync}>\n          <SyncIcon fontSize=\"small\" />\n        </IconButton>\n      )}\n\n      {index !== 0 && selectedUrl !== url && (\n        <IconButton size=\"small\" onClick={handleDel}>\n          <DeleteIcon fontSize=\"small\" />\n        </IconButton>\n      )}\n    </Stack>\n  );\n}\n\nfunction SubRulesEdit({ subList, addSub, updateDataCache }) {\n  const i18n = useI18n();\n  const [inputText, setInputText] = useState(\"\");\n  const [inputError, setInputError] = useState(\"\");\n  const [showInput, setShowInput] = useState(false);\n  const [loading, setLoading] = useState(false);\n\n  const handleCancel = (e) => {\n    e.preventDefault();\n    setShowInput(false);\n    setInputText(\"\");\n    setInputError(\"\");\n  };\n\n  const handleSave = async (e) => {\n    e.preventDefault();\n    const url = inputText.trim();\n\n    if (!url) {\n      setInputError(i18n(\"error_cant_be_blank\"));\n      return;\n    }\n\n    if (subList.some((item) => item.url === url)) {\n      setInputError(i18n(\"error_duplicate_values\"));\n      return;\n    }\n\n    try {\n      setLoading(true);\n      const rules = await syncSubRules(url);\n      if (rules.length === 0) {\n        throw new Error(\"empty rules\");\n      }\n      await addSub(url);\n      await updateDataCache(url);\n      setShowInput(false);\n      setInputText(\"\");\n    } catch (err) {\n      kissLog(\"fetch rules\", err);\n      setInputError(i18n(\"error_fetch_url\"));\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const handleInput = (e) => {\n    e.preventDefault();\n    setInputText(e.target.value);\n  };\n\n  const handleFocus = (e) => {\n    e.preventDefault();\n    setInputError(\"\");\n  };\n\n  return (\n    <>\n      <Stack direction=\"row\" alignItems=\"center\" spacing={2}>\n        <Button\n          size=\"small\"\n          variant=\"contained\"\n          disabled={showInput}\n          onClick={(e) => {\n            e.preventDefault();\n            setShowInput(true);\n          }}\n          startIcon={<AddIcon />}\n        >\n          {i18n(\"add\")}\n        </Button>\n        <HelpButton url={URL_KISS_RULES_NEW_ISSUE} />\n      </Stack>\n\n      {showInput && (\n        <>\n          <TextField\n            size=\"small\"\n            value={inputText}\n            error={!!inputError}\n            helperText={inputError}\n            onChange={handleInput}\n            onFocus={handleFocus}\n            label={i18n(\"subscribe_url\")}\n          />\n\n          <Stack direction=\"row\" alignItems=\"center\" spacing={2}>\n            <Button\n              size=\"small\"\n              variant=\"contained\"\n              onClick={handleSave}\n              disabled={loading}\n              startIcon={<SaveIcon />}\n            >\n              {i18n(\"save\")}\n            </Button>\n            <Button\n              size=\"small\"\n              variant=\"outlined\"\n              onClick={handleCancel}\n              startIcon={<CancelIcon />}\n            >\n              {i18n(\"cancel\")}\n            </Button>\n          </Stack>\n        </>\n      )}\n    </>\n  );\n}\n\nfunction SubRules({ subRules }) {\n  const {\n    subList,\n    selectSub,\n    addSub,\n    delSub,\n    selectedUrl,\n    selectedRules,\n    setSelectedRules,\n    loading,\n  } = subRules;\n  const { dataCaches, updateDataCache, deleteDataCache, reloadSync } =\n    useSyncCaches();\n\n  const handleSelect = (e) => {\n    const url = e.target.value;\n    selectSub(url);\n  };\n\n  useEffect(() => {\n    reloadSync();\n  }, [selectedRules, reloadSync]);\n\n  return (\n    <Stack spacing={3}>\n      <SubRulesEdit\n        subList={subList}\n        addSub={addSub}\n        updateDataCache={updateDataCache}\n      />\n\n      <RadioGroup value={selectedUrl} onChange={handleSelect}>\n        {subList.map((item, index) => (\n          <SubRulesItem\n            key={item.url}\n            url={item.url}\n            syncAt={dataCaches[item.url]}\n            index={index}\n            selectedUrl={selectedUrl}\n            delSub={delSub}\n            setSelectedRules={setSelectedRules}\n            updateDataCache={updateDataCache}\n            deleteDataCache={deleteDataCache}\n          />\n        ))}\n      </RadioGroup>\n\n      <Box>\n        {loading ? (\n          <center>\n            <CircularProgress />\n          </center>\n        ) : (\n          selectedRules.map((rule) => (\n            <RuleAccordion key={rule.pattern} rule={rule} />\n          ))\n        )}\n      </Box>\n    </Stack>\n  );\n}\n\nfunction GlobalRule({ rules }) {\n  const globalRule = useMemo(\n    () => rules.list[rules.list.length - 1],\n    [rules.list]\n  );\n\n  if (!globalRule) {\n    return;\n  }\n\n  return (\n    <Stack spacing={3}>\n      <RuleAccordion\n        key={globalRule.pattern}\n        rule={globalRule}\n        rules={rules}\n        isExpanded={true}\n      />\n    </Stack>\n  );\n}\n\nexport default function Rules() {\n  const i18n = useI18n();\n  const [activeTab, setActiveTab] = useState(0);\n  const subRules = useSubRules();\n  const rules = useRules();\n\n  const handleTabChange = (e, newValue) => {\n    setActiveTab(newValue);\n  };\n\n  return (\n    <Box>\n      <Stack spacing={3}>\n        <Alert severity=\"info\">\n          {i18n(\"rules_warn_1\")}\n          <br />\n          {i18n(\"rules_warn_2\")}\n          <br />\n          {i18n(\"rules_warn_3\")}\n        </Alert>\n\n        <Box sx={{ borderBottom: 1, borderColor: \"divider\" }}>\n          <Tabs value={activeTab} onChange={handleTabChange}>\n            <Tab label={i18n(\"global_rule\")} />\n            <Tab label={i18n(\"personal_rules\")} />\n            <Tab label={i18n(\"subscribe_rules\")} />\n            {/* <Tab label={i18n(\"overwrite_subscribe_rules\")} /> */}\n          </Tabs>\n        </Box>\n        <div hidden={activeTab !== 0}>\n          {activeTab === 0 && <GlobalRule rules={rules} />}\n        </div>\n        <div hidden={activeTab !== 1}>\n          {activeTab === 1 && <UserRules subRules={subRules} rules={rules} />}\n        </div>\n        <div hidden={activeTab !== 2}>\n          {activeTab === 2 && <SubRules subRules={subRules} />}\n        </div>\n        {/* <div hidden={activeTab !== 3}>{activeTab === 3 && <OwSubRule />}</div> */}\n      </Stack>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "src/views/Options/Setting.js",
    "content": "import Box from \"@mui/material/Box\";\nimport Stack from \"@mui/material/Stack\";\nimport TextField from \"@mui/material/TextField\";\nimport MenuItem from \"@mui/material/MenuItem\";\nimport Link from \"@mui/material/Link\";\nimport { useSetting } from \"../../hooks/Setting\";\nimport { useI18n } from \"../../hooks/I18n\";\nimport { useAlert } from \"../../hooks/Alert\";\nimport { isExt } from \"../../libs/client\";\nimport Grid from \"@mui/material/Grid\";\n\nimport {\n  UI_LANGS,\n  TRANS_NEWLINE_LENGTH,\n  CACHE_NAME,\n  OPT_LANGDETECTOR_ALL,\n  OPT_SHORTCUT_TRANSLATE,\n  OPT_SHORTCUT_STYLE,\n  OPT_SHORTCUT_POPUP,\n  OPT_SHORTCUT_SETTING,\n  DEFAULT_BLACKLIST,\n  DEFAULT_CSPLIST,\n  DEFAULT_ORILIST,\n  MSG_CONTEXT_MENUS,\n  MSG_UPDATE_CSP,\n  DEFAULT_HTTP_TIMEOUT,\n  OPT_LANGS_TO,\n} from \"../../config\";\nimport { useShortcut } from \"../../hooks/Shortcut\";\nimport ShortcutInput from \"./ShortcutInput\";\nimport { useFab } from \"../../hooks/Fab\";\nimport { sendBgMsg } from \"../../libs/msg\";\nimport { kissLog, LogLevel } from \"../../libs/log\";\nimport UploadButton from \"./UploadButton\";\nimport DownloadButton from \"./DownloadButton\";\nimport ValidationInput from \"../../hooks/ValidationInput\";\n\nfunction ShortcutItem({ action, label }) {\n  const { shortcut, setShortcut } = useShortcut(action);\n  return (\n    <ShortcutInput value={shortcut} onChange={setShortcut} label={label} />\n  );\n}\n\nexport default function Settings() {\n  const i18n = useI18n();\n  const { setting, updateSetting } = useSetting();\n  const alert = useAlert();\n  const { fab, updateFab } = useFab();\n\n  const handleChange = (e) => {\n    e.preventDefault();\n    let { name, value } = e.target;\n    switch (name) {\n      case \"contextMenuType\":\n        isExt && sendBgMsg(MSG_CONTEXT_MENUS, value);\n        break;\n      case \"csplist\":\n        isExt && sendBgMsg(MSG_UPDATE_CSP, { csplist: value });\n        break;\n      case \"orilist\":\n        isExt && sendBgMsg(MSG_UPDATE_CSP, { orilist: value });\n        break;\n      default:\n    }\n    updateSetting({\n      [name]: value,\n    });\n  };\n\n  const handleClearCache = () => {\n    try {\n      caches.delete(CACHE_NAME);\n      alert.success(i18n(\"clear_success\"));\n    } catch (err) {\n      kissLog(\"clear cache\", err);\n    }\n  };\n\n  const handleImport = async (data) => {\n    try {\n      updateSetting(JSON.parse(data));\n    } catch (err) {\n      kissLog(\"import setting\", err);\n    }\n  };\n\n  const {\n    uiLang,\n    minLength,\n    maxLength,\n    clearCache,\n    newlineLength = TRANS_NEWLINE_LENGTH,\n    httpTimeout = DEFAULT_HTTP_TIMEOUT,\n    contextMenuType = 1,\n    touchModes = [2],\n    blacklist = DEFAULT_BLACKLIST.join(\",\\n\"),\n    csplist = DEFAULT_CSPLIST.join(\",\\n\"),\n    orilist = DEFAULT_ORILIST.join(\",\\n\"),\n    transInterval = 100,\n    langDetector = \"-\",\n    logLevel = 1,\n    preInit = true,\n    skipLangs = [],\n    // detectRemote = true,\n    transAllnow = false,\n    rootMargin = 500,\n  } = setting;\n  const { isHide = false, fabClickAction = 0 } = fab || {};\n\n  return (\n    <Box>\n      <Stack spacing={3}>\n        {/* <Alert severity=\"info\">{i18n(\"setting_helper\")}</Alert> */}\n\n        <Stack\n          direction=\"row\"\n          alignItems=\"center\"\n          spacing={2}\n          useFlexGap\n          flexWrap=\"wrap\"\n        >\n          <UploadButton text={i18n(\"import\")} handleImport={handleImport} />\n          <DownloadButton\n            handleData={() => JSON.stringify(setting, null, 2)}\n            text={i18n(\"export\")}\n            fileName={`kiss-setting_v2_${Date.now()}.json`}\n          />\n        </Stack>\n\n        <Box>\n          <Grid container spacing={2} columns={12}>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                fullWidth\n                size=\"small\"\n                name=\"uiLang\"\n                value={uiLang}\n                label={i18n(\"ui_lang\")}\n                onChange={handleChange}\n              >\n                {UI_LANGS.map(([lang, name]) => (\n                  <MenuItem key={lang} value={lang}>\n                    {name}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                fullWidth\n                size=\"small\"\n                name=\"preInit\"\n                value={preInit}\n                label={i18n(\"if_pre_init\")}\n                onChange={handleChange}\n              >\n                <MenuItem value={true}>{i18n(\"enable\")}</MenuItem>\n                <MenuItem value={false}>{i18n(\"disable\")}</MenuItem>\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                fullWidth\n                size=\"small\"\n                name=\"isHide\"\n                value={isHide}\n                label={i18n(\"hide_fab_button\")}\n                onChange={(e) => {\n                  updateFab({ isHide: e.target.value });\n                }}\n              >\n                <MenuItem value={false}>{i18n(\"show\")}</MenuItem>\n                <MenuItem value={true}>{i18n(\"hide\")}</MenuItem>\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                fullWidth\n                size=\"small\"\n                name=\"fabClickAction\"\n                value={fabClickAction}\n                label={i18n(\"fab_click_action\")}\n                onChange={(e) => updateFab({ fabClickAction: e.target.value })}\n              >\n                <MenuItem value={0}>{i18n(\"fab_click_menu\")}</MenuItem>\n                <MenuItem value={1}>{i18n(\"fab_click_translate\")}</MenuItem>\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <ValidationInput\n                fullWidth\n                size=\"small\"\n                label={i18n(\"min_translate_length\")}\n                type=\"number\"\n                name=\"minLength\"\n                value={minLength}\n                onChange={handleChange}\n                min={1}\n                max={100}\n              />\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <ValidationInput\n                fullWidth\n                size=\"small\"\n                label={i18n(\"max_translate_length\")}\n                type=\"number\"\n                name=\"maxLength\"\n                value={maxLength}\n                onChange={handleChange}\n                min={100}\n                max={100000}\n              />\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <ValidationInput\n                fullWidth\n                size=\"small\"\n                label={i18n(\"num_of_newline_characters\")}\n                type=\"number\"\n                name=\"newlineLength\"\n                value={newlineLength}\n                onChange={handleChange}\n                min={1}\n                max={1000}\n              />\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <ValidationInput\n                fullWidth\n                size=\"small\"\n                label={i18n(\"translate_interval\")}\n                type=\"number\"\n                name=\"transInterval\"\n                value={transInterval}\n                onChange={handleChange}\n                min={1}\n                max={2000}\n              />\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <ValidationInput\n                fullWidth\n                size=\"small\"\n                label={i18n(\"http_timeout\")}\n                type=\"number\"\n                name=\"httpTimeout\"\n                value={httpTimeout}\n                onChange={handleChange}\n                min={1000}\n                max={600000}\n              />\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                fullWidth\n                size=\"small\"\n                name=\"touchModes\"\n                value={touchModes}\n                label={i18n(\"touch_translate_shortcut\")}\n                onChange={handleChange}\n                SelectProps={{\n                  multiple: true,\n                }}\n              >\n                {[0, 2, 3, 4, 5, 6, 7].map((item) => (\n                  <MenuItem key={item} value={item}>\n                    {i18n(`touch_tap_${item}`)}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                fullWidth\n                size=\"small\"\n                name=\"contextMenuType\"\n                value={contextMenuType}\n                label={i18n(\"context_menus\")}\n                onChange={handleChange}\n              >\n                <MenuItem value={0}>{i18n(\"hide_context_menus\")}</MenuItem>\n                <MenuItem value={1}>{i18n(\"simple_context_menus\")}</MenuItem>\n                <MenuItem value={2}>{i18n(\"secondary_context_menus\")}</MenuItem>\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                fullWidth\n                size=\"small\"\n                name=\"langDetector\"\n                value={langDetector}\n                label={i18n(\"detected_lang\")}\n                onChange={handleChange}\n              >\n                <MenuItem value={\"-\"}>{i18n(\"disable\")}</MenuItem>\n                {OPT_LANGDETECTOR_ALL.map((item) => (\n                  <MenuItem value={item} key={item}>\n                    {item}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                size=\"small\"\n                fullWidth\n                name=\"transAllnow\"\n                value={transAllnow}\n                label={i18n(\"trigger_mode\")}\n                onChange={handleChange}\n              >\n                <MenuItem value={false}>{i18n(\"mk_pagescroll\")}</MenuItem>\n                <MenuItem value={true}>{i18n(\"mk_pageopen\")}</MenuItem>\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <ValidationInput\n                fullWidth\n                size=\"small\"\n                label={i18n(\"pagescroll_root_margin\")}\n                type=\"number\"\n                name=\"rootMargin\"\n                value={rootMargin}\n                onChange={handleChange}\n                min={0}\n                max={10000}\n              />\n            </Grid>\n            {/* <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                size=\"small\"\n                fullWidth\n                name=\"detectRemote\"\n                value={detectRemote}\n                label={i18n(\"detect_lang_remote\")}\n                onChange={handleChange}\n              >\n                <MenuItem value={true}>{i18n(\"enable\")}</MenuItem>\n                <MenuItem value={false}>{i18n(\"disable\")}</MenuItem>\n              </TextField>\n            </Grid> */}\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                fullWidth\n                size=\"small\"\n                name=\"logLevel\"\n                value={logLevel}\n                label={i18n(\"log_level\")}\n                onChange={handleChange}\n              >\n                {Object.values(LogLevel).map(({ value, name }) => (\n                  <MenuItem value={value} key={value}>\n                    {name}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n          </Grid>\n        </Box>\n\n        <TextField\n          select\n          size=\"small\"\n          label={i18n(\"skip_langs\")}\n          helperText={i18n(\"skip_langs_helper\")}\n          name=\"skipLangs\"\n          value={skipLangs}\n          onChange={handleChange}\n          SelectProps={{\n            multiple: true,\n          }}\n        >\n          {OPT_LANGS_TO.map(([langKey, langName]) => (\n            <MenuItem key={langKey} value={langKey}>\n              {langName}\n            </MenuItem>\n          ))}\n        </TextField>\n        <TextField\n          size=\"small\"\n          label={i18n(\"translate_blacklist\")}\n          helperText={i18n(\"pattern_helper\")}\n          name=\"blacklist\"\n          value={blacklist}\n          onChange={handleChange}\n          maxRows={10}\n          multiline\n        />\n\n        {isExt ? (\n          <>\n            <TextField\n              select\n              fullWidth\n              size=\"small\"\n              name=\"clearCache\"\n              value={clearCache}\n              label={i18n(\"if_clear_cache\")}\n              onChange={handleChange}\n              helperText={\n                <Link component=\"button\" onClick={handleClearCache}>\n                  {i18n(\"clear_all_cache_now\")}\n                </Link>\n              }\n            >\n              <MenuItem value={false}>{i18n(\"clear_cache_never\")}</MenuItem>\n              <MenuItem value={true}>{i18n(\"clear_cache_restart\")}</MenuItem>\n            </TextField>\n\n            <TextField\n              size=\"small\"\n              label={i18n(\"disabled_orilist\")}\n              helperText={i18n(\"pattern_helper\")}\n              name=\"orilist\"\n              value={orilist}\n              onChange={handleChange}\n              multiline\n            />\n            <TextField\n              size=\"small\"\n              label={i18n(\"disabled_csplist\")}\n              helperText={\n                i18n(\"pattern_helper\") + \" \" + i18n(\"disabled_csplist_helper\")\n              }\n              name=\"csplist\"\n              value={csplist}\n              onChange={handleChange}\n              multiline\n            />\n          </>\n        ) : (\n          <>\n            <Box>\n              <Grid container spacing={2} columns={12}>\n                <Grid item xs={12} sm={12} md={6} lg={3}>\n                  <ShortcutItem\n                    action={OPT_SHORTCUT_TRANSLATE}\n                    label={i18n(\"toggle_translate_shortcut\")}\n                  />\n                </Grid>\n                <Grid item xs={12} sm={12} md={6} lg={3}>\n                  <ShortcutItem\n                    action={OPT_SHORTCUT_STYLE}\n                    label={i18n(\"toggle_style_shortcut\")}\n                  />\n                </Grid>\n                <Grid item xs={12} sm={12} md={6} lg={3}>\n                  <ShortcutItem\n                    action={OPT_SHORTCUT_POPUP}\n                    label={i18n(\"toggle_popup_shortcut\")}\n                  />\n                </Grid>\n                <Grid item xs={12} sm={12} md={6} lg={3}>\n                  <ShortcutItem\n                    action={OPT_SHORTCUT_SETTING}\n                    label={i18n(\"open_setting_shortcut\")}\n                  />\n                </Grid>\n              </Grid>\n            </Box>\n          </>\n        )}\n      </Stack>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "src/views/Options/ShortcutInput.js",
    "content": "import Stack from \"@mui/material/Stack\";\nimport TextField from \"@mui/material/TextField\";\nimport IconButton from \"@mui/material/IconButton\";\nimport EditIcon from \"@mui/icons-material/Edit\";\nimport CheckIcon from \"@mui/icons-material/Check\";\nimport { useEffect, useState, useRef } from \"react\";\nimport { shortcutListener } from \"../../libs/shortcut\";\nimport { useI18n } from \"../../hooks/I18n\";\n\nexport default function ShortcutInput({\n  value: keys,\n  onChange,\n  label,\n  helperText,\n}) {\n  const [isEditing, setIsEditing] = useState(false);\n  const [editingKeys, setEditingKeys] = useState([]);\n  const inputRef = useRef(null);\n  const i18n = useI18n();\n\n  const commitChanges = () => {\n    // if (editingKeys.length > 0) {\n    //   onChange(editingKeys);\n    // }\n    onChange(editingKeys);\n    setIsEditing(false);\n  };\n\n  const handleBlur = () => {\n    commitChanges();\n  };\n\n  const handleEditClick = () => {\n    setEditingKeys([]);\n    setIsEditing(true);\n  };\n\n  useEffect(() => {\n    if (!isEditing) {\n      return;\n    }\n    const inputElement = inputRef.current;\n    if (inputElement) {\n      inputElement.focus();\n    }\n    const clearShortcut = shortcutListener((pressedKeys, event) => {\n      event.preventDefault();\n      event.stopPropagation();\n      setEditingKeys([...pressedKeys]);\n    });\n\n    return () => {\n      clearShortcut();\n    };\n  }, [isEditing]);\n\n  const displayValue = isEditing ? editingKeys : keys;\n  const formattedValue = displayValue\n    .map((item) => (item === \" \" ? \"Space\" : item))\n    .join(\" + \");\n\n  return (\n    <Stack direction=\"row\" alignItems=\"flex-start\">\n      <TextField\n        size=\"small\"\n        label={label}\n        name={label}\n        value={formattedValue}\n        fullWidth\n        inputRef={inputRef}\n        disabled={!isEditing}\n        onBlur={handleBlur}\n        helperText={isEditing ? i18n(\"pls_press_shortcut\") : helperText}\n      />\n      {isEditing ? (\n        <IconButton onClick={commitChanges} color=\"primary\">\n          <CheckIcon />\n        </IconButton>\n      ) : (\n        <IconButton onClick={handleEditClick}>\n          <EditIcon />\n        </IconButton>\n      )}\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "src/views/Options/ShowMoreButton.js",
    "content": "import ExpandMoreIcon from \"@mui/icons-material/ExpandMore\";\nimport ExpandLessIcon from \"@mui/icons-material/ExpandLess\";\nimport Button from \"@mui/material/Button\";\nimport { useI18n } from \"../../hooks/I18n\";\n\nexport default function ShowMoreButton({ onChange, showMore }) {\n  const i18n = useI18n();\n  const handleClick = () => {\n    onChange((prev) => !prev);\n  };\n\n  if (showMore) {\n    return (\n      <Button\n        size=\"small\"\n        variant=\"text\"\n        onClick={handleClick}\n        startIcon={<ExpandLessIcon />}\n      >\n        {i18n(\"less\")}\n      </Button>\n    );\n  }\n\n  return (\n    <Button\n      size=\"small\"\n      variant=\"text\"\n      onClick={handleClick}\n      startIcon={<ExpandMoreIcon />}\n    >\n      {i18n(\"more\")}\n    </Button>\n  );\n}\n"
  },
  {
    "path": "src/views/Options/StylesSetting.js",
    "content": "import { useState, useEffect, useMemo } from \"react\";\nimport Stack from \"@mui/material/Stack\";\nimport TextField from \"@mui/material/TextField\";\nimport Button from \"@mui/material/Button\";\nimport { useI18n } from \"../../hooks/I18n\";\nimport Typography from \"@mui/material/Typography\";\nimport Accordion from \"@mui/material/Accordion\";\nimport AccordionSummary from \"@mui/material/AccordionSummary\";\nimport AccordionDetails from \"@mui/material/AccordionDetails\";\nimport ExpandMoreIcon from \"@mui/icons-material/ExpandMore\";\nimport AddIcon from \"@mui/icons-material/Add\";\nimport { useConfirm } from \"../../hooks/Confirm\";\nimport Box from \"@mui/material/Box\";\nimport { useAllTextStyles, useStyleList } from \"../../hooks/CustomStyles\";\nimport { css } from \"@emotion/css\";\nimport { getRandomQuote } from \"../../config/quotes\";\nimport { useSetting } from \"../../hooks/Setting\";\n\nfunction StyleFields({ customStyle, deleteStyle, updateStyle, isBuiltin }) {\n  const i18n = useI18n();\n  const {\n    setting: { uiLang },\n  } = useSetting();\n  const [formData, setFormData] = useState({});\n  const [isModified, setIsModified] = useState(false);\n  const confirm = useConfirm();\n\n  useEffect(() => {\n    if (customStyle) {\n      setFormData(customStyle);\n    }\n  }, [customStyle]);\n\n  useEffect(() => {\n    if (!customStyle) return;\n    const hasChanged = JSON.stringify(customStyle) !== JSON.stringify(formData);\n    setIsModified(hasChanged);\n  }, [customStyle, formData]);\n\n  const handleChange = (e) => {\n    e.preventDefault();\n    let { name, value } = e.target;\n\n    setFormData((prevData) => ({\n      ...prevData,\n      [name]: value,\n    }));\n  };\n\n  const handleSave = () => {\n    updateStyle(customStyle.styleSlug, formData);\n  };\n\n  const handleDelete = async () => {\n    const isConfirmed = await confirm({\n      confirmText: i18n(\"delete\"),\n      cancelText: i18n(\"cancel\"),\n    });\n\n    if (isConfirmed) {\n      deleteStyle(customStyle.styleSlug);\n    }\n  };\n\n  const { styleName = \"\", styleCode = \"\" } = formData;\n\n  const textClass = useMemo(\n    () => css`\n      ${styleCode}\n    `,\n    [styleCode]\n  );\n\n  const quote = useMemo(() => {\n    const q = getRandomQuote();\n    if (uiLang === \"en\") {\n      return [q.zh, q.en];\n    }\n    return [q.en, q[uiLang]];\n  }, [uiLang]);\n\n  return (\n    <Stack spacing={3}>\n      <Box>\n        {quote[0]}\n        <br />\n        <span className={textClass}>{quote[1]}</span>\n      </Box>\n\n      <TextField\n        size=\"small\"\n        label={i18n(\"style_name\")}\n        name=\"styleName\"\n        value={styleName}\n        onChange={handleChange}\n        disabled={isBuiltin}\n      />\n      <TextField\n        size=\"small\"\n        label={i18n(\"style_code\")}\n        name=\"styleCode\"\n        value={styleCode}\n        onChange={handleChange}\n        multiline\n        maxRows={10}\n        disabled={isBuiltin}\n      />\n\n      {!isBuiltin && (\n        <Stack\n          direction=\"row\"\n          alignItems=\"center\"\n          spacing={2}\n          useFlexGap\n          flexWrap=\"wrap\"\n        >\n          <Button\n            size=\"small\"\n            variant=\"contained\"\n            onClick={handleSave}\n            disabled={!isModified}\n          >\n            {i18n(\"save\")}\n          </Button>\n          <Button\n            size=\"small\"\n            variant=\"outlined\"\n            color=\"error\"\n            onClick={handleDelete}\n          >\n            {i18n(\"delete\")}\n          </Button>\n        </Stack>\n      )}\n    </Stack>\n  );\n}\n\nfunction StyleAccordion({ customStyle, deleteStyle, updateStyle, isBuiltin }) {\n  const [expanded, setExpanded] = useState(false);\n\n  const handleChange = (e) => {\n    setExpanded((pre) => !pre);\n  };\n\n  return (\n    <Accordion expanded={expanded} onChange={handleChange}>\n      <AccordionSummary expandIcon={<ExpandMoreIcon />}>\n        <Typography\n          sx={{\n            overflowWrap: \"anywhere\",\n          }}\n        >\n          {`${customStyle.styleName}`}\n        </Typography>\n      </AccordionSummary>\n      <AccordionDetails>\n        {expanded && (\n          <StyleFields\n            customStyle={customStyle}\n            deleteStyle={deleteStyle}\n            updateStyle={updateStyle}\n            isBuiltin={isBuiltin}\n          />\n        )}\n      </AccordionDetails>\n    </Accordion>\n  );\n}\n\nexport default function StylesSetting() {\n  const i18n = useI18n();\n  const { customStyles, addStyle, deleteStyle, updateStyle } = useStyleList();\n  const { builtinStyles } = useAllTextStyles();\n\n  const handleClick = (e) => {\n    e.preventDefault();\n    addStyle();\n  };\n\n  return (\n    <Box>\n      <Stack spacing={3}>\n        <Box>\n          <Button\n            size=\"small\"\n            id=\"add-style-button\"\n            variant=\"contained\"\n            onClick={handleClick}\n            startIcon={<AddIcon />}\n          >\n            {i18n(\"add\")}\n          </Button>\n        </Box>\n\n        <Box>\n          {customStyles.map((customStyle) => (\n            <StyleAccordion\n              key={customStyle.styleSlug}\n              customStyle={customStyle}\n              deleteStyle={deleteStyle}\n              updateStyle={updateStyle}\n            />\n          ))}\n        </Box>\n        <Box>\n          {builtinStyles.map((customStyle) => (\n            <StyleAccordion\n              key={customStyle.styleSlug}\n              customStyle={customStyle}\n              deleteStyle={deleteStyle}\n              updateStyle={updateStyle}\n              isBuiltin={true}\n            />\n          ))}\n        </Box>\n      </Stack>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "src/views/Options/Subtitle.js",
    "content": "import Box from \"@mui/material/Box\";\nimport Stack from \"@mui/material/Stack\";\nimport TextField from \"@mui/material/TextField\";\nimport MenuItem from \"@mui/material/MenuItem\";\nimport Grid from \"@mui/material/Grid\";\nimport Typography from \"@mui/material/Typography\";\nimport Slider from \"@mui/material/Slider\";\nimport Accordion from \"@mui/material/Accordion\";\nimport AccordionSummary from \"@mui/material/AccordionSummary\";\nimport AccordionDetails from \"@mui/material/AccordionDetails\";\nimport ExpandMoreIcon from \"@mui/icons-material/ExpandMore\";\nimport { useI18n } from \"../../hooks/I18n\";\nimport {\n  OPT_LANGS_TO,\n  OPT_ENHANCE_ON,\n  OPT_ENHANCE_OFF,\n  OPT_ENHANCE_MOBILE_OFF,\n} from \"../../config\";\nimport { debounce } from \"../../libs/utils\";\nimport FormControlLabel from \"@mui/material/FormControlLabel\";\nimport Alert from \"@mui/material/Alert\";\nimport Switch from \"@mui/material/Switch\";\nimport { useSubtitle } from \"../../hooks/Subtitle\";\nimport { useApiList } from \"../../hooks/Api\";\nimport ValidationInput from \"../../hooks/ValidationInput\";\nimport { useState, useEffect, useCallback, useMemo } from \"react\";\n\n// CSS 解析工具函数\nconst parseCssToObject = (cssString) => {\n  const result = {};\n  if (!cssString) return result;\n\n  const properties = cssString.split(\";\").filter((p) => p.trim());\n  properties.forEach((prop) => {\n    const colonIndex = prop.indexOf(\":\");\n    if (colonIndex > 0) {\n      const key = prop.substring(0, colonIndex).trim();\n      const value = prop.substring(colonIndex + 1).trim();\n      result[key] = value;\n    }\n  });\n  return result;\n};\n\nconst objectToCss = (obj) => {\n  const entries = Object.entries(obj).filter(\n    ([, value]) => value !== undefined && value !== \"\"\n  );\n  if (entries.length === 0) {\n    return \"\";\n  }\n  return entries.map(([key, value]) => `${key}: ${value}`).join(\";\\n\") + \";\";\n};\n\n// 解析 rgba 颜色\nconst parseRgba = (rgbaString) => {\n  const match = rgbaString?.match(\n    /rgba?\\s*\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*(?:,\\s*([\\d.]+))?\\s*\\)/\n  );\n  if (match) {\n    return {\n      r: parseInt(match[1], 10),\n      g: parseInt(match[2], 10),\n      b: parseInt(match[3], 10),\n      a: match[4] !== undefined ? parseFloat(match[4]) : 1,\n    };\n  }\n  return null;\n};\n\n// RGB 转 Hex\nconst rgbToHex = (r, g, b) => {\n  return (\n    \"#\" +\n    [r, g, b]\n      .map((x) => {\n        let v = Number(x);\n        if (Number.isNaN(v)) v = 0;\n        v = Math.min(255, Math.max(0, Math.round(v)));\n        return v.toString(16).padStart(2, \"0\");\n      })\n      .join(\"\")\n  );\n};\n\n// Hex 转 RGB\nconst hexToRgb = (hex) => {\n  const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);\n  return result\n    ? {\n        r: parseInt(result[1], 16),\n        g: parseInt(result[2], 16),\n        b: parseInt(result[3], 16),\n      }\n    : { r: 0, g: 0, b: 0 };\n};\n\n// 解析 font-size，支持 clamp 格式\nconst parseFontSize = (fontSizeStr) => {\n  if (!fontSizeStr) return { min: 1, preferred: 2, max: 3, unit: \"rem\" };\n\n  // 匹配 clamp(1rem, 2cqw, 3rem) 格式\n  const clampMatch = fontSizeStr.match(\n    /clamp\\s*\\(\\s*([\\d.]+)(\\w+)\\s*,\\s*([\\d.]+)(\\w+)\\s*,\\s*([\\d.]+)(\\w+)\\s*\\)/\n  );\n  if (clampMatch) {\n    return {\n      min: parseFloat(clampMatch[1]),\n      preferred: parseFloat(clampMatch[3]),\n      max: parseFloat(clampMatch[5]),\n      unit: clampMatch[2],\n    };\n  }\n\n  // 匹配普通格式如 16px, 1.5rem\n  const simpleMatch = fontSizeStr.match(/([\\d.]+)(\\w+)/);\n  if (simpleMatch) {\n    const value = parseFloat(simpleMatch[1]);\n    return {\n      min: value * 0.5,\n      preferred: value,\n      max: value * 1.5,\n      unit: simpleMatch[2],\n    };\n  }\n\n  return { min: 1, preferred: 2, max: 3, unit: \"rem\" };\n};\n\n// 解析 padding\nconst parsePadding = (paddingStr) => {\n  if (!paddingStr) return { vertical: 0.5, horizontal: 1, unit: \"em\" };\n\n  const parts = paddingStr.trim().split(/\\s+/);\n  if (parts.length === 1) {\n    const match = parts[0].match(/([\\d.]+)(\\w+)/);\n    if (match) {\n      return {\n        vertical: parseFloat(match[1]),\n        horizontal: parseFloat(match[1]),\n        unit: match[2],\n      };\n    }\n  } else if (parts.length >= 2) {\n    const vMatch = parts[0].match(/([\\d.]+)(\\w+)/);\n    const hMatch = parts[1].match(/([\\d.]+)(\\w+)/);\n    if (vMatch && hMatch) {\n      return {\n        vertical: parseFloat(vMatch[1]),\n        horizontal: parseFloat(hMatch[1]),\n        unit: vMatch[2],\n      };\n    }\n  }\n  return { vertical: 0.5, horizontal: 1, unit: \"em\" };\n};\n\n// 可视化样式编辑器组件\nfunction StyleVisualEditor({ label, cssValue, onChange, type }) {\n  const i18n = useI18n();\n  const [cssObj, setCssObj] = useState({});\n\n  useEffect(() => {\n    setCssObj(parseCssToObject(cssValue));\n  }, [cssValue]);\n\n  const debouncedOnChange = useMemo(() => debounce(onChange, 200), [onChange]);\n\n  const updateCss = useCallback(\n    (key, value) => {\n      setCssObj((prevCssObj) => {\n        const newObj = { ...prevCssObj, [key]: value };\n        debouncedOnChange(objectToCss(newObj));\n        return newObj;\n      });\n    },\n    [debouncedOnChange]\n  );\n\n  // 原文/译文样式 - 主要是 font-size\n  if (type === \"text\") {\n    const fontSizeStr = cssObj[\"font-size\"] || \"\";\n    const fontSize = parseFontSize(fontSizeStr);\n\n    return (\n      <Box sx={{ mb: 2 }}>\n        <Typography variant=\"subtitle2\" gutterBottom>\n          {label} - {i18n(\"visual_editor\") || \"可视化编辑\"}\n        </Typography>\n        <Grid container spacing={2} alignItems=\"center\">\n          <Grid item xs={12} sm={4}>\n            <Typography variant=\"body2\" color=\"text.secondary\">\n              {i18n(\"font_size\") || \"字体大小\"}\n            </Typography>\n          </Grid>\n          <Grid item xs={12} sm={8}>\n            <Box sx={{ display: \"flex\", alignItems: \"center\", gap: 2 }}>\n              <Slider\n                size=\"small\"\n                value={fontSize.preferred}\n                min={0.5}\n                max={5}\n                step={0.1}\n                onChange={(e, val) => {\n                  updateCss(\n                    \"font-size\",\n                    `clamp(${fontSize.min}${fontSize.unit}, ${val}cqw, ${fontSize.max}${fontSize.unit})`\n                  );\n                }}\n                sx={{ flex: 1 }}\n              />\n              <TextField\n                size=\"small\"\n                type=\"number\"\n                value={fontSize.preferred}\n                onChange={(e) => {\n                  const val = parseFloat(e.target.value) || 2;\n                  updateCss(\n                    \"font-size\",\n                    `clamp(${fontSize.min}${fontSize.unit}, ${val}cqw, ${fontSize.max}${fontSize.unit})`\n                  );\n                }}\n                inputProps={{ min: 0.5, max: 5, step: 0.1 }}\n                sx={{ width: 80 }}\n              />\n            </Box>\n          </Grid>\n          {/* 字体颜色 */}\n          <Grid item xs={12} sm={4}>\n            <Typography variant=\"body2\" color=\"text.secondary\">\n              {i18n(\"font_color\") || \"字体颜色\"}\n            </Typography>\n          </Grid>\n          <Grid item xs={12} sm={8}>\n            <Box sx={{ display: \"flex\", alignItems: \"center\", gap: 1 }}>\n              <input\n                type=\"color\"\n                value={\n                  cssObj[\"color\"] === \"white\"\n                    ? \"#ffffff\"\n                    : cssObj[\"color\"] || \"#ffffff\"\n                }\n                onChange={(e) => updateCss(\"color\", e.target.value)}\n                style={{\n                  width: 40,\n                  height: 30,\n                  border: \"none\",\n                  cursor: \"pointer\",\n                }}\n              />\n              <TextField\n                size=\"small\"\n                value={cssObj[\"color\"] || \"\"}\n                onChange={(e) => updateCss(\"color\", e.target.value)}\n                placeholder=\"white / #ffffff\"\n                sx={{ flex: 1 }}\n              />\n            </Box>\n          </Grid>\n        </Grid>\n      </Box>\n    );\n  }\n\n  // 背景样式 - padding, background-color, color, line-height, text-shadow\n  if (type === \"window\") {\n    const paddingStr = cssObj[\"padding\"] || \"0.5em 1em\";\n    const padding = parsePadding(paddingStr);\n\n    const bgColorStr = cssObj[\"background-color\"] || \"rgba(0, 0, 0, 0.5)\";\n    const bgRgba = parseRgba(bgColorStr) || { r: 0, g: 0, b: 0, a: 0.5 };\n    const bgHex = rgbToHex(bgRgba.r, bgRgba.g, bgRgba.b);\n\n    const lineHeight = parseFloat(cssObj[\"line-height\"]) || 1.3;\n    const hasTextShadow = !!cssObj[\"text-shadow\"];\n\n    return (\n      <Box sx={{ mb: 2 }}>\n        <Typography variant=\"subtitle2\" gutterBottom>\n          {label} - {i18n(\"visual_editor\") || \"可视化编辑\"}\n        </Typography>\n        <Grid container spacing={2} alignItems=\"center\">\n          {/* 背景颜色 */}\n          <Grid item xs={12} sm={4}>\n            <Typography variant=\"body2\" color=\"text.secondary\">\n              {i18n(\"background_color\") || \"背景颜色\"}\n            </Typography>\n          </Grid>\n          <Grid item xs={12} sm={8}>\n            <Box sx={{ display: \"flex\", alignItems: \"center\", gap: 1 }}>\n              <input\n                type=\"color\"\n                value={bgHex}\n                onChange={(e) => {\n                  const rgb = hexToRgb(e.target.value);\n                  updateCss(\n                    \"background-color\",\n                    `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${bgRgba.a})`\n                  );\n                }}\n                style={{\n                  width: 40,\n                  height: 30,\n                  border: \"none\",\n                  cursor: \"pointer\",\n                }}\n              />\n              <Typography variant=\"body2\" sx={{ minWidth: 60 }}>\n                {i18n(\"opacity\") || \"透明度\"}\n              </Typography>\n              <Slider\n                size=\"small\"\n                value={bgRgba.a}\n                min={0}\n                max={1}\n                step={0.05}\n                onChange={(e, val) => {\n                  updateCss(\n                    \"background-color\",\n                    `rgba(${bgRgba.r}, ${bgRgba.g}, ${bgRgba.b}, ${val})`\n                  );\n                }}\n                sx={{ flex: 1 }}\n              />\n              <Typography variant=\"body2\" sx={{ minWidth: 40 }}>\n                {Math.round(bgRgba.a * 100)}%\n              </Typography>\n            </Box>\n          </Grid>\n\n          {/* 内边距 */}\n          <Grid item xs={12} sm={4}>\n            <Typography variant=\"body2\" color=\"text.secondary\">\n              {i18n(\"padding\") || \"内边距\"}\n            </Typography>\n          </Grid>\n          <Grid item xs={12} sm={8}>\n            <Box sx={{ display: \"flex\", alignItems: \"center\", gap: 2 }}>\n              <Typography variant=\"body2\">\n                {i18n(\"vertical\") || \"上下\"}\n              </Typography>\n              <Slider\n                size=\"small\"\n                value={padding.vertical}\n                min={0}\n                max={2}\n                step={0.1}\n                onChange={(e, val) => {\n                  updateCss(\n                    \"padding\",\n                    `${val}${padding.unit} ${padding.horizontal}${padding.unit}`\n                  );\n                }}\n                sx={{ width: 100 }}\n              />\n              <Typography variant=\"body2\">\n                {i18n(\"horizontal\") || \"左右\"}\n              </Typography>\n              <Slider\n                size=\"small\"\n                value={padding.horizontal}\n                min={0}\n                max={3}\n                step={0.1}\n                onChange={(e, val) => {\n                  updateCss(\n                    \"padding\",\n                    `${padding.vertical}${padding.unit} ${val}${padding.unit}`\n                  );\n                }}\n                sx={{ width: 100 }}\n              />\n            </Box>\n          </Grid>\n\n          {/* 行高 */}\n          <Grid item xs={12} sm={4}>\n            <Typography variant=\"body2\" color=\"text.secondary\">\n              {i18n(\"line_height\") || \"行高\"}\n            </Typography>\n          </Grid>\n          <Grid item xs={12} sm={8}>\n            <Box sx={{ display: \"flex\", alignItems: \"center\", gap: 2 }}>\n              <Slider\n                size=\"small\"\n                value={lineHeight}\n                min={1}\n                max={2.5}\n                step={0.1}\n                onChange={(e, val) => updateCss(\"line-height\", String(val))}\n                sx={{ flex: 1 }}\n              />\n              <Typography variant=\"body2\" sx={{ minWidth: 30 }}>\n                {lineHeight}\n              </Typography>\n            </Box>\n          </Grid>\n\n          {/* 文字阴影 */}\n          <Grid item xs={12} sm={4}>\n            <Typography variant=\"body2\" color=\"text.secondary\">\n              {i18n(\"text_shadow\") || \"文字阴影\"}\n            </Typography>\n          </Grid>\n          <Grid item xs={12} sm={8}>\n            <FormControlLabel\n              control={\n                <Switch\n                  size=\"small\"\n                  checked={hasTextShadow}\n                  onChange={(e) => {\n                    if (e.target.checked) {\n                      updateCss(\"text-shadow\", \"1px 1px 2px black\");\n                    } else {\n                      const newObj = { ...cssObj };\n                      delete newObj[\"text-shadow\"];\n                      setCssObj(newObj);\n                      onChange(objectToCss(newObj));\n                    }\n                  }}\n                />\n              }\n              label={\n                hasTextShadow\n                  ? i18n(\"enabled\") || \"已启用\"\n                  : i18n(\"disabled\") || \"已禁用\"\n              }\n            />\n          </Grid>\n        </Grid>\n      </Box>\n    );\n  }\n\n  return null;\n}\n\nexport default function SubtitleSetting() {\n  const i18n = useI18n();\n  const { subtitleSetting, updateSubtitle } = useSubtitle();\n  const { enabledApis, aiEnabledApis } = useApiList();\n\n  const handleChange = (e) => {\n    e.preventDefault();\n    let { name, value } = e.target;\n    updateSubtitle({\n      [name]: value,\n    });\n  };\n\n  const handleStyleChange = (name) => (value) => {\n    updateSubtitle({ [name]: value });\n  };\n\n  const {\n    enabled,\n    apiSlug,\n    segSlug,\n    chunkLength,\n    preTrans = 90,\n    throttleTrans = 30,\n    toLang,\n    isBilingual,\n    enhanceMode = OPT_ENHANCE_MOBILE_OFF,\n    showList = true,\n    skipAd = false,\n    windowStyle,\n    originStyle,\n    translationStyle,\n  } = subtitleSetting;\n\n  return (\n    <Box>\n      <Stack spacing={3}>\n        <Alert severity=\"info\">\n          {i18n(\"subtitle_helper_1\")}\n          <br />\n          {i18n(\"subtitle_helper_2\")}\n          <br />\n          {i18n(\"subtitle_helper_3\")}\n        </Alert>\n\n        <FormControlLabel\n          control={\n            <Switch\n              size=\"small\"\n              name=\"enabled\"\n              checked={enabled}\n              onChange={() => {\n                updateSubtitle({ enabled: !enabled });\n              }}\n            />\n          }\n          label={i18n(\"toggle_subtitle_translate\")}\n          sx={{ width: \"fit-content\" }}\n        />\n\n        <Box>\n          <Grid container spacing={2} columns={12}>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                fullWidth\n                size=\"small\"\n                name=\"apiSlug\"\n                value={apiSlug}\n                label={i18n(\"translate_service\")}\n                onChange={handleChange}\n              >\n                {enabledApis.map((api) => (\n                  <MenuItem key={api.apiSlug} value={api.apiSlug}>\n                    {api.apiName}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                fullWidth\n                size=\"small\"\n                name=\"segSlug\"\n                value={segSlug}\n                label={i18n(\"ai_segmentation\")}\n                onChange={handleChange}\n              >\n                <MenuItem value={\"-\"}>{i18n(\"disable\")}</MenuItem>\n                {aiEnabledApis.map((api) => (\n                  <MenuItem key={api.apiSlug} value={api.apiSlug}>\n                    {api.apiName}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <ValidationInput\n                fullWidth\n                size=\"small\"\n                label={i18n(\"ai_chunk_length\")}\n                type=\"number\"\n                name=\"chunkLength\"\n                value={chunkLength}\n                onChange={handleChange}\n                min={200}\n                max={20000}\n              />\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <ValidationInput\n                fullWidth\n                size=\"small\"\n                label={i18n(\"pre_trans_seconds\")}\n                type=\"number\"\n                name=\"preTrans\"\n                value={preTrans}\n                onChange={handleChange}\n                min={10}\n                max={36000}\n              />\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <ValidationInput\n                fullWidth\n                size=\"small\"\n                label={i18n(\"throttle_trans_interval\")}\n                type=\"number\"\n                name=\"throttleTrans\"\n                value={throttleTrans}\n                onChange={handleChange}\n                min={1}\n                max={3600}\n              />\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                fullWidth\n                select\n                size=\"small\"\n                name=\"toLang\"\n                value={toLang}\n                label={i18n(\"to_lang\")}\n                onChange={handleChange}\n              >\n                {OPT_LANGS_TO.map(([lang, name]) => (\n                  <MenuItem key={lang} value={lang}>\n                    {name}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                fullWidth\n                select\n                size=\"small\"\n                name=\"isBilingual\"\n                value={isBilingual}\n                label={i18n(\"is_bilingual_view\")}\n                onChange={handleChange}\n              >\n                <MenuItem value={true}>{i18n(\"enable\")}</MenuItem>\n                <MenuItem value={false}>{i18n(\"disable\")}</MenuItem>\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                fullWidth\n                select\n                size=\"small\"\n                name=\"skipAd\"\n                value={skipAd}\n                label={i18n(\"is_skip_ad\")}\n                onChange={handleChange}\n              >\n                <MenuItem value={true}>{i18n(\"enable\")}</MenuItem>\n                <MenuItem value={false}>{i18n(\"disable\")}</MenuItem>\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                fullWidth\n                select\n                size=\"small\"\n                name=\"enhanceMode\"\n                value={enhanceMode}\n                label={i18n(\"is_enable_enhance\")}\n                onChange={handleChange}\n              >\n                <MenuItem value={OPT_ENHANCE_ON}>{i18n(\"enable\")}</MenuItem>\n                <MenuItem value={OPT_ENHANCE_OFF}>{i18n(\"disable\")}</MenuItem>\n                <MenuItem value={OPT_ENHANCE_MOBILE_OFF}>\n                  {i18n(\"disable_on_mobile\")}\n                </MenuItem>\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                fullWidth\n                select\n                size=\"small\"\n                name=\"showList\"\n                value={showList}\n                label={i18n(\"show_subtitle_list\") || \"显示字幕列表\"}\n                onChange={handleChange}\n              >\n                <MenuItem value={true}>{i18n(\"enable\")}</MenuItem>\n                <MenuItem value={false}>{i18n(\"disable\")}</MenuItem>\n              </TextField>\n            </Grid>\n          </Grid>\n        </Box>\n\n        {/* 原文样式 - 可视化编辑器 */}\n        <Box\n          sx={{\n            border: \"1px solid\",\n            borderColor: \"divider\",\n            borderRadius: 1,\n            p: 2,\n          }}\n        >\n          <StyleVisualEditor\n            label={i18n(\"origin_styles\")}\n            cssValue={originStyle}\n            onChange={handleStyleChange(\"originStyle\")}\n            type=\"text\"\n          />\n          <Accordion\n            sx={{ boxShadow: \"none\", \"&:before\": { display: \"none\" } }}\n          >\n            <AccordionSummary expandIcon={<ExpandMoreIcon />}>\n              <Typography variant=\"body2\" color=\"text.secondary\">\n                {i18n(\"advanced_css\") || \"高级 CSS 编辑\"}\n              </Typography>\n            </AccordionSummary>\n            <AccordionDetails>\n              <TextField\n                size=\"small\"\n                name=\"originStyle\"\n                value={originStyle}\n                onChange={handleChange}\n                maxRows={10}\n                multiline\n                fullWidth\n              />\n            </AccordionDetails>\n          </Accordion>\n        </Box>\n\n        {/* 译文样式 - 可视化编辑器 */}\n        <Box\n          sx={{\n            border: \"1px solid\",\n            borderColor: \"divider\",\n            borderRadius: 1,\n            p: 2,\n          }}\n        >\n          <StyleVisualEditor\n            label={i18n(\"translation_styles\")}\n            cssValue={translationStyle}\n            onChange={handleStyleChange(\"translationStyle\")}\n            type=\"text\"\n          />\n          <Accordion\n            sx={{ boxShadow: \"none\", \"&:before\": { display: \"none\" } }}\n          >\n            <AccordionSummary expandIcon={<ExpandMoreIcon />}>\n              <Typography variant=\"body2\" color=\"text.secondary\">\n                {i18n(\"advanced_css\") || \"高级 CSS 编辑\"}\n              </Typography>\n            </AccordionSummary>\n            <AccordionDetails>\n              <TextField\n                size=\"small\"\n                name=\"translationStyle\"\n                value={translationStyle}\n                onChange={handleChange}\n                maxRows={10}\n                multiline\n                fullWidth\n              />\n            </AccordionDetails>\n          </Accordion>\n        </Box>\n\n        {/* 背景样式 - 可视化编辑器 */}\n        <Box\n          sx={{\n            border: \"1px solid\",\n            borderColor: \"divider\",\n            borderRadius: 1,\n            p: 2,\n          }}\n        >\n          <StyleVisualEditor\n            label={i18n(\"background_styles\")}\n            cssValue={windowStyle}\n            onChange={handleStyleChange(\"windowStyle\")}\n            type=\"window\"\n          />\n          <Accordion\n            sx={{ boxShadow: \"none\", \"&:before\": { display: \"none\" } }}\n          >\n            <AccordionSummary expandIcon={<ExpandMoreIcon />}>\n              <Typography variant=\"body2\" color=\"text.secondary\">\n                {i18n(\"advanced_css\") || \"高级 CSS 编辑\"}\n              </Typography>\n            </AccordionSummary>\n            <AccordionDetails>\n              <TextField\n                size=\"small\"\n                name=\"windowStyle\"\n                value={windowStyle}\n                onChange={handleChange}\n                maxRows={10}\n                multiline\n                fullWidth\n              />\n            </AccordionDetails>\n          </Accordion>\n        </Box>\n      </Stack>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "src/views/Options/SyncSetting.js",
    "content": "import Box from \"@mui/material/Box\";\nimport Stack from \"@mui/material/Stack\";\nimport TextField from \"@mui/material/TextField\";\nimport { useI18n } from \"../../hooks/I18n\";\nimport { useSync } from \"../../hooks/Sync\";\nimport Alert from \"@mui/material/Alert\";\nimport Link from \"@mui/material/Link\";\nimport MenuItem from \"@mui/material/MenuItem\";\nimport LoadingButton from \"@mui/lab/LoadingButton\";\nimport Button from \"@mui/material/Button\";\nimport {\n  URL_KISS_WORKER,\n  OPT_SYNCTYPE_ALL,\n  OPT_SYNCTYPE_WORKER,\n  OPT_SYNCTYPE_WEBDAV,\n  OPT_SYNCTOKEN_PERFIX,\n} from \"../../config\";\nimport { useState } from \"react\";\nimport { syncSettingAndRules } from \"../../libs/sync\";\nimport { useAlert } from \"../../hooks/Alert\";\nimport { useSetting } from \"../../hooks/Setting\";\nimport { kissLog } from \"../../libs/log\";\nimport SyncIcon from \"@mui/icons-material/Sync\";\nimport ContentCopyIcon from \"@mui/icons-material/ContentCopy\";\nimport ContentPasteIcon from \"@mui/icons-material/ContentPaste\";\n\nexport default function SyncSetting() {\n  const i18n = useI18n();\n  const { sync, updateSync } = useSync();\n  const alert = useAlert();\n  const [loading, setLoading] = useState(false);\n  const { reloadSetting } = useSetting();\n\n  const handleChange = async (e) => {\n    e.preventDefault();\n    const { name, value } = e.target;\n    await updateSync({\n      [name]: value,\n    });\n  };\n\n  const handleSyncTest = async (e) => {\n    e.preventDefault();\n    try {\n      setLoading(true);\n      await syncSettingAndRules();\n      reloadSetting();\n      alert.success(i18n(\"sync_success\"));\n    } catch (err) {\n      kissLog(\"sync all\", err);\n      alert.error(i18n(\"sync_failed\"));\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const handleGenerateShareString = async () => {\n    try {\n      const base64Config = btoa(\n        JSON.stringify({\n          syncType: syncType,\n          syncUrl: syncUrl,\n          syncUser: syncUser,\n          syncKey: syncKey,\n        })\n      );\n      const shareString = `${OPT_SYNCTOKEN_PERFIX}${base64Config}`;\n      await navigator.clipboard.writeText(shareString);\n      kissLog(\"Share string copied to clipboard\", shareString);\n    } catch (error) {\n      kissLog(\"Failed to copy share string to clipboard\", error);\n    }\n  };\n\n  const handleImportFromClipboard = async () => {\n    try {\n      const text = await navigator.clipboard.readText();\n      kissLog(\"read_clipboard\", text);\n      if (text.startsWith(OPT_SYNCTOKEN_PERFIX)) {\n        const base64Config = text.slice(OPT_SYNCTOKEN_PERFIX.length);\n        const jsonString = atob(base64Config);\n        const updatedConfig = JSON.parse(jsonString);\n\n        if (!OPT_SYNCTYPE_ALL.includes(updatedConfig.syncType)) {\n          kissLog(\"error syncType\", updatedConfig.syncType);\n          return;\n        }\n\n        if (updatedConfig.syncUrl) {\n          updateSync({\n            syncType: updatedConfig.syncType,\n            syncUrl: updatedConfig.syncUrl,\n            syncUser: updatedConfig.syncUser,\n            syncKey: updatedConfig.syncKey,\n          });\n        } else {\n          kissLog(\"Invalid config structure\");\n        }\n      } else {\n        kissLog(\"Invalid share string\", text);\n      }\n    } catch (error) {\n      kissLog(\"Failed to read from clipboard or parse JSON\", error);\n    }\n  };\n\n  if (!sync) {\n    return;\n  }\n\n  const {\n    syncType = OPT_SYNCTYPE_WORKER,\n    syncUrl = \"\",\n    syncUser = \"\",\n    syncKey = \"\",\n  } = sync;\n\n  return (\n    <Box>\n      <Stack spacing={3}>\n        <Alert severity=\"warning\">{i18n(\"sync_warn\")}</Alert>\n        <Alert severity=\"warning\">{i18n(\"sync_warn_2\")}</Alert>\n\n        <TextField\n          select\n          size=\"small\"\n          name=\"syncType\"\n          value={syncType}\n          label={i18n(\"data_sync_type\")}\n          onChange={handleChange}\n        >\n          {OPT_SYNCTYPE_ALL.map((item) => (\n            <MenuItem key={item} value={item}>\n              {item}\n            </MenuItem>\n          ))}\n        </TextField>\n\n        <TextField\n          size=\"small\"\n          label={i18n(\"data_sync_url\")}\n          name=\"syncUrl\"\n          value={syncUrl}\n          onChange={handleChange}\n          helperText={\n            syncType === OPT_SYNCTYPE_WORKER && (\n              <Link href={URL_KISS_WORKER} target=\"_blank\">\n                {i18n(\"about_sync_api\")}\n              </Link>\n            )\n          }\n        />\n\n        {syncType === OPT_SYNCTYPE_WEBDAV && (\n          <TextField\n            size=\"small\"\n            label={i18n(\"data_sync_user\")}\n            name=\"syncUser\"\n            value={syncUser}\n            onChange={handleChange}\n          />\n        )}\n\n        <TextField\n          size=\"small\"\n          type=\"password\"\n          label={i18n(\"data_sync_key\")}\n          name=\"syncKey\"\n          value={syncKey}\n          onChange={handleChange}\n        />\n\n        <Stack\n          direction=\"row\"\n          alignItems=\"center\"\n          spacing={2}\n          useFlexGap\n          flexWrap=\"wrap\"\n        >\n          <LoadingButton\n            size=\"small\"\n            variant=\"contained\"\n            disabled={!syncUrl || !syncKey || loading}\n            onClick={handleSyncTest}\n            startIcon={<SyncIcon />}\n            loading={loading}\n          >\n            {i18n(\"sync_now\")}\n          </LoadingButton>\n          <Button\n            size=\"small\"\n            variant=\"outlined\"\n            onClick={handleGenerateShareString}\n            startIcon={<ContentCopyIcon />}\n          >\n            {i18n(\"copy\", \"copy\")}\n          </Button>\n          <Button\n            onClick={handleImportFromClipboard}\n            size=\"small\"\n            variant=\"outlined\"\n            startIcon={<ContentPasteIcon />}\n          >\n            {i18n(\"import\", \"import\")}\n          </Button>\n        </Stack>\n      </Stack>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "src/views/Options/Tranbox.js",
    "content": "import Box from \"@mui/material/Box\";\nimport Stack from \"@mui/material/Stack\";\nimport TextField from \"@mui/material/TextField\";\nimport MenuItem from \"@mui/material/MenuItem\";\nimport Grid from \"@mui/material/Grid\";\nimport { useI18n } from \"../../hooks/I18n\";\nimport {\n  OPT_LANGS_FROM,\n  OPT_LANGS_TO,\n  OPT_TRANBOX_TRIGGER_CLICK,\n  OPT_TRANBOX_TRIGGER_ALL,\n  OPT_DICT_BING,\n  OPT_DICT_ALL,\n  OPT_SUG_ALL,\n  OPT_SUG_YOUDAO,\n} from \"../../config\";\nimport ShortcutInput from \"./ShortcutInput\";\nimport FormControlLabel from \"@mui/material/FormControlLabel\";\nimport Switch from \"@mui/material/Switch\";\nimport { useCallback } from \"react\";\nimport { limitNumber } from \"../../libs/utils\";\nimport { useTranbox } from \"../../hooks/Tranbox\";\nimport { isExt } from \"../../libs/client\";\nimport { useApiList } from \"../../hooks/Api\";\nimport ValidationInput from \"../../hooks/ValidationInput\";\n\nexport default function Tranbox() {\n  const i18n = useI18n();\n  const { tranboxSetting, updateTranbox } = useTranbox();\n  const { enabledApis } = useApiList();\n\n  const handleChange = (e) => {\n    e.preventDefault();\n    let { name, value } = e.target;\n    switch (name) {\n      case \"btnOffsetX\":\n      case \"btnOffsetY\":\n      case \"boxOffsetX\":\n      case \"boxOffsetY\":\n        value = limitNumber(value, -200, 200);\n        break;\n      default:\n    }\n    updateTranbox({\n      [name]: value,\n    });\n  };\n\n  const handleShortcutInput = useCallback(\n    (val) => {\n      updateTranbox({ tranboxShortcut: val });\n    },\n    [updateTranbox]\n  );\n\n  const {\n    transOpen,\n    apiSlugs,\n    fromLang,\n    toLang,\n    toLang2 = \"en\",\n    tranboxShortcut,\n    btnOffsetX,\n    btnOffsetY,\n    boxOffsetX = 0,\n    boxOffsetY = 10,\n    hideTranBtn = false,\n    hideClickAway = false,\n    simpleStyle = false,\n    followSelection = false,\n    autoHeight = false,\n    triggerMode = OPT_TRANBOX_TRIGGER_CLICK,\n    // extStyles = \"\",\n    enDict = OPT_DICT_BING,\n    enSug = OPT_SUG_YOUDAO,\n  } = tranboxSetting;\n\n  return (\n    <Box>\n      <Stack spacing={3}>\n        <FormControlLabel\n          control={\n            <Switch\n              size=\"small\"\n              name=\"transOpen\"\n              checked={transOpen}\n              onChange={() => {\n                updateTranbox({ transOpen: !transOpen });\n              }}\n            />\n          }\n          label={i18n(\"toggle_selection_translate\")}\n          sx={{ width: \"fit-content\" }}\n        />\n\n        <Box>\n          <Grid container spacing={2} columns={12}>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                select\n                fullWidth\n                size=\"small\"\n                name=\"apiSlugs\"\n                value={apiSlugs}\n                label={i18n(\"translate_service_multiple\")}\n                onChange={handleChange}\n                SelectProps={{\n                  multiple: true,\n                }}\n              >\n                {enabledApis.map((api) => (\n                  <MenuItem key={api.apiSlug} value={api.apiSlug}>\n                    {api.apiName}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                fullWidth\n                select\n                size=\"small\"\n                name=\"fromLang\"\n                value={fromLang}\n                label={i18n(\"from_lang\")}\n                onChange={handleChange}\n              >\n                {OPT_LANGS_FROM.map(([lang, name]) => (\n                  <MenuItem key={lang} value={lang}>\n                    {name}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                fullWidth\n                select\n                size=\"small\"\n                name=\"toLang\"\n                value={toLang}\n                label={i18n(\"to_lang\")}\n                onChange={handleChange}\n              >\n                {OPT_LANGS_TO.map(([lang, name]) => (\n                  <MenuItem key={lang} value={lang}>\n                    {name}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                fullWidth\n                select\n                size=\"small\"\n                name=\"toLang2\"\n                value={toLang2}\n                label={i18n(\"to_lang2\")}\n                helperText={i18n(\"to_lang2_helper\")}\n                onChange={handleChange}\n              >\n                <MenuItem value={\"-\"}>{i18n(\"disable\")}</MenuItem>\n                {OPT_LANGS_TO.map(([lang, name]) => (\n                  <MenuItem key={lang} value={lang}>\n                    {name}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                fullWidth\n                select\n                size=\"small\"\n                name=\"enDict\"\n                value={enDict}\n                label={i18n(\"english_dict\")}\n                onChange={handleChange}\n              >\n                <MenuItem value={\"-\"}>{i18n(\"disable\")}</MenuItem>\n                {OPT_DICT_ALL.map((item) => (\n                  <MenuItem value={item} key={item}>\n                    {item}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                fullWidth\n                select\n                size=\"small\"\n                name=\"enSug\"\n                value={enSug}\n                label={i18n(\"english_suggest\")}\n                onChange={handleChange}\n              >\n                <MenuItem value={\"-\"}>{i18n(\"disable\")}</MenuItem>\n                {OPT_SUG_ALL.map((item) => (\n                  <MenuItem value={item} key={item}>\n                    {item}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                fullWidth\n                select\n                size=\"small\"\n                name=\"triggerMode\"\n                value={triggerMode}\n                label={i18n(\"trigger_mode\")}\n                onChange={handleChange}\n              >\n                {OPT_TRANBOX_TRIGGER_ALL.map((item) => (\n                  <MenuItem key={item} value={item}>\n                    {i18n(`trigger_${item}`)}\n                  </MenuItem>\n                ))}\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                fullWidth\n                select\n                size=\"small\"\n                name=\"hideTranBtn\"\n                value={hideTranBtn}\n                label={i18n(\"hide_tran_button\")}\n                onChange={handleChange}\n              >\n                <MenuItem value={false}>{i18n(\"show\")}</MenuItem>\n                <MenuItem value={true}>{i18n(\"hide\")}</MenuItem>\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                fullWidth\n                select\n                size=\"small\"\n                name=\"hideClickAway\"\n                value={hideClickAway}\n                label={i18n(\"hide_click_away\")}\n                onChange={handleChange}\n              >\n                <MenuItem value={false}>{i18n(\"disable\")}</MenuItem>\n                <MenuItem value={true}>{i18n(\"enable\")}</MenuItem>\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                fullWidth\n                select\n                size=\"small\"\n                name=\"simpleStyle\"\n                value={simpleStyle}\n                label={i18n(\"use_simple_style\")}\n                onChange={handleChange}\n              >\n                <MenuItem value={false}>{i18n(\"disable\")}</MenuItem>\n                <MenuItem value={true}>{i18n(\"enable\")}</MenuItem>\n              </TextField>\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                fullWidth\n                select\n                size=\"small\"\n                name=\"followSelection\"\n                value={followSelection}\n                label={i18n(\"follow_selection\")}\n                onChange={handleChange}\n              >\n                <MenuItem value={false}>{i18n(\"disable\")}</MenuItem>\n                <MenuItem value={true}>{i18n(\"enable\")}</MenuItem>\n              </TextField>\n            </Grid>\n\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <ValidationInput\n                fullWidth\n                size=\"small\"\n                label={i18n(\"tranbtn_offset_x\")}\n                type=\"number\"\n                name=\"btnOffsetX\"\n                value={btnOffsetX}\n                onChange={handleChange}\n                min={-200}\n                max={200}\n              />\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <ValidationInput\n                fullWidth\n                size=\"small\"\n                label={i18n(\"tranbtn_offset_y\")}\n                type=\"number\"\n                name=\"btnOffsetY\"\n                value={btnOffsetY}\n                onChange={handleChange}\n                min={-200}\n                max={200}\n              />\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <ValidationInput\n                fullWidth\n                size=\"small\"\n                label={i18n(\"tranbox_offset_x\")}\n                type=\"number\"\n                name=\"boxOffsetX\"\n                value={boxOffsetX}\n                onChange={handleChange}\n                min={-200}\n                max={200}\n              />\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <ValidationInput\n                fullWidth\n                size=\"small\"\n                label={i18n(\"tranbox_offset_y\")}\n                type=\"number\"\n                name=\"boxOffsetY\"\n                value={boxOffsetY}\n                onChange={handleChange}\n                min={-200}\n                max={200}\n              />\n            </Grid>\n            <Grid item xs={12} sm={12} md={6} lg={3}>\n              <TextField\n                fullWidth\n                select\n                size=\"small\"\n                name=\"autoHeight\"\n                value={autoHeight}\n                label={i18n(\"tranbox_auto_height\")}\n                onChange={handleChange}\n              >\n                <MenuItem value={false}>{i18n(\"disable\")}</MenuItem>\n                <MenuItem value={true}>{i18n(\"enable\")}</MenuItem>\n              </TextField>\n            </Grid>\n            {!isExt && (\n              <Grid item xs={12} sm={12} md={6} lg={3}>\n                <ShortcutInput\n                  value={tranboxShortcut}\n                  onChange={handleShortcutInput}\n                  label={i18n(\"trigger_tranbox_shortcut\")}\n                />\n              </Grid>\n            )}\n          </Grid>\n        </Box>\n\n        {/* <TextField\n          size=\"small\"\n          label={i18n(\"extend_styles\")}\n          name=\"extStyles\"\n          value={extStyles}\n          onChange={handleChange}\n          maxRows={10}\n          multiline\n        /> */}\n      </Stack>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "src/views/Options/UploadButton.js",
    "content": "import { useRef } from \"react\";\nimport FileUploadIcon from \"@mui/icons-material/FileUpload\";\nimport { useI18n } from \"../../hooks/I18n\";\nimport Button from \"@mui/material/Button\";\n\nexport default function UploadButton({\n  handleImport,\n  text,\n  fileType = \"json\",\n  fileExts = [\".json\"],\n}) {\n  const i18n = useI18n();\n  const inputRef = useRef(null);\n  const handleClick = () => {\n    if (inputRef.current) {\n      inputRef.current.click();\n      inputRef.current.value = null;\n    }\n  };\n  const onChange = (e) => {\n    const file = e.target.files[0];\n    if (!file) {\n      return;\n    }\n\n    if (!file.type.includes(fileType)) {\n      alert(i18n(\"error_wrong_file_type\"));\n      return;\n    }\n\n    const reader = new FileReader();\n    reader.onload = async (e) => {\n      handleImport(e.target.result);\n    };\n    reader.readAsText(file);\n  };\n\n  return (\n    <Button\n      size=\"small\"\n      variant=\"outlined\"\n      onClick={handleClick}\n      startIcon={<FileUploadIcon />}\n    >\n      {text}\n      <input\n        type=\"file\"\n        accept={fileExts.join(\", \")}\n        ref={inputRef}\n        onChange={onChange}\n        hidden\n      />\n    </Button>\n  );\n}\n"
  },
  {
    "path": "src/views/Options/index.js",
    "content": "import { Routes, Route, HashRouter } from \"react-router-dom\";\nimport About from \"./About\";\nimport Rules from \"./Rules\";\nimport Setting from \"./Setting\";\nimport Layout from \"./Layout\";\nimport SyncSetting from \"./SyncSetting\";\nimport { SettingProvider } from \"../../hooks/Setting\";\nimport ThemeProvider from \"../../hooks/Theme\";\nimport { useEffect, useState } from \"react\";\nimport { isGm } from \"../../libs/client\";\nimport { sleep } from \"../../libs/utils\";\nimport { trySyncSettingAndRules } from \"../../libs/sync\";\nimport { AlertProvider } from \"../../hooks/Alert\";\nimport { ConfirmProvider } from \"../../hooks/Confirm\";\nimport Link from \"@mui/material/Link\";\nimport Divider from \"@mui/material/Divider\";\nimport Stack from \"@mui/material/Stack\";\nimport { adaptScript } from \"../../libs/gm\";\nimport Alert from \"@mui/material/Alert\";\nimport Apis from \"./Apis\";\nimport InputSetting from \"./InputSetting\";\nimport Tranbox from \"./Tranbox\";\nimport FavWords from \"./FavWords\";\nimport Playgound from \"./Playground\";\nimport MouseHoverSetting from \"./MouseHover\";\nimport SubtitleSetting from \"./Subtitle\";\nimport Loading from \"../../hooks/Loading\";\nimport StylesSetting from \"./StylesSetting\";\n\nexport default function Options() {\n  const [error, setError] = useState(\"\");\n  const [ready, setReady] = useState(false);\n\n  useEffect(() => {\n    const isValidVersion = (v1Str, v2Str) => {\n      if (!v1Str || !v2Str) {\n        return false;\n      }\n\n      const v1 = v1Str.split(\".\");\n      const v2 = v2Str.split(\".\");\n\n      return v1[0] === v2[0] && v1[1] === v2[1];\n    };\n\n    (async () => {\n      if (isGm) {\n        // 等待GM注入\n        let i = 0;\n        for (;;) {\n          if (window?.APP_INFO?.name === process.env.REACT_APP_NAME) {\n            const { version, eventName } = window.APP_INFO;\n\n            // 检查版本是否一致（只检查前两位）\n            if (!isValidVersion(version, process.env.REACT_APP_VERSION)) {\n              setError(\n                `The version of the local script(v${version}) is not the latest version(v${process.env.REACT_APP_VERSION}). 本地脚本之版本(v${version})非最新版(v${process.env.REACT_APP_VERSION})。`\n              );\n              return;\n            }\n\n            if (eventName) {\n              // 注入GM接口\n              adaptScript(eventName);\n            }\n\n            break;\n          }\n\n          if (++i > 8) {\n            setError(\n              \"Time out. Please confirm whether to install or enable KISS Translator GreaseMonkey script? 连接超时，请检查是否安装或启用简约翻译油猴脚本。\"\n            );\n            return;\n          }\n\n          await sleep(1000);\n        }\n      }\n\n      // 同步数据\n      await trySyncSettingAndRules();\n      setReady(true);\n    })();\n  }, []);\n\n  if (error) {\n    return (\n      <center>\n        <Divider>\n          <Link\n            href={process.env.REACT_APP_HOMEPAGE}\n          >{`KISS Translator v${process.env.REACT_APP_VERSION}`}</Link>\n        </Divider>\n        <Alert severity=\"error\">{error}</Alert>\n        <Stack spacing={2}>\n          <Link href={process.env.REACT_APP_USERSCRIPT_DOWNLOADURL}>\n            Install/Update Userscript for Tampermonkey/Violentmonkey\n          </Link>\n          <Link href={process.env.REACT_APP_USERSCRIPT_IOS_DOWNLOADURL}>\n            Install/Update Userscript for iOS Safari\n          </Link>\n        </Stack>\n      </center>\n    );\n  }\n\n  if (!ready) {\n    return <Loading />;\n  }\n\n  return (\n    <SettingProvider context=\"options\">\n      <ThemeProvider>\n        <AlertProvider>\n          <ConfirmProvider>\n            <HashRouter>\n              <Routes>\n                <Route path=\"/\" element={<Layout />}>\n                  <Route index element={<Setting />} />\n                  <Route path=\"rules\" element={<Rules />} />\n                  <Route path=\"styles\" element={<StylesSetting />} />\n                  <Route path=\"input\" element={<InputSetting />} />\n                  <Route path=\"tranbox\" element={<Tranbox />} />\n                  <Route path=\"mousehover\" element={<MouseHoverSetting />} />\n                  <Route path=\"subtitle\" element={<SubtitleSetting />} />\n                  <Route path=\"apis\" element={<Apis />} />\n                  <Route path=\"sync\" element={<SyncSetting />} />\n                  <Route path=\"words\" element={<FavWords />} />\n                  <Route path=\"playground\" element={<Playgound />} />\n                  <Route path=\"about\" element={<About />} />\n                </Route>\n              </Routes>\n            </HashRouter>\n          </ConfirmProvider>\n        </AlertProvider>\n      </ThemeProvider>\n    </SettingProvider>\n  );\n}\n"
  },
  {
    "path": "src/views/Popup/Header.js",
    "content": "import IconButton from \"@mui/material/IconButton\";\nimport CloseIcon from \"@mui/icons-material/Close\";\nimport HomeIcon from \"@mui/icons-material/Home\";\nimport Stack from \"@mui/material/Stack\";\n// import DarkModeButton from \"../Options/DarkModeButton\";\nimport Typography from \"@mui/material/Typography\";\nimport SyncAltIcon from \"@mui/icons-material/SyncAlt\";\nimport OpenInNewIcon from \"@mui/icons-material/OpenInNew\";\nimport { useI18n } from \"../../hooks/I18n\";\n\nexport default function Header({ onClose, toggleTab, openSeparateWindow }) {\n  const i18n = useI18n();\n  const handleHomepage = () => {\n    window.open(process.env.REACT_APP_HOMEPAGE, \"_blank\");\n  };\n\n  return (\n    <Stack\n      direction=\"row\"\n      justifyContent=\"space-between\"\n      alignItems=\"center\"\n      spacing={2}\n    >\n      <Stack direction=\"row\" justifyContent=\"flex-start\" alignItems=\"center\">\n        <IconButton onClick={handleHomepage}>\n          <HomeIcon />\n        </IconButton>\n        <Typography\n          component=\"div\"\n          sx={{\n            userSelect: \"none\",\n            WebkitUserSelect: \"none\",\n            fontWeight: \"bold\",\n          }}\n        >\n          {`${process.env.REACT_APP_NAME} v${process.env.REACT_APP_VERSION}`}\n        </Typography>\n      </Stack>\n\n      {onClose ? (\n        <IconButton\n          onClick={() => {\n            onClose();\n          }}\n        >\n          <CloseIcon />\n        </IconButton>\n      ) : (\n        <Stack\n          direction=\"row\"\n          alignItems=\"center\"\n          title={i18n(\"toggle_transbox\")}\n        >\n          <IconButton onClick={toggleTab}>\n            <SyncAltIcon />\n          </IconButton>\n          {/* <DarkModeButton /> */}\n          <IconButton\n            onClick={openSeparateWindow}\n            title={i18n(\"open_separate_window\")}\n          >\n            <OpenInNewIcon />\n          </IconButton>\n        </Stack>\n      )}\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "src/views/Popup/PopupCont.js",
    "content": "import { useState, useEffect, useMemo } from \"react\";\nimport Stack from \"@mui/material/Stack\";\nimport MenuItem from \"@mui/material/MenuItem\";\nimport FormControlLabel from \"@mui/material/FormControlLabel\";\nimport Switch from \"@mui/material/Switch\";\nimport Button from \"@mui/material/Button\";\nimport Grid from \"@mui/material/Grid\";\nimport Snackbar from \"@mui/material/Snackbar\";\nimport MuiAlert from \"@mui/material/Alert\";\nimport { sendBgMsg, sendTabMsg, getCurTab } from \"../../libs/msg\";\nimport { isExt } from \"../../libs/client\";\nimport { useI18n } from \"../../hooks/I18n\";\nimport TextField from \"@mui/material/TextField\";\nimport {\n  MSG_TRANS_TOGGLE,\n  MSG_TRANS_PUTRULE,\n  MSG_SAVE_RULE,\n  MSG_COMMAND_SHORTCUTS,\n  MSG_TRANSBOX_TOGGLE,\n  MSG_MOUSEHOVER_TOGGLE,\n  MSG_TRANSINPUT_TOGGLE,\n  OPT_LANGS_FROM,\n  OPT_LANGS_TO,\n} from \"../../config\";\nimport { saveRule } from \"../../libs/rules\";\nimport { tryClearCaches } from \"../../libs/cache\";\nimport { kissLog } from \"../../libs/log\";\nimport { getDomainOptions, truncateMiddle } from \"../../libs/url\";\nimport { useAllTextStyles } from \"../../hooks/CustomStyles\";\n\nexport default function PopupCont({\n  rule,\n  setting,\n  setRule,\n  setSetting,\n  handleOpenSetting,\n  processActions,\n  isContent = false,\n}) {\n  const i18n = useI18n();\n  const [commands, setCommands] = useState({});\n  const [domainOptions, setDomainOptions] = useState([]);\n  const [selectedDomain, setSelectedDomain] = useState(\"\");\n  const [snackbar, setSnackbar] = useState({ open: false, message: \"\" });\n  const { allTextStyles } = useAllTextStyles();\n\n  const handleTransToggle = async (e) => {\n    try {\n      setRule({ ...rule, transOpen: e.target.checked ? \"true\" : \"false\" });\n\n      if (!processActions) {\n        await sendTabMsg(MSG_TRANS_TOGGLE);\n      } else {\n        processActions({ action: MSG_TRANS_TOGGLE });\n      }\n    } catch (err) {\n      kissLog(\"toggle trans\", err);\n    }\n  };\n\n  const handleTransboxToggle = async (e) => {\n    try {\n      setSetting((pre) => ({\n        ...pre,\n        tranboxSetting: { ...pre.tranboxSetting, transOpen: e.target.checked },\n      }));\n\n      if (!processActions) {\n        await sendTabMsg(MSG_TRANSBOX_TOGGLE);\n      } else {\n        processActions({ action: MSG_TRANSBOX_TOGGLE });\n      }\n    } catch (err) {\n      kissLog(\"toggle transbox\", err);\n    }\n  };\n\n  const handleMousehoverToggle = async (e) => {\n    try {\n      setSetting((pre) => ({\n        ...pre,\n        mouseHoverSetting: {\n          ...pre.mouseHoverSetting,\n          useMouseHover: e.target.checked,\n        },\n      }));\n\n      if (!processActions) {\n        await sendTabMsg(MSG_MOUSEHOVER_TOGGLE);\n      } else {\n        processActions({ action: MSG_MOUSEHOVER_TOGGLE });\n      }\n    } catch (err) {\n      kissLog(\"toggle mousehover\", err);\n    }\n  };\n\n  const handleInputTransToggle = async (e) => {\n    try {\n      setSetting((pre) => ({\n        ...pre,\n        inputRule: {\n          ...pre.inputRule,\n          transOpen: e.target.checked,\n        },\n      }));\n\n      if (!processActions) {\n        await sendTabMsg(MSG_TRANSINPUT_TOGGLE);\n      } else {\n        processActions({ action: MSG_TRANSINPUT_TOGGLE });\n      }\n    } catch (err) {\n      kissLog(\"toggle inputtrans\", err);\n    }\n  };\n\n  const handleChange = async (e) => {\n    try {\n      let { name, value, checked } = e.target;\n      if (name === \"isPlainText\") {\n        value = checked;\n      }\n      setRule((pre) => ({ ...pre, [name]: value }));\n\n      if (!processActions) {\n        await sendTabMsg(MSG_TRANS_PUTRULE, { [name]: value });\n      } else {\n        processActions({ action: MSG_TRANS_PUTRULE, args: { [name]: value } });\n      }\n    } catch (err) {\n      kissLog(\"update rule\", err);\n    }\n  };\n\n  const handleClearCache = () => {\n    tryClearCaches();\n  };\n\n  const handleSaveRule = async () => {\n    try {\n      if (!selectedDomain) {\n        return;\n      }\n\n      const curRule = { ...rule, pattern: selectedDomain };\n      if (isExt && isContent) {\n        sendBgMsg(MSG_SAVE_RULE, curRule);\n      } else {\n        saveRule(curRule);\n      }\n      setSnackbar({\n        open: true,\n        message: `${i18n(\"save_rule\")}: ${selectedDomain}`,\n      });\n    } catch (err) {\n      kissLog(\"save rule\", err);\n    }\n  };\n\n  useEffect(() => {\n    (async () => {\n      try {\n        let href = \"\";\n        if (!isContent) {\n          const tab = await getCurTab();\n          href = tab.url;\n        } else {\n          href = window.location?.href;\n        }\n\n        if (href && typeof href === \"string\") {\n          const options = getDomainOptions(href);\n          setDomainOptions(options);\n          if (options.length > 0) {\n            setSelectedDomain(options[0]);\n          }\n        }\n      } catch (err) {\n        kissLog(\"get domain options\", err);\n      }\n    })();\n  }, [isContent]);\n\n  useEffect(() => {\n    (async () => {\n      try {\n        const commands = {};\n        if (isExt) {\n          const res = await sendBgMsg(MSG_COMMAND_SHORTCUTS);\n          res.forEach(({ name, shortcut }) => {\n            commands[name] = shortcut;\n          });\n        } else {\n          const shortcuts = setting.shortcuts;\n          if (shortcuts) {\n            Object.entries(shortcuts).forEach(([key, val]) => {\n              commands[key] = val.join(\"+\");\n            });\n          }\n        }\n        setCommands(commands);\n      } catch (err) {\n        kissLog(\"query cmds\", err);\n      }\n    })();\n  }, [setting.shortcuts]);\n\n  const optApis = useMemo(\n    () =>\n      setting.transApis\n        .filter((api) => !api.isDisabled)\n        .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))\n        .map((api) => ({\n          key: api.apiSlug,\n          name: api.apiName || api.apiSlug,\n        })),\n    [setting.transApis]\n  );\n\n  const tranboxEnabled = setting.tranboxSetting.transOpen;\n  const mouseHoverEnabled = setting.mouseHoverSetting.useMouseHover;\n  const inputTransEnabled = setting.inputRule.transOpen;\n\n  const {\n    transOpen,\n    apiSlug,\n    fromLang,\n    toLang,\n    textStyle,\n    autoScan,\n    transOnly,\n    hasRichText,\n    scanAll,\n    isPlainText = false,\n  } = rule;\n\n  return (\n    <Stack sx={{ p: 2 }} spacing={2}>\n      <Grid container columns={12} spacing={1}>\n        <Grid item xs={12}>\n          <FormControlLabel\n            control={\n              <Switch\n                checked={transOpen === \"true\"}\n                onChange={handleTransToggle}\n              />\n            }\n            label={\n              commands[\"toggleTranslate\"]\n                ? `${i18n(\"translate_alt\")}(${commands[\"toggleTranslate\"]})`\n                : i18n(\"translate_alt\")\n            }\n          />\n        </Grid>\n        <Grid item xs={6}>\n          <FormControlLabel\n            control={\n              <Switch\n                size=\"small\"\n                name=\"autoScan\"\n                value={autoScan === \"true\" ? \"false\" : \"true\"}\n                checked={autoScan === \"true\"}\n                onChange={handleChange}\n              />\n            }\n            label={i18n(\"autoscan_alt\")}\n          />\n        </Grid>\n        <Grid item xs={6}>\n          <FormControlLabel\n            control={\n              <Switch\n                size=\"small\"\n                name=\"scanAll\"\n                value={scanAll === \"true\" ? \"false\" : \"true\"}\n                checked={scanAll === \"true\"}\n                onChange={handleChange}\n              />\n            }\n            label={i18n(\"scan_all_nodes\")}\n          />\n        </Grid>\n        <Grid item xs={6}>\n          <FormControlLabel\n            control={\n              <Switch\n                size=\"small\"\n                name=\"hasRichText\"\n                value={hasRichText === \"true\" ? \"false\" : \"true\"}\n                checked={hasRichText === \"true\"}\n                onChange={handleChange}\n              />\n            }\n            label={i18n(\"richtext_alt\")}\n          />\n        </Grid>\n        <Grid item xs={6}>\n          <FormControlLabel\n            control={\n              <Switch\n                size=\"small\"\n                name=\"transOnly\"\n                value={transOnly === \"true\" ? \"false\" : \"true\"}\n                checked={transOnly === \"true\"}\n                onChange={handleChange}\n              />\n            }\n            label={i18n(\"transonly_alt\")}\n          />\n        </Grid>\n        <Grid item xs={6}>\n          <FormControlLabel\n            control={\n              <Switch\n                size=\"small\"\n                name=\"tranboxEnabled\"\n                value={!tranboxEnabled}\n                checked={tranboxEnabled}\n                onChange={handleTransboxToggle}\n              />\n            }\n            label={i18n(\"selection_translate\")}\n          />\n        </Grid>\n        <Grid item xs={6}>\n          <FormControlLabel\n            control={\n              <Switch\n                size=\"small\"\n                name=\"mouseHoverEnabled\"\n                value={!mouseHoverEnabled}\n                checked={mouseHoverEnabled}\n                onChange={handleMousehoverToggle}\n              />\n            }\n            label={i18n(\"mousehover_translate\")}\n          />\n        </Grid>\n        <Grid item xs={6}>\n          <FormControlLabel\n            control={\n              <Switch\n                size=\"small\"\n                name=\"inputTransEnabled\"\n                value={!inputTransEnabled}\n                checked={inputTransEnabled}\n                onChange={handleInputTransToggle}\n              />\n            }\n            label={i18n(\"input_translate\")}\n          />\n        </Grid>\n        <Grid item xs={6}>\n          <FormControlLabel\n            control={\n              <Switch\n                size=\"small\"\n                name=\"isPlainText\"\n                value={!isPlainText}\n                checked={isPlainText}\n                onChange={handleChange}\n              />\n            }\n            label={i18n(\"plain_text_translate\")}\n          />\n        </Grid>\n      </Grid>\n\n      <Stack direction=\"row\" spacing={2}>\n        <TextField\n          select\n          SelectProps={{ MenuProps: { disablePortal: true } }}\n          size=\"small\"\n          value={fromLang}\n          name=\"fromLang\"\n          label={i18n(\"from_lang\")}\n          onChange={handleChange}\n          fullWidth\n        >\n          {OPT_LANGS_FROM.map(([lang, name]) => (\n            <MenuItem key={lang} value={lang}>\n              {name}\n            </MenuItem>\n          ))}\n        </TextField>\n\n        <TextField\n          select\n          SelectProps={{ MenuProps: { disablePortal: true } }}\n          size=\"small\"\n          value={toLang}\n          name=\"toLang\"\n          label={i18n(\"to_lang\")}\n          onChange={handleChange}\n          fullWidth\n        >\n          {OPT_LANGS_TO.map(([lang, name]) => (\n            <MenuItem key={lang} value={lang}>\n              {name}\n            </MenuItem>\n          ))}\n        </TextField>\n      </Stack>\n\n      <Stack direction=\"row\" spacing={2}>\n        <TextField\n          select\n          SelectProps={{ MenuProps: { disablePortal: true } }}\n          size=\"small\"\n          value={apiSlug}\n          name=\"apiSlug\"\n          label={i18n(\"translate_service\")}\n          onChange={handleChange}\n          fullWidth\n        >\n          {optApis.map(({ key, name }) => (\n            <MenuItem key={key} value={key}>\n              {name}\n            </MenuItem>\n          ))}\n        </TextField>\n\n        <TextField\n          select\n          SelectProps={{ MenuProps: { disablePortal: true } }}\n          size=\"small\"\n          value={textStyle}\n          name=\"textStyle\"\n          label={\n            commands[\"toggleStyle\"]\n              ? `${i18n(\"text_style_alt\")}(${commands[\"toggleStyle\"]})`\n              : i18n(\"text_style_alt\")\n          }\n          onChange={handleChange}\n          fullWidth\n        >\n          {allTextStyles.map((item) => (\n            <MenuItem key={item.styleSlug} value={item.styleSlug}>\n              {item.styleName}\n            </MenuItem>\n          ))}\n        </TextField>\n      </Stack>\n\n      <Stack>\n        <TextField\n          select\n          SelectProps={{ MenuProps: { disablePortal: true } }}\n          size=\"small\"\n          value={selectedDomain}\n          label={i18n(\"domain\")}\n          onChange={(e) => setSelectedDomain(e.target.value)}\n          fullWidth\n          sx={{ mb: 1 }}\n        >\n          {domainOptions.map((domain) => (\n            <MenuItem key={domain} value={domain} title={domain}>\n              {truncateMiddle(domain)}\n            </MenuItem>\n          ))}\n        </TextField>\n        <Stack\n          direction=\"row\"\n          justifyContent=\"space-between\"\n          alignItems=\"center\"\n        >\n          <Button\n            variant=\"text\"\n            onClick={handleSaveRule}\n            disabled={domainOptions.length === 0}\n          >\n            {i18n(\"save_rule\")}\n          </Button>\n          <Button variant=\"text\" onClick={handleClearCache}>\n            {i18n(\"clear_cache\")}\n          </Button>\n        </Stack>\n        <Stack\n          direction=\"row\"\n          justifyContent=\"space-between\"\n          alignItems=\"center\"\n        >\n          <Button\n            variant=\"text\"\n            onClick={() => {\n              window.open(\n                \"https://chromewebstore.google.com/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof/reviews\",\n                \"_blank\"\n              );\n            }}\n          >\n            {i18n(\"comment_support\")}\n          </Button>\n          <Button\n            variant=\"text\"\n            onClick={() => {\n              window.open(\n                \"https://github.com/fishjar/kiss-translator#%E8%B5%9E%E8%B5%8F\",\n                \"_blank\"\n              );\n            }}\n          >\n            {i18n(\"appreciate_support\")}\n          </Button>\n          <Button variant=\"text\" onClick={handleOpenSetting}>\n            {i18n(\"setting\")}\n          </Button>\n        </Stack>\n      </Stack>\n      <Snackbar\n        open={snackbar.open}\n        autoHideDuration={3000}\n        onClose={() => setSnackbar({ open: false, message: \"\" })}\n        anchorOrigin={{ vertical: \"top\", horizontal: \"center\" }}\n      >\n        <MuiAlert\n          onClose={() => setSnackbar({ open: false, message: \"\" })}\n          severity=\"success\"\n          variant=\"filled\"\n          sx={{ width: \"100%\" }}\n        >\n          {snackbar.message}\n        </MuiAlert>\n      </Snackbar>\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "src/views/Popup/index.js",
    "content": "import { useState, useEffect, useCallback } from \"react\";\nimport Box from \"@mui/material/Box\";\nimport Stack from \"@mui/material/Stack\";\nimport Button from \"@mui/material/Button\";\nimport { sendBgMsg, sendTabMsg } from \"../../libs/msg\";\nimport { browser } from \"../../libs/browser\";\nimport { useI18n } from \"../../hooks/I18n\";\nimport Divider from \"@mui/material/Divider\";\nimport Header from \"./Header\";\nimport { MSG_OPEN_SEPARATE_WINDOW, MSG_TRANS_GETRULE } from \"../../config\";\nimport { kissLog } from \"../../libs/log\";\nimport PopupCont from \"./PopupCont\";\nimport TranForm from \"../Selection/TranForm\";\nimport { useSetting } from \"../../hooks/Setting\";\n\nfunction Trantab() {\n  const [text, setText] = useState(\"\");\n  const { setting } = useSetting();\n\n  const {\n    tranboxSetting: { enDict, enSug, apiSlugs, fromLang, toLang, toLang2 },\n    transApis,\n    langDetector,\n  } = setting;\n\n  return (\n    <Box sx={{ p: 2 }}>\n      <TranForm\n        text={text}\n        setText={setText}\n        apiSlugs={apiSlugs}\n        fromLang={fromLang}\n        toLang={toLang}\n        toLang2={toLang2}\n        transApis={transApis}\n        simpleStyle={false}\n        langDetector={langDetector}\n        enDict={enDict}\n        enSug={enSug}\n      />\n    </Box>\n  );\n}\n\nexport default function Popup() {\n  const i18n = useI18n();\n  const [rule, setRule] = useState(null);\n  const [setting, setSetting] = useState(null);\n  const [showTrantab, setShowTrantab] = useState(false);\n  const [isSeparate, setIsSeparate] = useState(false);\n\n  const handleOpenSetting = useCallback(() => {\n    browser?.runtime.openOptionsPage();\n  }, []);\n\n  useEffect(() => {\n    (async () => {\n      try {\n        const cleanHash = window.location.hash.slice(1);\n        if (cleanHash === \"tranbox\") {\n          setIsSeparate(true);\n          return;\n        }\n\n        const res = await sendTabMsg(MSG_TRANS_GETRULE);\n        if (!res.error) {\n          setRule(res.rule);\n          setSetting(res.setting);\n        }\n      } catch (err) {\n        kissLog(\"query rule\", err);\n      }\n    })();\n  }, []);\n\n  const toggleTab = useCallback(() => {\n    setShowTrantab((pre) => !pre);\n  }, []);\n\n  const openSeparateWindow = useCallback(() => {\n    sendBgMsg(MSG_OPEN_SEPARATE_WINDOW);\n    window.close();\n  }, []);\n\n  if (isSeparate) {\n    return (\n      <Box>\n        <Trantab />\n      </Box>\n    );\n  }\n\n  return (\n    <Box width={360}>\n      <Header toggleTab={toggleTab} openSeparateWindow={openSeparateWindow} />\n      <Divider />\n      <Box sx={{ overflowY: \"auto\", maxHeight: 500 }}>\n        {showTrantab ? (\n          <Trantab />\n        ) : rule ? (\n          <PopupCont\n            rule={rule}\n            setting={setting}\n            setRule={setRule}\n            setSetting={setSetting}\n            handleOpenSetting={handleOpenSetting}\n          />\n        ) : (\n          <Stack\n            sx={{ p: 2 }}\n            direction=\"row\"\n            justifyContent=\"space-between\"\n            alignItems=\"center\"\n          >\n            <Button\n              variant=\"text\"\n              onClick={() => {\n                window.open(\n                  \"https://chromewebstore.google.com/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof/reviews\",\n                  \"_blank\"\n                );\n              }}\n            >\n              {i18n(\"comment_support\")}\n            </Button>\n            <Button\n              variant=\"text\"\n              onClick={() => {\n                window.open(\n                  \"https://github.com/fishjar/kiss-translator#%E8%B5%9E%E8%B5%8F\",\n                  \"_blank\"\n                );\n              }}\n            >\n              {i18n(\"appreciate_support\")}\n            </Button>\n            <Button variant=\"text\" onClick={handleOpenSetting}>\n              {i18n(\"setting\")}\n            </Button>\n          </Stack>\n        )}\n      </Box>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "src/views/Selection/AudioBtn.js",
    "content": "import IconButton from \"@mui/material/IconButton\";\nimport VolumeUpIcon from \"@mui/icons-material/VolumeUp\";\nimport { useAudio } from \"../../hooks/Audio\";\nimport queryString from \"query-string\";\n\nexport function AudioBtn({ src }) {\n  const { error, ready, playing, onPlay } = useAudio(src);\n\n  if (error || !ready) {\n    return (\n      <IconButton disabled size=\"small\">\n        <VolumeUpIcon fontSize=\"inherit\" />\n      </IconButton>\n    );\n  }\n\n  if (playing) {\n    return (\n      <IconButton color=\"primary\" size=\"small\">\n        <VolumeUpIcon fontSize=\"inherit\" />\n      </IconButton>\n    );\n  }\n\n  return (\n    <IconButton onClick={onPlay} size=\"small\">\n      <VolumeUpIcon fontSize=\"inherit\" />\n    </IconButton>\n  );\n}\n\nexport function BaiduAudioBtn({ text, lan = \"uk\", spd = 3 }) {\n  if (!text) return null;\n\n  const src = `https://fanyi.baidu.com/gettts?${queryString.stringify({ lan, text, spd })}`;\n  return <AudioBtn src={src} />;\n}\n"
  },
  {
    "path": "src/views/Selection/CopyBtn.js",
    "content": "import IconButton from \"@mui/material/IconButton\";\nimport ContentCopyIcon from \"@mui/icons-material/ContentCopy\";\nimport LibraryAddCheckIcon from \"@mui/icons-material/LibraryAddCheck\";\nimport { useState } from \"react\";\n\nexport default function CopyBtn({ text, title = \"copy\" }) {\n  const [copied, setCopied] = useState(false);\n  const handleClick = async (e) => {\n    e.stopPropagation();\n    await navigator.clipboard.writeText(text);\n    setCopied(true);\n    const timer = setTimeout(() => {\n      clearTimeout(timer);\n      setCopied(false);\n    }, 500);\n  };\n  return (\n    <IconButton\n      size=\"small\"\n      sx={{\n        opacity: 0.5,\n        \"&:hover\": {\n          opacity: 1,\n        },\n      }}\n      onClick={handleClick}\n      title={title}\n    >\n      {copied ? (\n        <LibraryAddCheckIcon fontSize=\"inherit\" />\n      ) : (\n        <ContentCopyIcon fontSize=\"inherit\" />\n      )}\n    </IconButton>\n  );\n}\n"
  },
  {
    "path": "src/views/Selection/DictCont.js",
    "content": "import { useState, useEffect, useMemo } from \"react\";\nimport Stack from \"@mui/material/Stack\";\nimport FavBtn from \"./FavBtn\";\nimport Typography from \"@mui/material/Typography\";\nimport CircularProgress from \"@mui/material/CircularProgress\";\nimport Divider from \"@mui/material/Divider\";\nimport Alert from \"@mui/material/Alert\";\nimport CopyBtn from \"./CopyBtn\";\nimport { useAsyncNow } from \"../../hooks/Fetch\";\nimport { dictHandlers } from \"./DictHandler\";\nimport { useI18n } from \"../../hooks/I18n\";\n\nfunction DictBody({ text, setCopyText, setRealWord, dict }) {\n  const { loading, error, data } = useAsyncNow(dict.apiFn, text);\n\n  useEffect(() => {\n    if (!data) {\n      return;\n    }\n\n    const realWord = dict.reWord(data) || text;\n    const copyText = [realWord, dict.toText(data).join(\"\\n\")].join(\"\\n\");\n    setRealWord(realWord);\n    setCopyText(copyText);\n  }, [data, text, dict, setCopyText, setRealWord]);\n\n  const uiAudio = useMemo(() => dict.uiAudio(data), [data, dict]);\n  const uiTrans = useMemo(() => dict.uiTrans(data), [data, dict]);\n\n  if (loading) {\n    return <CircularProgress size={16} />;\n  }\n\n  if (error) {\n    return <Alert severity=\"error\">{error}</Alert>;\n  }\n\n  if (!data) {\n    return <Typography>Not found!</Typography>;\n  }\n\n  return (\n    <Typography component=\"div\">\n      {uiAudio}\n      {uiTrans}\n    </Typography>\n  );\n}\n\nexport default function DictCont({ text, enDict }) {\n  const i18n = useI18n();\n  const [copyText, setCopyText] = useState(text);\n  const [realWord, setRealWord] = useState(text);\n  const dict = dictHandlers[enDict];\n\n  return (\n    <Stack spacing={1}>\n      {text && (\n        <Stack direction=\"row\" justifyContent=\"space-between\">\n          <Typography variant=\"subtitle1\" style={{ fontWeight: \"bold\" }}>\n            {realWord}\n          </Typography>\n          <Stack direction=\"row\" justifyContent=\"space-between\">\n            <CopyBtn text={copyText} title={i18n(\"copy\")} />\n            <FavBtn word={realWord} title={i18n(\"collect\")} />\n          </Stack>\n        </Stack>\n      )}\n\n      <Divider />\n\n      {dict && (\n        <DictBody\n          text={text}\n          setCopyText={setCopyText}\n          setRealWord={setRealWord}\n          dict={dict}\n        />\n      )}\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "src/views/Selection/DictHandler.js",
    "content": "import Typography from \"@mui/material/Typography\";\nimport { AudioBtn, BaiduAudioBtn } from \"./AudioBtn\";\nimport { OPT_DICT_BING, OPT_DICT_YOUDAO } from \"../../config\";\nimport { apiMicrosoftDict, apiYoudaoDict } from \"../../apis\";\n\nexport const dictHandlers = {\n  [OPT_DICT_BING]: {\n    apiFn: apiMicrosoftDict,\n    reWord: (data) => data?.word,\n    toText: (data) =>\n      data?.trs?.map(({ pos, def }) => `${pos ? `[${pos}] ` : \"\"}${def}`) || [],\n    uiAudio: (data) => (\n      <Typography component=\"div\">\n        {data?.aus?.map(({ key, audio, phonetic }) => (\n          <Typography\n            component=\"div\"\n            key={key}\n            style={{ display: \"inline-block\", paddingRight: \"1em\" }}\n          >\n            <Typography component=\"span\">{`${key} [${phonetic || \"\"}]`}</Typography>\n            <AudioBtn src={audio} />\n          </Typography>\n        ))}\n      </Typography>\n    ),\n    uiTrans: (data) => (\n      <Typography component=\"div\">\n        <Typography component=\"ul\">\n          {data?.trs?.map(({ pos, def }, idx) => (\n            <Typography component=\"li\" key={idx}>\n              {pos && `[${pos}] `}\n              {def}\n            </Typography>\n          ))}\n        </Typography>\n\n        {/* 时态 */}\n        {data?.presents?.length > 0 && (\n          <Typography component=\"div\" style={{ marginTop: \"10px\" }}>\n            {data.presents.join(\", \")}\n          </Typography>\n        )}\n\n        {/* 英汉双解 */}\n        {data?.ecs?.length > 0 && (\n          <Typography component=\"div\" style={{ marginTop: \"10px\" }}>\n            <Typography\n              component=\"div\"\n              style={{ fontWeight: \"bold\", marginBottom: \"5px\" }}\n            >\n              英汉双解\n            </Typography>\n            {data.ecs.map(({ pos, lis }) => (\n              <Typography component=\"div\" key={pos}>\n                <Typography component=\"div\">{pos}</Typography>\n                <Typography component=\"ul\">\n                  {lis.map((item, idx) => (\n                    <Typography component=\"li\" key={idx}>\n                      {item}\n                    </Typography>\n                  ))}\n                </Typography>\n              </Typography>\n            ))}\n          </Typography>\n        )}\n\n        {/* 显示例句 */}\n        {data?.sentences?.length > 0 && (\n          <Typography component=\"div\" style={{ marginTop: \"10px\" }}>\n            <Typography\n              component=\"div\"\n              style={{ fontWeight: \"bold\", marginBottom: \"5px\" }}\n            >\n              例句\n            </Typography>\n            {data.sentences.slice(0, 2).map((sentence, idx) => (\n              <Typography\n                component=\"div\"\n                key={idx}\n                style={{ marginBottom: \"5px\" }}\n              >\n                <Typography component=\"div\">\n                  {sentence.eng?.split(data.word)?.map((part, i, arr) => (\n                    <span key={i}>\n                      {i > 0 && (\n                        <span style={{ fontWeight: \"bold\", color: \"#1e88e5\" }}>\n                          {data.word}\n                        </span>\n                      )}\n                      {part}\n                    </span>\n                  ))}\n                </Typography>\n                <Typography\n                  component=\"div\"\n                  style={{ opacity: \"0.6\", fontStyle: \"italic\" }}\n                >\n                  {sentence.chs}\n                </Typography>\n              </Typography>\n            ))}\n          </Typography>\n        )}\n      </Typography>\n    ),\n  },\n  [OPT_DICT_YOUDAO]: {\n    apiFn: apiYoudaoDict,\n    reWord: (data) => data?.ec?.word?.[\"return-phrase\"],\n    toText: (data) =>\n      data?.ec?.word?.trs?.map(\n        ({ pos, tran }) => `${pos ? `[${pos}] ` : \"\"}${tran}`\n      ) || [],\n    uiAudio: (data) => (\n      <Typography component=\"div\">\n        <Typography\n          component=\"div\"\n          style={{ display: \"inline-block\", paddingRight: \"1em\" }}\n        >\n          <Typography component=\"span\">{`英 ${data?.ec?.word?.ukphone ? `[${data?.ec?.word?.ukphone}]` : \"\"}`}</Typography>\n          <BaiduAudioBtn text={data?.ec?.word?.[\"return-phrase\"]} lan=\"uk\" />\n        </Typography>\n        <Typography\n          component=\"div\"\n          style={{ display: \"inline-block\", paddingRight: \"1em\" }}\n        >\n          <Typography component=\"span\">{`美 ${data?.ec?.word?.usphone ? `[${data?.ec?.word?.usphone}]` : \"\"}`}</Typography>\n          <BaiduAudioBtn text={data?.ec?.word?.[\"return-phrase\"]} lan=\"en\" />\n        </Typography>\n      </Typography>\n    ),\n    uiTrans: (data) => (\n      <Typography component=\"div\">\n        <Typography component=\"ul\">\n          {data?.ec?.word?.trs?.map(({ pos, tran }, idx) => (\n            <Typography component=\"li\" key={idx}>\n              {pos && `[${pos}] `}\n              {tran}\n            </Typography>\n          ))}\n        </Typography>\n\n        {/* 显示例句 */}\n        {data?.blng_sents_part?.[\"sentence-pair\"]?.length > 0 && (\n          <Typography component=\"div\" style={{ marginTop: \"10px\" }}>\n            <Typography\n              component=\"div\"\n              style={{ fontWeight: \"bold\", marginBottom: \"5px\" }}\n            >\n              例句\n            </Typography>\n            {data.blng_sents_part[\"sentence-pair\"]\n              .slice(0, 2)\n              .map((sentence, idx) => (\n                <Typography\n                  component=\"div\"\n                  key={idx}\n                  style={{ marginBottom: \"5px\" }}\n                >\n                  <Typography component=\"div\">\n                    {sentence.sentence\n                      ?.split(data.ec?.word?.[\"return-phrase\"])\n                      ?.map((part, i, arr) => (\n                        <span key={i}>\n                          {i > 0 && data.ec?.word?.[\"return-phrase\"] && (\n                            <span\n                              style={{ fontWeight: \"bold\", color: \"#1e88e5\" }}\n                            >\n                              {data.ec.word[\"return-phrase\"]}\n                            </span>\n                          )}\n                          {part}\n                        </span>\n                      ))}\n                  </Typography>\n                  <Typography\n                    component=\"div\"\n                    style={{ opacity: \"0.6\", fontStyle: \"italic\" }}\n                  >\n                    {sentence[\"sentence-translation\"]}\n                  </Typography>\n                </Typography>\n              ))}\n          </Typography>\n        )}\n      </Typography>\n    ),\n  },\n};\n"
  },
  {
    "path": "src/views/Selection/DraggableResizable.js",
    "content": "import { useState } from \"react\";\nimport Paper from \"@mui/material/Paper\";\nimport Box from \"@mui/material/Box\";\nimport { isMobile } from \"../../libs/mobile\";\nimport { useTheme, alpha } from \"@mui/material/styles\";\nimport { limitNumber } from \"../../libs/utils\";\n\nfunction Pointer({\n  direction,\n  size,\n  setSize,\n  position,\n  setPosition,\n  children,\n  minSize,\n  maxSize,\n  ...props\n}) {\n  const [origin, setOrigin] = useState(null);\n\n  function handlePointerDown(e) {\n    !isMobile && e.target.setPointerCapture(e.pointerId);\n    const { clientX, clientY } = isMobile ? e.targetTouches[0] : e;\n    setOrigin({\n      x: position.x,\n      y: position.y,\n      w: size.w,\n      h: size.h,\n      clientX,\n      clientY,\n    });\n  }\n\n  function handlePointerMove(e) {\n    const { clientX, clientY } = isMobile ? e.targetTouches[0] : e;\n    if (origin) {\n      const dx = clientX - origin.clientX;\n      const dy = clientY - origin.clientY;\n      let x = position.x;\n      let y = position.y;\n      let w = size.w;\n      let h = size.h;\n\n      switch (direction) {\n        case \"Header\":\n          x = origin.x + dx;\n          y = origin.y + dy;\n          break;\n        case \"TopLeft\":\n          x = origin.x + dx;\n          y = origin.y + dy;\n          w = origin.w - dx;\n          h = origin.h - dy;\n          break;\n        case \"Top\":\n          y = origin.y + dy;\n          h = origin.h - dy;\n          break;\n        case \"TopRight\":\n          y = origin.y + dy;\n          w = origin.w + dx;\n          h = origin.h - dy;\n          break;\n        case \"Left\":\n          x = origin.x + dx;\n          w = origin.w - dx;\n          break;\n        case \"Right\":\n          w = origin.w + dx;\n          break;\n        case \"BottomLeft\":\n          x = origin.x + dx;\n          w = origin.w - dx;\n          h = origin.h + dy;\n          break;\n        case \"Bottom\":\n          h = origin.h + dy;\n          break;\n        case \"BottomRight\":\n          w = origin.w + dx;\n          h = origin.h + dy;\n          break;\n        default:\n      }\n\n      if (w < minSize.w) {\n        w = minSize.w;\n        x = position.x;\n      }\n      if (w > maxSize.w) {\n        w = maxSize.w;\n        x = position.x;\n      }\n      if (h < minSize.h) {\n        h = minSize.h;\n        y = position.y;\n      }\n      if (h > maxSize.h) {\n        h = maxSize.h;\n        y = position.y;\n      }\n\n      setPosition({\n        x: limitNumber(x, 0, window.innerWidth - w),\n        y: limitNumber(y, 0, window.innerHeight - 50),\n      });\n      setSize({\n        w: limitNumber(w, minSize.w, window.innerWidth),\n        h: limitNumber(h, minSize.h, window.innerHeight),\n      });\n    }\n  }\n\n  function handlePointerUp(e) {\n    e.stopPropagation();\n    setOrigin(null);\n  }\n\n  const touchProps = isMobile\n    ? {\n        onTouchStart: handlePointerDown,\n        onTouchMove: handlePointerMove,\n        onTouchEnd: handlePointerUp,\n      }\n    : {\n        onPointerDown: handlePointerDown,\n        onPointerMove: handlePointerMove,\n        onPointerUp: handlePointerUp,\n      };\n\n  return (\n    <div {...props} {...touchProps}>\n      {children}\n    </div>\n  );\n}\n\nexport default function DraggableResizable({\n  header,\n  children,\n  position = {\n    x: 0,\n    y: 0,\n  },\n  size = {\n    w: 600,\n    h: 400,\n  },\n  minSize = {\n    w: 300,\n    h: 200,\n  },\n  maxSize = {\n    w: 1200,\n    h: 1200,\n  },\n  setSize,\n  setPosition,\n  onChangeSize,\n  onChangePosition,\n  autoHeight,\n  ...props\n}) {\n  const lineWidth = 4;\n  const theme = useTheme();\n  const isDark = theme.palette.mode === \"dark\";\n  //dark模式日食效果,突出显示翻译小窗口\n  const glowShadow = isDark\n    ? `\n        0 0 0 1px rgba(255,255,255,0.18),\n        0 0 10px 2px rgba(255,255,255,0.18),\n        0 8px 32px rgba(0,0,0,0.35)\n      `\n    : ` \n        0 4px 18px rgba(0, 0, 0, 0.15)\n      `;\n  const opts = {\n    size,\n    setSize,\n    position,\n    setPosition,\n    minSize,\n    maxSize,\n  };\n\n  return (\n    <Box\n      className=\"KT-draggable\"\n      style={{\n        touchAction: \"none\",\n        position: \"fixed\",\n        left: position.x,\n        top: position.y,\n        display: \"grid\",\n        gridTemplateColumns: `${lineWidth * 2}px auto ${lineWidth * 2}px`,\n        gridTemplateRows: `${lineWidth * 2}px auto ${lineWidth * 2}px`,\n        zIndex: 2147483647,\n        borderRadius: \"12px\",\n        overflow: \"hidden\",\n      }}\n      {...props}\n    >\n      <Pointer\n        direction=\"TopLeft\"\n        style={{\n          transform: `translate(${lineWidth}px, ${lineWidth}px)`,\n          cursor: \"nw-resize\",\n        }}\n        {...opts}\n      />\n      <Pointer\n        direction=\"Top\"\n        style={{\n          margin: `0 ${lineWidth}px`,\n          transform: `translate(0px, ${lineWidth}px)`,\n          cursor: \"row-resize\",\n        }}\n        {...opts}\n      />\n      <Pointer\n        direction=\"TopRight\"\n        style={{\n          transform: `translate(-${lineWidth}px, ${lineWidth}px)`,\n          cursor: \"ne-resize\",\n        }}\n        {...opts}\n      />\n      <Pointer\n        direction=\"Left\"\n        style={{\n          margin: `${lineWidth}px 0`,\n          transform: `translate(${lineWidth}px, 0px)`,\n          cursor: \"col-resize\",\n        }}\n        {...opts}\n      />\n      <Paper\n        className=\"KT-draggable-body\"\n        elevation={4}\n        sx={{\n          borderRadius: 4,\n          overflow: \"hidden\",\n          backgroundColor: theme.palette.background.paper,\n          boxShadow: glowShadow,\n        }}\n      >\n        <Pointer\n          className=\"KT-draggable-header\"\n          direction=\"Header\"\n          style={{ cursor: \"move\" }}\n          {...opts}\n        >\n          {header}\n        </Pointer>\n        <Box\n          className=\"KT-draggable-container\"\n          sx={() => {\n            const containerStyle = autoHeight\n              ? {\n                  maxWidth: size.w,\n                  maxHeight: size.h,\n                  overflow: \"hidden auto\",\n                }\n              : {\n                  maxWidth: size.w,\n                  height: size.h,\n                  overflow: \"hidden auto\",\n                };\n\n            const scrollbarTrackColor =\n              theme.palette.mode === \"dark\"\n                ? \"#1f1f23\"\n                : theme.palette.background.paper;\n            const scrollbarThumbColor =\n              theme.palette.mode === \"dark\"\n                ? alpha(theme.palette.text.primary, 0.28)\n                : alpha(theme.palette.text.primary, 0.24);\n\n            return {\n              ...containerStyle,\n              backgroundColor: theme.palette.background.paper,\n              \"&::-webkit-scrollbar\": {\n                width: 10,\n                height: 10,\n              },\n              \"&::-webkit-scrollbar-track\": {\n                background: scrollbarTrackColor,\n              },\n              \"&::-webkit-scrollbar-thumb\": {\n                backgroundColor: scrollbarThumbColor,\n                borderRadius: 8,\n                border: `2px solid ${theme.palette.background.paper}`,\n              },\n              \"&::-webkit-scrollbar-thumb:hover\": {\n                backgroundColor: alpha(theme.palette.text.primary, 0.36),\n              },\n              // firefox\n              scrollbarWidth: \"thin\",\n              scrollbarColor: `${scrollbarThumbColor} ${scrollbarTrackColor}`,\n            };\n          }}\n        >\n          {children}\n        </Box>\n      </Paper>\n      <Pointer\n        direction=\"Right\"\n        style={{\n          margin: `${lineWidth}px 0`,\n          transform: `translate(-${lineWidth}px, 0px)`,\n          cursor: \"col-resize\",\n        }}\n        {...opts}\n      />\n      <Pointer\n        direction=\"BottomLeft\"\n        style={{\n          transform: `translate(${lineWidth}px, -${lineWidth}px)`,\n          cursor: \"ne-resize\",\n        }}\n        {...opts}\n      />\n      <Pointer\n        direction=\"Bottom\"\n        style={{\n          margin: `0 ${lineWidth}px`,\n          transform: `translate(0px, -${lineWidth}px)`,\n          cursor: \"row-resize\",\n        }}\n        {...opts}\n      />\n      <Pointer\n        direction=\"BottomRight\"\n        style={{\n          transform: `translate(-${lineWidth}px, -${lineWidth}px)`,\n          cursor: \"nw-resize\",\n        }}\n        {...opts}\n      />\n    </Box>\n  );\n}\n"
  },
  {
    "path": "src/views/Selection/FavBtn.js",
    "content": "import IconButton from \"@mui/material/IconButton\";\nimport FavoriteIcon from \"@mui/icons-material/Favorite\";\nimport FavoriteBorderIcon from \"@mui/icons-material/FavoriteBorder\";\nimport { useState } from \"react\";\nimport { useFavWords } from \"../../hooks/FavWords\";\nimport { kissLog } from \"../../libs/log\";\n\nexport default function FavBtn({ word, title }) {\n  const { favWords, toggleFav } = useFavWords();\n  const [loading, setLoading] = useState(false);\n\n  const handleClick = () => {\n    try {\n      setLoading(true);\n      toggleFav(word);\n    } catch (err) {\n      kissLog(\"set fav\", err);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  return (\n    <IconButton\n      disabled={loading}\n      size=\"small\"\n      onClick={handleClick}\n      title={title}\n    >\n      {favWords[word] ? (\n        <FavoriteIcon fontSize=\"inherit\" />\n      ) : (\n        <FavoriteBorderIcon fontSize=\"inherit\" />\n      )}\n    </IconButton>\n  );\n}\n"
  },
  {
    "path": "src/views/Selection/SugCont.js",
    "content": "import Typography from \"@mui/material/Typography\";\nimport CircularProgress from \"@mui/material/CircularProgress\";\nimport Divider from \"@mui/material/Divider\";\nimport Alert from \"@mui/material/Alert\";\nimport { apiBaiduSuggest, apiYoudaoSuggest } from \"../../apis\";\nimport Stack from \"@mui/material/Stack\";\nimport { OPT_SUG_BAIDU, OPT_SUG_YOUDAO } from \"../../config\";\nimport { useAsyncNow } from \"../../hooks/Fetch\";\n\nfunction SugBaidu({ text }) {\n  const { loading, error, data } = useAsyncNow(apiBaiduSuggest, text);\n\n  if (loading) {\n    return <CircularProgress size={16} />;\n  }\n\n  if (error) {\n    return <Alert severity=\"error\">{error}</Alert>;\n  }\n\n  if (!data) {\n    return null;\n  }\n\n  return (\n    <>\n      {data.map(({ k, v }) => (\n        <Typography component=\"div\" key={k}>\n          <Typography>{k}</Typography>\n          <Typography component=\"ul\" style={{ margin: \"0\" }}>\n            <Typography component=\"li\">{v}</Typography>\n          </Typography>\n        </Typography>\n      ))}\n    </>\n  );\n}\n\nfunction SugYoudao({ text }) {\n  const { loading, error, data } = useAsyncNow(apiYoudaoSuggest, text);\n\n  if (loading) {\n    return <CircularProgress size={16} />;\n  }\n\n  if (error) {\n    return <Alert severity=\"error\">{error}</Alert>;\n  }\n\n  if (!data) {\n    return null;\n  }\n\n  return (\n    <>\n      {data.map(({ entry, explain }) => (\n        <Typography component=\"div\" key={entry}>\n          <Typography>{entry}</Typography>\n          <Typography component=\"ul\" style={{ margin: \"0\" }}>\n            <Typography component=\"li\">{explain}</Typography>\n          </Typography>\n        </Typography>\n      ))}\n    </>\n  );\n}\n\nexport default function SugCont({ text, enSug }) {\n  const sugMap = {\n    [OPT_SUG_BAIDU]: <SugBaidu text={text} />,\n    [OPT_SUG_YOUDAO]: <SugYoudao text={text} />,\n  };\n\n  return (\n    <Stack spacing={1}>\n      <Divider />\n      {sugMap[enSug] || <Typography>Sug not support</Typography>}\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "src/views/Selection/TranBox.js",
    "content": "import { SettingProvider } from \"../../hooks/Setting\";\nimport ThemeProvider from \"../../hooks/Theme\";\nimport DraggableResizable from \"./DraggableResizable\";\nimport Stack from \"@mui/material/Stack\";\nimport Box from \"@mui/material/Box\";\nimport IconButton from \"@mui/material/IconButton\";\nimport UnfoldLessIcon from \"@mui/icons-material/UnfoldLess\";\nimport UnfoldMoreIcon from \"@mui/icons-material/UnfoldMore\";\nimport OpenInNewIcon from \"@mui/icons-material/OpenInNew\";\nimport PushPinIcon from \"@mui/icons-material/PushPin\";\nimport PushPinOutlinedIcon from \"@mui/icons-material/PushPinOutlined\";\nimport LockIcon from \"@mui/icons-material/Lock\";\nimport LockOpenIcon from \"@mui/icons-material/LockOpen\";\nimport CloseIcon from \"@mui/icons-material/Close\";\nimport Typography from \"@mui/material/Typography\";\nimport { useI18n } from \"../../hooks/I18n\";\nimport { useCallback, useState } from \"react\";\nimport TranForm from \"./TranForm.js\";\nimport { MSG_OPEN_SEPARATE_WINDOW } from \"../../config/msg.js\";\nimport { sendBgMsg } from \"../../libs/msg.js\";\nimport { isExt } from \"../../libs/client.js\";\nimport { useTheme, alpha } from \"@mui/material/styles\";\nimport Logo from \"../../components/Logo\";\n\nfunction TranBoxHeader({\n  setShowBox,\n  simpleStyle,\n  setSimpleStyle,\n  hideClickAway,\n  setHideClickAway,\n  followSelection,\n  setFollowSelection,\n}) {\n  const theme = useTheme();\n  const i18n = useI18n();\n\n  const iconColor = theme.palette.text.secondary;\n\n  const openSeparateWindow = useCallback(() => {\n    sendBgMsg(MSG_OPEN_SEPARATE_WINDOW);\n  }, []);\n\n  const blurOnLeave = (e) => e.currentTarget.blur();\n\n  const baseBtnStyle = {\n    borderRadius: \"6px\",\n    padding: \"5px\",\n    minWidth: \"30px\",\n    minHeight: \"30px\",\n    transition: \"all 0.2s ease\",\n    backgroundColor: \"transparent\",\n    \"& svg\": {\n      color: iconColor,\n    },\n  };\n\n  return (\n    <Box\n      onMouseUp={(e) => e.stopPropagation()}\n      onTouchEnd={(e) => e.stopPropagation()}\n      sx={{\n        backgroundColor: theme.palette.background.default,\n        padding: \"4px 8px 4px 12px\",\n        height: \"36px\",\n        display: \"flex\",\n        alignItems: \"center\",\n        minHeight: \"auto\",\n      }}\n    >\n      <Stack\n        direction=\"row\"\n        justifyContent=\"space-between\"\n        alignItems=\"center\"\n        spacing={1}\n        sx={{\n          width: \"100%\",\n          height: \"100%\",\n        }}\n      >\n        <Stack direction=\"row\" alignItems=\"center\" spacing={1}>\n          <Box\n            sx={{\n              width: 18,\n              height: 18,\n              display: \"flex\",\n              alignItems: \"center\",\n              justifyContent: \"center\",\n              borderRadius: \"4px\",\n              backgroundColor: theme.palette.background.paper,\n              border: `1px solid ${theme.palette.divider}`,\n              transition: \"all 0.2s ease\",\n              \"&:hover\": {\n                boxShadow: theme.shadows[2],\n                transform: \"translateY(-1px)\",\n                backgroundColor: theme.palette.action.hover,\n              },\n            }}\n          >\n            <Logo size={16} />\n          </Box>\n\n          {!simpleStyle && (\n            <Typography\n              variant=\"caption\"\n              sx={{\n                fontWeight: 500,\n                fontSize: \"12px\",\n                color: theme.palette.text.secondary,\n              }}\n            >\n              {`${process.env.REACT_APP_NAME} v${process.env.REACT_APP_VERSION}`}\n            </Typography>\n          )}\n        </Stack>\n\n        <Stack direction=\"row\" alignItems=\"center\" spacing={0.5}>\n          {isExt && (\n            <IconButton\n              size=\"small\"\n              title={i18n(\"open_separate_window\")}\n              onClick={openSeparateWindow}\n              onMouseLeave={blurOnLeave}\n              sx={{\n                ...baseBtnStyle,\n                \"&:hover\": {\n                  backgroundColor: theme.palette.primary.light + \"20\",\n                  transform: \"scale(1.05)\",\n                  boxShadow: theme.shadows[2],\n                  \"& svg\": { color: theme.palette.primary.main },\n                },\n                \"&:active\": {\n                  transform: \"scale(0.95)\",\n                  backgroundColor: theme.palette.primary.light + \"40\",\n                },\n              }}\n            >\n              <OpenInNewIcon sx={{ width: 16, height: 16 }} />\n            </IconButton>\n          )}\n\n          <IconButton\n            size=\"small\"\n            title={i18n(\"btn_tip_click_away\")}\n            onMouseLeave={blurOnLeave}\n            onClick={() => setHideClickAway((pre) => !pre)}\n            sx={{\n              ...baseBtnStyle,\n              \"&:hover\": {\n                backgroundColor: theme.palette.success.light + \"20\",\n                transform: \"scale(1.05)\",\n                boxShadow: theme.shadows[2],\n                \"& svg\": { color: theme.palette.success.main },\n              },\n              \"&:active\": {\n                transform: \"scale(0.95)\",\n                backgroundColor: theme.palette.success.light + \"40\",\n              },\n            }}\n          >\n            {hideClickAway ? (\n              <LockOpenIcon\n                sx={{\n                  width: 16,\n                  height: 16,\n                  color: theme.palette.success.main,\n                }}\n              />\n            ) : (\n              <LockIcon sx={{ width: 16, height: 16 }} />\n            )}\n          </IconButton>\n\n          <IconButton\n            size=\"small\"\n            title={i18n(\"btn_tip_follow_selection\")}\n            onMouseLeave={blurOnLeave}\n            onClick={() => setFollowSelection((pre) => !pre)}\n            sx={{\n              ...baseBtnStyle,\n              \"&:hover\": {\n                backgroundColor: theme.palette.warning.light + \"20\",\n                transform: \"scale(1.05)\",\n                boxShadow: theme.shadows[2],\n                \"& svg\": { color: theme.palette.warning.main },\n              },\n              \"&:active\": {\n                transform: \"scale(0.95)\",\n                backgroundColor: theme.palette.warning.light + \"40\",\n              },\n            }}\n          >\n            {followSelection ? (\n              <PushPinOutlinedIcon\n                sx={{\n                  width: 16,\n                  height: 16,\n                  color: theme.palette.warning.main,\n                }}\n              />\n            ) : (\n              <PushPinIcon sx={{ width: 16, height: 16 }} />\n            )}\n          </IconButton>\n\n          <IconButton\n            size=\"small\"\n            title={i18n(\"btn_tip_simple_style\")}\n            onMouseLeave={blurOnLeave}\n            onClick={() => setSimpleStyle((pre) => !pre)}\n            sx={{\n              ...baseBtnStyle,\n              \"&:hover\": {\n                backgroundColor: theme.palette.info.light + \"20\",\n                transform: \"scale(1.05)\",\n                boxShadow: theme.shadows[2],\n                \"& svg\": { color: theme.palette.info.main },\n              },\n              \"&:active\": {\n                transform: \"scale(0.95)\",\n                backgroundColor: theme.palette.info.light + \"40\",\n              },\n            }}\n          >\n            {simpleStyle ? (\n              <UnfoldMoreIcon\n                sx={{ width: 16, height: 16, color: theme.palette.info.main }}\n              />\n            ) : (\n              <UnfoldLessIcon sx={{ width: 16, height: 16 }} />\n            )}\n          </IconButton>\n\n          <IconButton\n            size=\"small\"\n            title={i18n(\"close\")}\n            onMouseLeave={blurOnLeave}\n            onClick={() => setShowBox(false)}\n            sx={{\n              ...baseBtnStyle,\n              \"&:hover\": {\n                backgroundColor: theme.palette.error.light + \"20\",\n                transform: \"scale(1.05)\",\n                boxShadow: theme.shadows[2],\n                \"& svg\": { color: theme.palette.error.main },\n              },\n              \"&:active\": {\n                transform: \"scale(0.95)\",\n                backgroundColor: theme.palette.error.light + \"40\",\n              },\n            }}\n          >\n            <CloseIcon sx={{ width: 16, height: 16 }} />\n          </IconButton>\n        </Stack>\n      </Stack>\n    </Box>\n  );\n}\n\nfunction TranBoxContent({\n  simpleStyle,\n  text,\n  setText,\n  apiSlugs,\n  fromLang,\n  toLang,\n  toLang2,\n  transApis,\n  langDetector,\n  enDict,\n  enSug,\n}) {\n  const theme = useTheme();\n  const isDark = theme.palette.mode === \"dark\";\n  const scrollbarTrackColor =\n    theme.palette.mode === \"dark\" ? \"#1f1f23\" : theme.palette.background.paper;\n  const scrollbarThumbColor =\n    theme.palette.mode === \"dark\"\n      ? alpha(theme.palette.text.primary, 0.28)\n      : alpha(theme.palette.text.primary, 0.24);\n\n  return (\n    <Box\n      sx={{\n        p: simpleStyle ? 1 : 2,\n        backgroundColor: theme.palette.background.paper,\n\n        \"&::-webkit-scrollbar\": {\n          width: 10,\n          height: 10,\n        },\n        \"&::-webkit-scrollbar-track\": {\n          background: scrollbarTrackColor,\n        },\n        \"&::-webkit-scrollbar-thumb\": {\n          backgroundColor: scrollbarThumbColor,\n          borderRadius: 8,\n          border: `2px solid ${theme.palette.background.paper}`,\n        },\n        \"&::-webkit-scrollbar-thumb:hover\": {\n          backgroundColor: alpha(theme.palette.text.primary, 0.36),\n        },\n        // Firefox\n        scrollbarWidth: \"thin\",\n        scrollbarColor: `${scrollbarThumbColor} ${scrollbarTrackColor}`,\n\n        color: isDark\n          ? \"rgba(255,255,255,0.82)\" // 柔白, 避免刺眼\n          : theme.palette.text.primary,\n\n        lineHeight: 1.55,\n      }}\n    >\n      <TranForm\n        text={text}\n        setText={setText}\n        apiSlugs={apiSlugs}\n        fromLang={fromLang}\n        toLang={toLang}\n        toLang2={toLang2}\n        transApis={transApis}\n        simpleStyle={simpleStyle}\n        langDetector={langDetector}\n        enDict={enDict}\n        enSug={enSug}\n      />\n    </Box>\n  );\n}\n\nexport default function TranBox(props) {\n  const [mouseHover, setMouseHover] = useState(false);\n\n  const simpleStyle = props.simpleStyle;\n  const setSimpleStyle = props.setSimpleStyle;\n  const hideClickAway = props.hideClickAway;\n  const setHideClickAway = props.setHideClickAway;\n  const followSelection = props.followSelection;\n  const setFollowSelection = props.setFollowSelection;\n  return (\n    <SettingProvider context=\"tranbox\">\n      <ThemeProvider styles={props.extStyles}>\n        {props.showBox && (\n          <DraggableResizable\n            position={props.boxPosition}\n            size={props.boxSize}\n            setSize={props.setBoxSize}\n            setPosition={props.setBoxPosition}\n            autoHeight={props.tranboxSetting.autoHeight}\n            header={\n              <TranBoxHeader\n                setShowBox={props.setShowBox}\n                simpleStyle={simpleStyle}\n                setSimpleStyle={setSimpleStyle}\n                hideClickAway={hideClickAway}\n                setHideClickAway={setHideClickAway}\n                followSelection={followSelection}\n                setFollowSelection={setFollowSelection}\n                mouseHover={mouseHover}\n              />\n            }\n            onClick={(e) => e.stopPropagation()}\n            onMouseEnter={() => setMouseHover(true)}\n            onMouseLeave={() => setMouseHover(false)}\n          >\n            <TranBoxContent\n              simpleStyle={simpleStyle}\n              text={props.text}\n              setText={props.setText}\n              apiSlugs={props.tranboxSetting.apiSlugs}\n              fromLang={props.tranboxSetting.fromLang}\n              toLang={props.tranboxSetting.toLang}\n              toLang2={props.tranboxSetting.toLang2}\n              transApis={props.transApis}\n              langDetector={props.langDetector}\n              enDict={props.tranboxSetting.enDict}\n              enSug={props.tranboxSetting.enSug}\n            />\n          </DraggableResizable>\n        )}\n      </ThemeProvider>\n    </SettingProvider>\n  );\n}\n"
  },
  {
    "path": "src/views/Selection/TranBtn.js",
    "content": "import { isMobile } from \"../../libs/mobile\";\nimport { limitNumber } from \"../../libs/utils\";\n\nexport default function TranBtn({\n  onTrigger,\n  btnEvent,\n  position,\n  btnOffsetX,\n  btnOffsetY,\n}) {\n  const left = limitNumber(position.x + btnOffsetX, 0, window.innerWidth - 32);\n  const top = limitNumber(position.y + btnOffsetY, 0, window.innerHeight - 32);\n\n  return (\n    <div\n      className=\"KT-tranbtn\"\n      style={{\n        cursor: \"pointer\",\n        // position: \"absolute\",\n        position: \"fixed\",\n        left,\n        top,\n        zIndex: 2147483647,\n      }}\n      {...{ [btnEvent]: onTrigger }}\n    >\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        width={isMobile ? \"32\" : \"20\"}\n        height={isMobile ? \"32\" : \"20\"}\n        viewBox=\"0 0 32 32\"\n        version=\"1.1\"\n      >\n        <path\n          d=\"M0 0 C10.56 0 21.12 0 32 0 C32 10.56 32 21.12 32 32 C21.44 32 10.88 32 0 32 C0 21.44 0 10.88 0 0 Z \"\n          fill=\"#209CEE\"\n          transform=\"translate(0,0)\"\n        />\n        <path\n          d=\"M0 0 C0.66 0 1.32 0 2 0 C2 2.97 2 5.94 2 9 C2.969375 8.2575 3.93875 7.515 4.9375 6.75 C5.48277344 6.33234375 6.02804688 5.9146875 6.58984375 5.484375 C8.39053593 3.83283924 8.39053593 3.83283924 9 0 C13.95 0 18.9 0 24 0 C24 0.99 24 1.98 24 3 C22.68 3 21.36 3 20 3 C20 9.27 20 15.54 20 22 C19.01 22 18.02 22 17 22 C17 15.73 17 9.46 17 3 C15.35 3 13.7 3 12 3 C11.731875 3.598125 11.46375 4.19625 11.1875 4.8125 C10.01506533 6.97224808 8.80630718 8.35790256 7 10 C8.01790655 12.27071461 8.77442829 13.80784632 10.6875 15.4375 C11.120625 15.953125 11.55375 16.46875 12 17 C11.6875 19.6875 11.6875 19.6875 11 22 C10.34 22 9.68 22 9 22 C8.773125 21.236875 8.54625 20.47375 8.3125 19.6875 C6.73268318 16.45263699 5.16717283 15.58358642 2 14 C2 16.64 2 19.28 2 22 C1.34 22 0.68 22 0 22 C0 14.74 0 7.48 0 0 Z \"\n          fill=\"#E9F5FD\"\n          transform=\"translate(4,5)\"\n        />\n      </svg>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/views/Selection/TranCont.js",
    "content": "import TextField from \"@mui/material/TextField\";\nimport Box from \"@mui/material/Box\";\nimport CircularProgress from \"@mui/material/CircularProgress\";\nimport Stack from \"@mui/material/Stack\";\nimport { useI18n } from \"../../hooks/I18n\";\nimport { useEffect, useState, useMemo } from \"react\";\nimport { apiTranslate } from \"../../apis\";\nimport CopyBtn from \"./CopyBtn\";\nimport Typography from \"@mui/material/Typography\";\nimport Alert from \"@mui/material/Alert\";\n\nexport default function TranCont({\n  text,\n  fromLang,\n  toLang,\n  apiSlug,\n  transApis,\n  simpleStyle = false,\n}) {\n  const i18n = useI18n();\n  const [trText, setTrText] = useState(\"\");\n  const [loading, setLoading] = useState(false);\n  const [error, setError] = useState(\"\");\n\n  const apiSetting = useMemo(\n    () => transApis.find((api) => api.apiSlug === apiSlug),\n    [transApis, apiSlug]\n  );\n\n  useEffect(() => {\n    if (!text?.trim() || !apiSetting) {\n      return;\n    }\n\n    (async () => {\n      try {\n        setLoading(true);\n        setTrText(\"\");\n        setError(\"\");\n\n        const { trText } = await apiTranslate({\n          text,\n          fromLang,\n          toLang,\n          apiSetting,\n        });\n\n        setTrText(trText);\n      } catch (err) {\n        setError(err.message);\n      } finally {\n        setLoading(false);\n      }\n    })();\n  }, [text, fromLang, toLang, apiSetting]);\n\n  if (!apiSetting) {\n    return null;\n  }\n\n  if (simpleStyle) {\n    return (\n      <Box>\n        {error ? (\n          <Alert severity=\"error\">{error}</Alert>\n        ) : loading ? (\n          <CircularProgress size={16} />\n        ) : (\n          <Typography style={{ whiteSpace: \"pre-line\" }}>{trText}</Typography>\n        )}\n      </Box>\n    );\n  }\n\n  return (\n    <Box>\n      <TextField\n        size=\"small\"\n        label={`${i18n(\"translated_text\")} - ${apiSetting.apiName}`}\n        // disabled\n        fullWidth\n        multiline\n        value={trText}\n        helperText={error}\n        InputProps={{\n          startAdornment: loading ? <CircularProgress size={16} /> : null,\n          endAdornment: (\n            <Stack\n              direction=\"row\"\n              sx={{\n                position: \"absolute\",\n                right: 0,\n                top: 0,\n              }}\n            >\n              <CopyBtn text={trText} title={i18n(\"copy\")} />\n            </Stack>\n          ),\n        }}\n      />\n    </Box>\n  );\n}\n"
  },
  {
    "path": "src/views/Selection/TranForm.js",
    "content": "import Stack from \"@mui/material/Stack\";\nimport TextField from \"@mui/material/TextField\";\nimport MenuItem from \"@mui/material/MenuItem\";\nimport Grid from \"@mui/material/Grid\";\nimport Box from \"@mui/material/Box\";\nimport IconButton from \"@mui/material/IconButton\";\nimport DoneIcon from \"@mui/icons-material/Done\";\nimport CircularProgress from \"@mui/material/CircularProgress\";\nimport ContentPasteIcon from \"@mui/icons-material/ContentPaste\";\nimport { useI18n } from \"../../hooks/I18n\";\nimport {\n  OPT_LANGS_FROM,\n  OPT_LANGS_TO,\n  OPT_LANGDETECTOR_ALL,\n  OPT_DICT_ALL,\n  OPT_SUG_ALL,\n  OPT_LANGS_MAP,\n  OPT_DICT_MAP,\n  OPT_SUG_MAP,\n} from \"../../config\";\nimport { useState, useMemo, useEffect, useRef } from \"react\";\nimport TranCont from \"./TranCont\";\nimport DictCont from \"./DictCont\";\nimport SugCont from \"./SugCont\";\nimport CopyBtn from \"./CopyBtn\";\nimport { isValidWord } from \"../../libs/utils\";\nimport { kissLog } from \"../../libs/log\";\nimport { tryDetectLang } from \"../../libs/detect\";\n\nexport default function TranForm({\n  text,\n  setText,\n  apiSlugs: initApiSlugs,\n  fromLang: initFromLang,\n  toLang: initToLang,\n  toLang2: initToLang2,\n  transApis,\n  simpleStyle = false,\n  langDetector: initLangDetector = \"-\",\n  enDict: initEnDict = \"-\",\n  enSug: initEnSug = \"-\",\n  isPlaygound = false,\n}) {\n  const i18n = useI18n();\n\n  const [editMode, setEditMode] = useState(false);\n  const [editText, setEditText] = useState(text);\n  const [apiSlugs, setApiSlugs] = useState(initApiSlugs);\n  const [fromLang, setFromLang] = useState(initFromLang);\n  const [toLang, setToLang] = useState(initToLang);\n  const [toLang2, setToLang2] = useState(initToLang2);\n  const [langDetector, setLangDetector] = useState(initLangDetector);\n  const [enDict, setEnDict] = useState(initEnDict);\n  const [enSug, setEnSug] = useState(initEnSug);\n  const [deLang, setDeLang] = useState(\"\");\n  const [deLoading, setDeLoading] = useState(false);\n  const inputRef = useRef(null);\n\n  useEffect(() => {\n    const input = inputRef.current;\n    if (!input) return;\n\n    input.focus();\n\n    const len = input.value.length;\n    input.setSelectionRange(len, len);\n  }, []);\n\n  useEffect(() => {\n    if (isValidWord(text)) {\n      const event = new CustomEvent(\"kiss-add-word\", {\n        detail: { word: text },\n      });\n      document.dispatchEvent(event);\n    }\n  }, [text]);\n\n  useEffect(() => {\n    if (!editMode) {\n      setEditText(text);\n    }\n  }, [text, editMode]);\n\n  useEffect(() => {\n    if (!text.trim()) {\n      setDeLang(\"\");\n      return;\n    }\n\n    (async () => {\n      try {\n        setDeLoading(true);\n        const deLang = await tryDetectLang(text, langDetector);\n        if (deLang) {\n          setDeLang(deLang);\n        }\n      } catch (err) {\n        kissLog(\"tranbox: detect lang\", err);\n      } finally {\n        setDeLoading(false);\n      }\n    })();\n  }, [text, langDetector, setDeLang, setDeLoading]);\n\n  const handlePaste = async () => {\n    try {\n      const text = await navigator.clipboard.readText();\n      setText(text.trim());\n    } catch (err) {\n      //\n    }\n  };\n\n  // todo: 语言变化后，realToLang引发二次翻译请求\n  const realToLang = useMemo(() => {\n    if (\n      fromLang === \"auto\" &&\n      toLang !== toLang2 &&\n      toLang2 !== \"-\" &&\n      deLang === toLang\n    ) {\n      return toLang2;\n    }\n\n    return toLang;\n  }, [fromLang, toLang, toLang2, deLang]);\n\n  const optApis = useMemo(\n    () =>\n      transApis\n        .filter((api) => !api.isDisabled)\n        .map((api) => ({\n          key: api.apiSlug,\n          name: api.apiName || api.apiSlug,\n        })),\n    [transApis]\n  );\n\n  const isWord = useMemo(() => isValidWord(text), [text]);\n  const xs = useMemo(() => (isPlaygound ? 6 : 4), [isPlaygound]);\n  const md = useMemo(() => (isPlaygound ? 3 : 4), [isPlaygound]);\n\n  const activeApiSlugs = useMemo(() => {\n    const validSlugs = new Set(optApis.map((api) => api.key));\n    return apiSlugs.filter((slug) => validSlugs.has(slug));\n  }, [apiSlugs, optApis]);\n\n\n  return (\n    <Stack spacing={simpleStyle ? 1 : 2}>\n      {!simpleStyle && (\n        <>\n          <Box>\n            <Grid container spacing={2} columns={12}>\n              <Grid item xs={xs} md={md}>\n                <TextField\n                  select\n                  SelectProps={{\n                    multiple: true,\n                    MenuProps: { disablePortal: !isPlaygound },\n                  }}\n                  fullWidth\n                  size=\"small\"\n                  value={activeApiSlugs}\n                  name=\"apiSlugs\"\n                  label={i18n(\"translate_service_multiple\")}\n                  onChange={(e) => {\n                    setApiSlugs(e.target.value);\n                  }}\n                >\n                  {optApis.map(({ key, name }) => (\n                    <MenuItem key={key} value={key}>\n                      {name}\n                    </MenuItem>\n                  ))}\n                </TextField>\n              </Grid>\n              <Grid item xs={xs} md={md}>\n                <TextField\n                  select\n                  SelectProps={{ MenuProps: { disablePortal: !isPlaygound } }}\n                  fullWidth\n                  size=\"small\"\n                  name=\"fromLang\"\n                  value={fromLang}\n                  label={i18n(\"from_lang\")}\n                  onChange={(e) => {\n                    setFromLang(e.target.value);\n                  }}\n                >\n                  {OPT_LANGS_FROM.map(([lang, name]) => (\n                    <MenuItem key={lang} value={lang}>\n                      {name}\n                    </MenuItem>\n                  ))}\n                </TextField>\n              </Grid>\n              <Grid item xs={xs} md={md}>\n                <TextField\n                  select\n                  SelectProps={{ MenuProps: { disablePortal: !isPlaygound } }}\n                  fullWidth\n                  size=\"small\"\n                  name=\"toLang\"\n                  value={toLang}\n                  label={i18n(\"to_lang\")}\n                  onChange={(e) => {\n                    setToLang(e.target.value);\n                  }}\n                >\n                  {OPT_LANGS_TO.map(([lang, name]) => (\n                    <MenuItem key={lang} value={lang}>\n                      {name}\n                    </MenuItem>\n                  ))}\n                </TextField>\n              </Grid>\n\n              {isPlaygound && (\n                <>\n                  <Grid item xs={xs} md={md}>\n                    <TextField\n                      select\n                      SelectProps={{\n                        MenuProps: { disablePortal: !isPlaygound },\n                      }}\n                      fullWidth\n                      size=\"small\"\n                      name=\"toLang2\"\n                      value={toLang2}\n                      label={i18n(\"to_lang2\")}\n                      onChange={(e) => {\n                        setToLang2(e.target.value);\n                      }}\n                    >\n                      {OPT_LANGS_TO.map(([lang, name]) => (\n                        <MenuItem key={lang} value={lang}>\n                          {name}\n                        </MenuItem>\n                      ))}\n                    </TextField>\n                  </Grid>\n                  <Grid item xs={xs} md={md}>\n                    <TextField\n                      select\n                      SelectProps={{\n                        MenuProps: { disablePortal: !isPlaygound },\n                      }}\n                      fullWidth\n                      size=\"small\"\n                      name=\"enDict\"\n                      value={enDict}\n                      label={i18n(\"english_dict\")}\n                      onChange={(e) => {\n                        setEnDict(e.target.value);\n                      }}\n                    >\n                      <MenuItem value={\"-\"}>{i18n(\"disable\")}</MenuItem>\n                      {OPT_DICT_ALL.map((item) => (\n                        <MenuItem value={item} key={item}>\n                          {item}\n                        </MenuItem>\n                      ))}\n                    </TextField>\n                  </Grid>\n                  <Grid item xs={xs} md={md}>\n                    <TextField\n                      select\n                      SelectProps={{\n                        MenuProps: { disablePortal: !isPlaygound },\n                      }}\n                      fullWidth\n                      size=\"small\"\n                      name=\"enSug\"\n                      value={enSug}\n                      label={i18n(\"english_suggest\")}\n                      onChange={(e) => {\n                        setEnSug(e.target.value);\n                      }}\n                    >\n                      <MenuItem value={\"-\"}>{i18n(\"disable\")}</MenuItem>\n                      {OPT_SUG_ALL.map((item) => (\n                        <MenuItem value={item} key={item}>\n                          {item}\n                        </MenuItem>\n                      ))}\n                    </TextField>\n                  </Grid>\n                  <Grid item xs={xs} md={md}>\n                    <TextField\n                      select\n                      SelectProps={{\n                        MenuProps: { disablePortal: !isPlaygound },\n                      }}\n                      fullWidth\n                      size=\"small\"\n                      name=\"langDetector\"\n                      value={langDetector}\n                      label={i18n(\"detected_lang\")}\n                      onChange={(e) => {\n                        setLangDetector(e.target.value);\n                      }}\n                    >\n                      <MenuItem value={\"-\"}>{i18n(\"disable\")}</MenuItem>\n                      {OPT_LANGDETECTOR_ALL.map((item) => (\n                        <MenuItem value={item} key={item}>\n                          {item}\n                        </MenuItem>\n                      ))}\n                    </TextField>\n                  </Grid>\n                  <Grid item xs={xs} md={md}>\n                    <TextField\n                      fullWidth\n                      size=\"small\"\n                      name=\"deLang\"\n                      value={deLang && OPT_LANGS_MAP.get(deLang)}\n                      label={i18n(\"detected_result\")}\n                      disabled\n                      InputProps={{\n                        startAdornment: deLoading ? (\n                          <CircularProgress size={16} />\n                        ) : null,\n                      }}\n                    />\n                  </Grid>\n                </>\n              )}\n            </Grid>\n          </Box>\n\n          <Box>\n            <TextField\n              size=\"small\"\n              label={i18n(\"original_text\")}\n              fullWidth\n              multiline\n              inputRef={inputRef}\n              minRows={isPlaygound ? 2 : 1}\n              maxRows={10}\n              value={editText}\n              onChange={(e) => {\n                setEditText(e.target.value);\n              }}\n              onFocus={() => {\n                setEditMode(true);\n              }}\n              onBlur={() => {\n                setEditMode(false);\n                setText(editText.trim());\n              }}\n              InputProps={{\n                endAdornment: (\n                  <Stack\n                    direction=\"row\"\n                    sx={{\n                      position: \"absolute\",\n                      right: 0,\n                      top: 0,\n                    }}\n                  >\n                    {editMode ? (\n                      <IconButton\n                        size=\"small\"\n                        onClick={(e) => {\n                          e.stopPropagation();\n                          setEditMode(false);\n                          setText(editText.trim());\n                        }}\n                        title={i18n(\"submit\")}\n                      >\n                        <DoneIcon fontSize=\"inherit\" />\n                      </IconButton>\n                    ) : text ? (\n                      <CopyBtn text={text} title={i18n(\"copy\")} />\n                    ) : (\n                      <IconButton\n                        size=\"small\"\n                        onClick={handlePaste}\n                        title={i18n(\"paste\")}\n                      >\n                        <ContentPasteIcon fontSize=\"inherit\" />\n                      </IconButton>\n                    )}\n                  </Stack>\n                ),\n              }}\n            />\n          </Box>\n        </>\n      )}\n\n      {activeApiSlugs.map((slug) => (\n        <TranCont\n          key={slug}\n          text={text}\n          fromLang={fromLang}\n          toLang={realToLang}\n          simpleStyle={simpleStyle}\n          apiSlug={slug}\n          transApis={transApis}\n        />\n      ))}\n\n      {isWord && OPT_DICT_MAP.has(enDict) && (\n        <DictCont text={text} enDict={enDict} />\n      )}\n\n      {isWord && OPT_SUG_MAP.has(enSug) && (\n        <SugCont text={text} enSug={enSug} />\n      )}\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "src/views/Selection/index.js",
    "content": "import TranBtn from \"./TranBtn\";\nimport TranBox from \"./TranBox\";\nimport useTranBoxState from \"../../hooks/useTranBoxState\";\nimport useSelectionController from \"../../hooks/useSelectionController\";\nimport useTranboxShortcuts from \"../../hooks/useTranboxShortcuts\";\n\nexport default function Selection({\n  contextMenuType,\n  tranboxSetting,\n  transApis,\n  uiLang,\n  langDetector,\n}) {\n  const {\n    boxSize,\n    setBoxSize,\n    boxPosition,\n    setBoxPosition,\n    simpleStyle,\n    setSimpleStyle,\n    hideClickAway,\n    setHideClickAway,\n    followSelection,\n    setFollowSelection,\n    boxOffsetX,\n    boxOffsetY,\n  } = useTranBoxState(tranboxSetting);\n\n  const {\n    showBox,\n    setShowBox,\n    showBtn,\n    text,\n    setText,\n    position,\n    handleOpenTranbox,\n    handleToggleTranbox,\n    btnEvent,\n  } = useSelectionController({\n    tranboxSetting,\n    followSelection,\n    boxOffsetX,\n    boxOffsetY,\n    boxSize,\n    setBoxPosition,\n    hideClickAway,\n  });\n\n  useTranboxShortcuts({\n    tranboxSetting,\n    showBox,\n    setShowBox,\n    handleToggleTranbox,\n    contextMenuType,\n    uiLang,\n  });\n\n  return (\n    <>\n      {\n        <TranBox\n          showBox={showBox}\n          text={text}\n          setText={setText}\n          boxSize={boxSize}\n          setBoxSize={setBoxSize}\n          boxPosition={boxPosition}\n          setBoxPosition={setBoxPosition}\n          tranboxSetting={tranboxSetting}\n          transApis={transApis}\n          setShowBox={setShowBox}\n          simpleStyle={simpleStyle}\n          setSimpleStyle={setSimpleStyle}\n          hideClickAway={hideClickAway}\n          setHideClickAway={setHideClickAway}\n          followSelection={followSelection}\n          setFollowSelection={setFollowSelection}\n          // extStyles={extStyles}\n          langDetector={langDetector}\n        />\n      }\n\n      {showBtn && (\n        <TranBtn\n          position={position}\n          btnOffsetX={tranboxSetting.btnOffsetX}\n          btnOffsetY={tranboxSetting.btnOffsetY}\n          btnEvent={btnEvent}\n          onTrigger={(e) => {\n            e.stopPropagation();\n            handleOpenTranbox();\n          }}\n        />\n      )}\n    </>\n  );\n}\n"
  }
]