├── .env.example ├── .gitattributes ├── .github ├── dependabot.yml ├── renovate.json └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .prettierignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── toolkit.code-snippets ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── addon ├── bootstrap.js ├── content │ ├── icons │ │ ├── favicon.png │ │ ├── favicon@0.5x.png │ │ ├── favicon@16x16.png │ │ └── icon.svg │ ├── preferences.xhtml │ ├── taskManager.xhtml │ └── zoteroPane.css ├── locale │ ├── en-US │ │ ├── addon.ftl │ │ ├── mainWindow.ftl │ │ ├── preferences.ftl │ │ └── taskManager.ftl │ └── zh-CN │ │ ├── addon.ftl │ │ ├── mainWindow.ftl │ │ ├── preferences.ftl │ │ └── taskManager.ftl ├── manifest.json └── prefs.js ├── eslint.config.mjs ├── package.json ├── pnpm-lock.yaml ├── screenshots ├── get-zotero-auth-key.png ├── install.png ├── preview.png ├── right_menu.png ├── set-zotero-auth-key.png └── task-modal.png ├── src ├── addon.ts ├── api │ ├── index.ts │ └── request.ts ├── config │ └── index.ts ├── hooks.ts ├── index.ts ├── modules │ ├── language │ │ ├── config.ts │ │ ├── index.ts │ │ └── types.ts │ ├── menu.ts │ ├── notify.ts │ ├── preference-window.ts │ ├── prompt.ts │ ├── shortcuts.ts │ ├── toolbar.ts │ └── translate │ │ ├── confirm-dialog.ts │ │ ├── persistence.ts │ │ ├── store.ts │ │ ├── task-manager.ts │ │ ├── task-monitor.ts │ │ ├── task.ts │ │ └── translate.ts ├── types.ts └── utils │ ├── const.ts │ ├── dialog.ts │ ├── fake_user.ts │ ├── locale.ts │ ├── prefs.ts │ ├── report.ts │ ├── window.ts │ └── ztoolkit.ts ├── tsconfig.json ├── typings ├── global.d.ts └── prefs.d.ts └── zotero-plugin.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | # Usage: 2 | # Copy this file as `.env` and fill in the variables below as instructed. 3 | 4 | # If you are developing more than one plugin, you can store the bin path and 5 | # profile path in the system environment variables, which can be omitted here. 6 | 7 | # The path of the Zotero binary file. 8 | # The path delimiter should be escaped as `\\` for win32. 9 | # The path is `*/Zotero.app/Contents/MacOS/zotero` for MacOS. 10 | ZOTERO_PLUGIN_ZOTERO_BIN_PATH = /path/to/zotero.exe 11 | 12 | # The path of the profile used for development. 13 | # Start the profile manager by `/path/to/zotero.exe -p` to create a profile for development. 14 | # @see https://www.zotero.org/support/kb/profile_directory 15 | ZOTERO_PLUGIN_PROFILE_PATH = /path/to/profile 16 | 17 | # The directory where the database is located. 18 | # If this field is kept empty, Zotero will start with the default data. 19 | # @see https://www.zotero.org/support/zotero_data 20 | ZOTERO_PLUGIN_DATA_DIR = 21 | 22 | # Custom commands to kill Zotero processes. 23 | # Commands for different platforms are already built into zotero-plugin, 24 | # if the built-in commands are not suitable for your needs, please modify this variable. 25 | # ZOTERO_PLUGIN_KILL_COMMAND = 26 | 27 | # GitHub Token 28 | # For scaffold auto create release and upload assets. 29 | # Fill in this variable if you are publishing locally instead of CI. 30 | # GITHUB_TOKEN = -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | groups: 13 | all-non-major: 14 | update-types: 15 | - "minor" 16 | - "patch" 17 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | ":semanticPrefixChore", 6 | ":prHourlyLimitNone", 7 | ":prConcurrentLimitNone", 8 | ":enableVulnerabilityAlerts", 9 | ":dependencyDashboard", 10 | "group:allNonMajor", 11 | "schedule:weekly" 12 | ], 13 | "labels": ["dependencies"], 14 | "packageRules": [ 15 | { 16 | "matchPackageNames": [ 17 | "zotero-plugin-toolkit", 18 | "zotero-types", 19 | "zotero-plugin-scaffold" 20 | ], 21 | "schedule": ["at any time"], 22 | "automerge": true 23 | } 24 | ], 25 | "git-submodules": { 26 | "enabled": true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GitHub_TOKEN }} 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Setup PNPM 23 | uses: pnpm/action-setup@v4 24 | with: 25 | version: ^7.0 26 | 27 | - name: Setup Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: 20 31 | cache: "pnpm" 32 | 33 | - name: Install deps 34 | run: pnpm install --no-frozen-lockfile 35 | 36 | - name: Run Lint 37 | run: | 38 | pnpm lint:check 39 | 40 | build: 41 | runs-on: ubuntu-latest 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GitHub_TOKEN }} 44 | OLD_GA_MEASUREMENT_ID: ${{ secrets.OLD_GA_MEASUREMENT_ID }} 45 | OLD_GA_API_SECRET: ${{ secrets.OLD_GA_API_SECRET }} 46 | NEW_GA_MEASUREMENT_ID: ${{ secrets.NEW_GA_MEASUREMENT_ID }} 47 | NEW_GA_API_SECRET: ${{ secrets.NEW_GA_API_SECRET }} 48 | steps: 49 | - name: Checkout 50 | uses: actions/checkout@v4 51 | with: 52 | fetch-depth: 0 53 | 54 | - name: Setup PNPM 55 | uses: pnpm/action-setup@v4 56 | with: 57 | version: ^7.0 58 | 59 | - name: Setup Node.js 60 | uses: actions/setup-node@v4 61 | with: 62 | node-version: 20 63 | cache: "pnpm" 64 | 65 | - name: Install deps 66 | run: pnpm install --no-frozen-lockfile 67 | 68 | - name: Build 69 | run: | 70 | pnpm build 71 | 72 | - name: Upload build result 73 | uses: actions/upload-artifact@v4 74 | with: 75 | name: build-result 76 | path: | 77 | build 78 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v** 7 | 8 | permissions: 9 | contents: write 10 | issues: write 11 | pull-requests: write 12 | 13 | jobs: 14 | release: 15 | runs-on: ubuntu-latest 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GitHub_TOKEN }} 18 | OLD_GA_MEASUREMENT_ID: ${{ secrets.OLD_GA_MEASUREMENT_ID }} 19 | OLD_GA_API_SECRET: ${{ secrets.OLD_GA_API_SECRET }} 20 | NEW_GA_MEASUREMENT_ID: ${{ secrets.NEW_GA_MEASUREMENT_ID }} 21 | NEW_GA_API_SECRET: ${{ secrets.NEW_GA_API_SECRET }} 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | 28 | - name: Setup PNPM 29 | uses: pnpm/action-setup@v4 30 | with: 31 | version: ^7.0 32 | 33 | - name: Setup Node.js 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: 20 37 | cache: "pnpm" 38 | 39 | - name: Install deps 40 | run: pnpm install --no-frozen-lockfile 41 | 42 | - name: Build 43 | run: | 44 | pnpm build 45 | 46 | - name: Release to GitHub 47 | run: | 48 | pnpm release 49 | sleep 1s 50 | 51 | - name: Notify release 52 | uses: apexskier/github-release-commenter@v1 53 | continue-on-error: true 54 | with: 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | comment-template: | 57 | :rocket: _This ticket has been resolved in {release_tag}. See {release_link} for release notes._ 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dot files 2 | .DS_Store 3 | 4 | # Node.js 5 | node_modules 6 | yarn.lock 7 | 8 | # TSC 9 | tsconfig.tsbuildinfo 10 | 11 | # Scaffold 12 | .env 13 | .scaffold 14 | build 15 | logs 16 | dist -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | build 3 | logs 4 | node_modules 5 | package-lock.json 6 | yarn.lock 7 | pnpm-lock.yaml 8 | dist -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "macabeus.vscode-fluent" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // 使用 IntelliSense 了解相关属性。 3 | // 悬停以查看现有属性的描述。 4 | // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Start", 11 | "runtimeExecutable": "npm", 12 | "runtimeArgs": ["run", "start"] 13 | }, 14 | { 15 | "type": "node", 16 | "request": "launch", 17 | "name": "Build", 18 | "runtimeExecutable": "npm", 19 | "runtimeArgs": ["run", "build"] 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnType": false, 3 | "editor.formatOnSave": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "explicit" 6 | }, 7 | "typescript.tsdk": "node_modules/typescript/lib" 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/toolkit.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "appendElement - full": { 3 | "scope": "javascript,typescript", 4 | "prefix": "appendElement", 5 | "body": [ 6 | "appendElement({", 7 | "\ttag: '${1:div}',", 8 | "\tid: '${2:id}',", 9 | "\tnamespace: '${3:html}',", 10 | "\tclassList: ['${4:class}'],", 11 | "\tstyles: {${5:style}: '$6'},", 12 | "\tproperties: {},", 13 | "\tattributes: {},", 14 | "\t[{ '${7:onload}', (e: Event) => $8, ${9:false} }],", 15 | "\tcheckExistanceParent: ${10:HTMLElement},", 16 | "\tignoreIfExists: ${11:true},", 17 | "\tskipIfExists: ${12:true},", 18 | "\tremoveIfExists: ${13:true},", 19 | "\tcustomCheck: (doc: Document, options: ElementOptions) => ${14:true},", 20 | "\tchildren: [$15]", 21 | "}, ${16:container});" 22 | ] 23 | }, 24 | "appendElement - minimum": { 25 | "scope": "javascript,typescript", 26 | "prefix": "appendElement", 27 | "body": "appendElement({ tag: '$1' }, $2);" 28 | }, 29 | "register Notifier": { 30 | "scope": "javascript,typescript", 31 | "prefix": "registerObserver", 32 | "body": [ 33 | "registerObserver({", 34 | "\t notify: (", 35 | "\t\tevent: _ZoteroTypes.Notifier.Event,", 36 | "\t\ttype: _ZoteroTypes.Notifier.Type,", 37 | "\t\tids: string[],", 38 | "\t\textraData: _ZoteroTypes.anyObj", 39 | "\t) => {", 40 | "\t\t$0", 41 | "\t}", 42 | "});" 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Zotero BabelDOC 2 | 3 | Thank you for your interest in contributing to the Zotero BabelDOC plugin! This document provides guidelines and instructions for contributing to this project. 4 | 5 | ## Development Setup 6 | 7 | ### Prerequisites 8 | 9 | - Install a beta version of Zotero: https://www.zotero.org/support/beta_builds 10 | - [Node.js](https://nodejs.org/) (v14 or later recommended) 11 | - [Git](https://git-scm.com/) 12 | 13 | ### Setting Up the Development Environment 14 | 15 | 1. Fork and clone the repository: 16 | 17 | ``` 18 | git clone https://github.com/YOUR-USERNAME/zotero-immersivetranslate.git 19 | cd zotero-immersivetranslate 20 | ``` 21 | 22 | 2. Copy the environment variable file. Modify the commands that starts your installation of the beta Zotero. 23 | 24 | > Create a development profile (Optional) 25 | > Start the beta Zotero with /path/to/zotero -p. Create a new profile and use it as your development profile. Do this only once. 26 | 27 | ``` 28 | cp .env.example .env 29 | vim .env 30 | ``` 31 | 32 | You may need to adjust the Zotero installation path in `package.json` if your Zotero is not installed in the default location. 33 | 34 | 3. Install dependencies with `pnpm install`. 35 | 36 | 4. Start development with auto-reload: 37 | ``` 38 | pnpm start 39 | ``` 40 | 41 | ## Building and Testing 42 | 43 | - To build the plugin: `npm run build` 44 | 45 | ## Submitting Contributions 46 | 47 | 1. Create a new branch for your feature or bugfix: 48 | 49 | ``` 50 | git checkout -b feature/your-feature-name 51 | ``` 52 | 53 | or 54 | 55 | ``` 56 | git checkout -b fix/issue-you-are-fixing 57 | ``` 58 | 59 | 2. Make your changes, following the coding conventions below. 60 | 61 | 3. Test your changes thoroughly. 62 | 63 | 4. Commit your changes with clear, descriptive commit messages: 64 | 65 | ``` 66 | git commit -m "feat: description of the feature" 67 | ``` 68 | 69 | 5. Push your branch to your fork: 70 | 71 | ``` 72 | git push origin feature/your-feature-name 73 | ``` 74 | 75 | 6. Submit a pull request to the main repository. 76 | 77 | ## Code Style and Conventions 78 | 79 | - Follow the existing code style and structure. 80 | - Use descriptive variable and function names. 81 | - Add comments for complex functionality. 82 | - Keep functions small and focused on a single responsibility. 83 | - Write clear commit messages describing what changes you made and why. 84 | 85 | ## Internationalization (i18n) 86 | 87 | When adding or modifying user-facing text: 88 | 89 | 1. Add strings to the appropriate `.ftl` files in the `addon/locale` directory. 90 | 2. Make sure to add translations for all supported languages. 91 | 3. Use the appropriate IDs and structures as shown in existing files. 92 | 93 | ## Reporting Issues 94 | 95 | - Use the [GitHub Issues](https://github.com/immersive-translate/zotero-immersivetranslate/issues) page to report bugs or suggest features. 96 | - Before creating a new issue, please check if a similar issue already exists. 97 | - Provide detailed information about the issue, including: 98 | - Steps to reproduce 99 | - Expected behavior 100 | - Actual behavior 101 | - Screenshots if applicable 102 | - Your environment (Zotero version, OS, etc.) 103 | - If relevant, include the task ID when reporting translation issues 104 | 105 | ## Communication 106 | 107 | - For general discussion and feedback, join the [BabelDOC community](https://immersivetranslate.com/zh-Hans/docs/communities/). 108 | - For specific technical questions, you can open an issue on GitHub. 109 | 110 | Thank you for contributing to make Zotero BabelDOC better! 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zotero Immersive Translate plugin 2 | 3 | [![zotero target version](https://img.shields.io/badge/Zotero-7-green?style=flat-square&logo=zotero&logoColor=CC2936)](https://www.zotero.org) 4 | [![Using Zotero Plugin Template](https://img.shields.io/badge/Using-Zotero%20Plugin%20Template-blue?style=flat-square&logo=github)](https://github.com/windingwind/zotero-plugin-template) 5 | 6 | 这是沉浸式翻译的 Zotero 插件,使用 [BabelDOC](https://github.com/funstory-ai/BabelDOC) 翻译 Zotero 的 PDF 文献。 7 | 8 | > [!NOTE] 9 | > 本插件基于 Zotero 7 开发,不兼容 Zotero 6,请升级至最新版本。 10 | 11 | [在网页中使用 BabelDOC](https://app.immersivetranslate.com/babel-doc/) 12 | 13 | ## 预览 14 | 15 | ![Preview](./screenshots/preview.png) 16 | 17 | ## 下载 18 | 19 | [在 Releases 页面下载](https://github.com/immersive-translate/zotero-immersivetranslate/releases) 20 | 21 | ## 使用 22 | 23 | 1. 安装插件,在[ Releases ](https://github.com/immersive-translate/zotero-immersivetranslate/releases)页面下载最新的`.xpi`文件,在 Zotero 的插件管理页面安装并启用 24 | 25 | ![Install](https://s.immersivetranslate.com/assets/r2-uploads/zotero_plugin_immersive_translate_babeldoc_20250507-daw1aSQgtbU9D3NZ.gif) 26 | 27 | 2. 在 [沉浸式翻译](https://immersivetranslate.com/profile) 官网个人主页获取 Zotero 授权码 28 | 29 | ![Get Zotero Auth Key](./screenshots/get-zotero-auth-key.png) 30 | 31 | 3. 在插件的设置页面,粘贴你的 Zotero 授权码,点击 `测试` 按钮,如果显示 `测试成功`,则说明配置成功 32 | 33 | ![Set Zotero Auth Key](./screenshots/set-zotero-auth-key.png) 34 | 35 | 4. 在设置页面配置目标语言、翻译模型、翻译模式等等。 36 | 37 | 5. 在 Zotero 的文献管理页面,右键文件,出现右键菜单,选择 `使用沉浸式翻译` 38 | 39 | ![Translate](./screenshots/right_menu.png) 40 | 41 | 6. 在弹出的窗口中二次确认,之后会出现任务管理窗口,显示翻译任务的进度。 42 | 43 | ![Task Manager](./screenshots/task-modal.png) 44 | 45 | ## 翻译任务管理 46 | 47 | 在任务管理窗口,可以查看翻译任务的进度及结果。 48 | 49 | ![Task Manager](./screenshots/task-modal.png) 50 | 51 | 选中某一项任务,点击窗口底部的按钮,进行对应操作。 52 | 53 | ### 取消 54 | 55 | 目前仅支持取消未开始的任务。 56 | 57 | ### 复制任务 ID 58 | 59 | 当翻译任务开始后,会产生一个任务 ID,可以点击 `复制任务 ID` 按钮,复制任务 ID。 60 | 61 | 当翻译失败,或出现错误,可以复制任务 ID,进行反馈。 62 | 63 | ### 查看翻译结果 64 | 65 | 当翻译任务完成后,可以点击 `查看翻译结果` 按钮,直接用阅读器打开翻译后的 PDF。 66 | 67 | ## 翻译任务的恢复 68 | 69 | 当翻译开始后,如果关闭 Zotero 客户端,会存储未完成的翻译任务。 70 | 71 | 再次打开 Zotero 客户端,插件会自动恢复未完成的翻译任务,已完成的任务不会存储。 72 | 73 | ## 快捷键 74 | 75 | - `Shift+A` 翻译选中的文献 76 | - `Shift+T` 打开任务管理窗口 77 | 78 | ## FAQ 79 | 80 | ### 为什么我配置了 Zotero 授权码,但是还是不能使用? 81 | 82 | 请检查你的 Zotero 授权码是否正确,是否过期。 83 | 84 | 目前 Zotero 仅支持沉浸式翻译 Pro 用户使用。 85 | 86 | ### 如果翻译失败了,怎么办? 87 | 88 | 可以复制任务 ID,进行反馈。 89 | 90 | ### 如果翻译结果不满意,怎么办? 91 | 92 | 可以复制任务 ID,进行反馈。 93 | 94 | ## 不小心关闭了任务管理窗口,怎么办? 95 | 96 | 可以在 Zotero 的 `查看` 菜单下,点击 `查看沉浸式翻译任务`,打开任务管理窗口。 97 | 98 | ## 反馈 99 | 100 | 如果是插件功能问题,可以在 [Issues](https://github.com/immersive-translate/zotero-immersivetranslate/issues) 中反馈。 101 | 102 | 如果是翻译结果问题,可以 [加入 BabelDOC 的内测群](https://immersivetranslate.com/zh-Hans/docs/communities/),发生任务 ID 进行反馈。 103 | 104 | ## 贡献 105 | 106 | 请查看 [贡献指南](CONTRIBUTING.md)。 107 | -------------------------------------------------------------------------------- /addon/bootstrap.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | 3 | /** 4 | * Most of this code is from Zotero team's official Make It Red example[1] 5 | * or the Zotero 7 documentation[2]. 6 | * [1] https://github.com/zotero/make-it-red 7 | * [2] https://www.zotero.org/support/dev/zotero_7_for_developers 8 | */ 9 | 10 | var chromeHandle; 11 | 12 | function install(data, reason) {} 13 | 14 | async function startup({ id, version, resourceURI, rootURI }, reason) { 15 | await Zotero.initializationPromise; 16 | 17 | // String 'rootURI' introduced in Zotero 7 18 | if (!rootURI) { 19 | rootURI = resourceURI.spec; 20 | } 21 | 22 | var aomStartup = Components.classes[ 23 | "@mozilla.org/addons/addon-manager-startup;1" 24 | ].getService(Components.interfaces.amIAddonManagerStartup); 25 | var manifestURI = Services.io.newURI(rootURI + "manifest.json"); 26 | chromeHandle = aomStartup.registerChrome(manifestURI, [ 27 | ["content", "__addonRef__", rootURI + "content/"], 28 | ]); 29 | 30 | /** 31 | * Global variables for plugin code. 32 | * The `_globalThis` is the global root variable of the plugin sandbox environment 33 | * and all child variables assigned to it is globally accessible. 34 | * See `src/index.ts` for details. 35 | */ 36 | const ctx = { 37 | rootURI, 38 | }; 39 | ctx._globalThis = ctx; 40 | 41 | Services.scriptloader.loadSubScript( 42 | `${rootURI}/content/scripts/__addonRef__.js`, 43 | ctx, 44 | ); 45 | Zotero.__addonInstance__.hooks.onStartup(); 46 | } 47 | 48 | async function onMainWindowLoad({ window }, reason) { 49 | Zotero.__addonInstance__?.hooks.onMainWindowLoad(window); 50 | } 51 | 52 | async function onMainWindowUnload({ window }, reason) { 53 | Zotero.__addonInstance__?.hooks.onMainWindowUnload(window); 54 | } 55 | 56 | function shutdown({ id, version, resourceURI, rootURI }, reason) { 57 | if (reason === APP_SHUTDOWN) { 58 | return; 59 | } 60 | 61 | if (typeof Zotero === "undefined") { 62 | Zotero = Components.classes["@zotero.org/Zotero;1"].getService( 63 | Components.interfaces.nsISupports, 64 | ).wrappedJSObject; 65 | } 66 | Zotero.__addonInstance__?.hooks.onShutdown(); 67 | 68 | Cc["@mozilla.org/intl/stringbundle;1"] 69 | .getService(Components.interfaces.nsIStringBundleService) 70 | .flushBundles(); 71 | 72 | Cu.unload(`${rootURI}/content/scripts/__addonRef__.js`); 73 | 74 | if (chromeHandle) { 75 | chromeHandle.destruct(); 76 | chromeHandle = null; 77 | } 78 | } 79 | 80 | function uninstall(data, reason) {} 81 | -------------------------------------------------------------------------------- /addon/content/icons/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/immersive-translate/zotero-immersivetranslate/073ff6bffdd3a3988352b8f409bdb642a89c6086/addon/content/icons/favicon.png -------------------------------------------------------------------------------- /addon/content/icons/favicon@0.5x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/immersive-translate/zotero-immersivetranslate/073ff6bffdd3a3988352b8f409bdb642a89c6086/addon/content/icons/favicon@0.5x.png -------------------------------------------------------------------------------- /addon/content/icons/favicon@16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/immersive-translate/zotero-immersivetranslate/073ff6bffdd3a3988352b8f409bdb642a89c6086/addon/content/icons/favicon@16x16.png -------------------------------------------------------------------------------- /addon/content/icons/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /addon/content/preferences.xhtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 17 | 21 | 22 | 23 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 56 | 57 | 58 | 64 | 65 | 66 |

67 | 68 | 69 | 75 | 76 | 77 |

78 | 79 | 80 | 81 | 87 | 88 | 89 |

90 | 91 | 92 | 93 | 94 | 95 | 96 | 102 | 103 | 104 |

105 | 106 | 107 |

108 | 109 | 110 | 111 | 112 | 113 | 117 | 118 | 125 | 131 | 132 | 133 | 134 | -------------------------------------------------------------------------------- /addon/content/taskManager.xhtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 44 | 45 | 54 | 97 | 98 | 99 |

100 |
101 |
102 |
103 |
104 | 116 | 117 | 118 | -------------------------------------------------------------------------------- /addon/content/zoteroPane.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/immersive-translate/zotero-immersivetranslate/073ff6bffdd3a3988352b8f409bdb642a89c6086/addon/content/zoteroPane.css -------------------------------------------------------------------------------- /addon/locale/en-US/addon.ftl: -------------------------------------------------------------------------------- 1 | startup-begin = Addon is loading 2 | startup-finish = Addon is ready 3 | menuitem-translate = Translate with ImmersiveTranslate(Shift+A) 4 | menuView-tasks = View Immersive translate tasks(Shift+T) 5 | pref-test-success = Test successfully 6 | pref-test-failed = Test failed 7 | pref-test-failed-description = Please check your authkey 8 | 9 | prefs-title = Immersive Translate 10 | item-filed-status = Translation Status 11 | 12 | translateMode-all = Bilingual mode & Translation only 13 | translateMode-dua = Bilingual mode 14 | translateMode-translation = Translation only 15 | 16 | translateModel-deepseek = DeepSeek 17 | translateModel-doubao = Doubao 18 | translateModel-glm-4-plus = GLM-4-Plus 19 | translateModel-OpenAI = OpenAI 20 | translateModel-Gemini = Gemini 21 | translateModel-glm-4-flash = GLM-4-Flash 22 | 23 | confirm-title = Translate Confirm 24 | confirm-options = Options 25 | confirm-enable-compatibility = Enable compatibility mode 26 | confirm-enable-compatibility-description = Enabling this will improve PDF compatibility, but will increase the output file size. 27 | confirm-enable-ocr-workaround = Enable OCR temporary solution 28 | confirm-enable-ocr-workaround-description = When your scanned/image-based PDF file has undergone OCR processing and is in black text on a white background, you can try enabling the OCR version of the temporary solution. This solution will add white rectangular blocks below the translated text to cover the original content. 29 | confirm-translate-model = Translation model 30 | confirm-translate-mode = Translation mode 31 | confirm-target-language = Target language 32 | confirm-yes = Confirm 33 | confirm-cancel = Cancel 34 | 35 | task-no-pdf = No PDF found for translation 36 | 37 | column-item = Item 38 | column-attachment = Attachment 39 | column-target-language = Target Language 40 | column-translate-model = Translation Model 41 | column-translate-mode = Translation Mode 42 | column-pdfId = Task ID 43 | column-status = Task Status 44 | column-stage = Current Stage 45 | column-progress = Translate Progress 46 | column-error = Error Message 47 | 48 | task-uncomplete = Task not completed 49 | task-select-tip = Please select a task 50 | task-copy-success = Task ID Copied! 51 | task-cancel-success = Task canceled successfully 52 | task-cancel-tip = Only unstarted tasks can be canceled 53 | 54 | task-status-queued = Pending start 55 | task-status-uploading = Uploading 56 | task-status-translating = Translating 57 | task-status-success = Success 58 | task-status-failed = Failed 59 | task-status-canceled = Canceled 60 | 61 | task-stage-queued = Queuing 62 | task-stage-uploading = Uploading PDF 63 | task-stage-parse-pdf = Parsing PDF 64 | task-stage-DetectScannedFile= Checking if it is a scanned version 65 | task-stage-ParseLayout= Parsing page layout 66 | task-stage-ParseParagraphs= Parsing paragraphs 67 | task-stage-ParseFormulasAndStyles = Parsing formulas & styles 68 | task-stage-RemoveCharDescent = Correcting character offset 69 | task-stage-TranslateParagraphs = Translating paragraphs 70 | task-stage-Typesetting = Typesetting 71 | task-stage-AddFonts = Adding fonts 72 | task-stage-GenerateDrawingInstructions = Exporting PDF 73 | task-stage-SubsetFont = Subsetting font 74 | task-stage-SavePDF = Generating PDF 75 | task-stage-prepareFileDownload = Processing file 76 | task-stage-ParseTable = Parsing table 77 | task-stage-WaitingInLine = Queuing 78 | task-stage-CreateTask = Creating task 79 | task-stage-downloading = Downloading 80 | task-stage-completed = Translate completed 81 | 82 | task-retry-success = Task retry queued successfully 83 | task-retry-tip = Only failed tasks can be retried 84 | -------------------------------------------------------------------------------- /addon/locale/en-US/mainWindow.ftl: -------------------------------------------------------------------------------- 1 | item-section-babeldoc-info = 2 | .label = BabelDOC Information 3 | item-section-babeldoc-info-tooltip = 4 | .tooltiptext = BabelDOC Information 5 | item-info-status = BabelDOC Translation Status 6 | -------------------------------------------------------------------------------- /addon/locale/en-US/preferences.ftl: -------------------------------------------------------------------------------- 1 | pref-title = General 2 | pref-enable-autoTranslate = 3 | .label = Enable automatic translation 4 | pref-enable-compatibility = 5 | .label = Enable compatibility mode 6 | pref-enable-compatibility-description = Enabling this will improve PDF compatibility, but will increase the output file size. 7 | pref-enable-ocr-workaround = 8 | .label = Enable OCR temporary workaround 9 | pref-enable-ocr-workaround-description = When your scanned/image-based PDF file has undergone OCR processing and is in black text on a white background, you can try enabling the OCR version of the temporary solution. This solution will add white rectangular blocks below the translated text to cover the original content. 10 | pref-authkey = Enter authorization key 11 | pref-translate-model = Translation model 12 | pref-translate-mode = Translation mode 13 | pref-target-language = Target language 14 | pref-test-button = Test 15 | pref-enable-autoOpenPDF = 16 | .label = Automatically open translated PDF 17 | pref-enable-autoTranslate-description = After enabling, newly added PDF files will be automatically translated. PDF files that cannot be parsed into Items are not currently supported for automatic translation. 18 | pref-imt-site = Get your authorization key from your personal page on Immersive Translate website 19 | pref-shortcuts-settings = Shortcuts 20 | pref-enable-shortcuts = 21 | .label = Enable shortcuts 22 | pref-enable-shortcuts-description-1 = Translate selected items 23 | pref-enable-shortcuts-description-2 = Open task manager 24 | 25 | pref-translate-settings = Translation 26 | 27 | pref-about = About 28 | 29 | pref-about-feedback = 30 | .value = GitHub 31 | pref-about-docs = 32 | .value = Documentation 33 | pref-about-version = 34 | .value = { $name } version { $version } Build { $time } 35 | -------------------------------------------------------------------------------- /addon/locale/en-US/taskManager.ftl: -------------------------------------------------------------------------------- 1 | taskManager-title = Immersive Translation Tasks 2 | refresh = 3 | .label = Refresh 4 | .tooltiptext = Refresh translation task list 5 | copy-pdf-id = 6 | .label = Copy Task ID 7 | .tooltiptext = Copy task ID for feedback or reporting issues 8 | cancel = 9 | .label = Cancel 10 | .tooltiptext = Cancel pending tasks 11 | view-pdf = 12 | .label = View Translation Result 13 | .tooltiptext = Open the translated PDF 14 | retry = 15 | .label = Retry 16 | .tooltiptext = Retry when failed 17 | feedback = 18 | .label = Issue Feedback 19 | .tooltiptext = Feedback current task issue -------------------------------------------------------------------------------- /addon/locale/zh-CN/addon.ftl: -------------------------------------------------------------------------------- 1 | startup-begin = 插件加载中 2 | startup-finish = 插件已就绪 3 | menuitem-translate = 使用沉浸式翻译(Shift+A) 4 | menuView-tasks = 查看沉浸式翻译任务(Shift+T) 5 | pref-test-success = 测试成功 6 | pref-test-failed = 测试失败 7 | pref-test-failed-description = 请检查授权码是否正确 8 | 9 | prefs-title = 沉浸式翻译 10 | item-filed-status = 翻译状态 11 | 12 | translateMode-all = 双语模式 & 译文模式 13 | translateMode-dua = 双语模式 14 | translateMode-translation = 仅译文 15 | 16 | translateModel-deepseek = DeepSeek 17 | translateModel-doubao = 豆包 18 | translateModel-glm-4-plus = 智谱 4 Plus 19 | translateModel-OpenAI = OpenAI 20 | translateModel-Gemini = Gemini 21 | translateModel-glm-4-flash = 智谱 4 Flash 22 | 23 | confirm-title = 翻译确认 24 | confirm-options = 选项 25 | confirm-enable-compatibility = 是否启用兼容模式 26 | confirm-enable-compatibility-description = 启用后将会改善 PDF 兼容性,但是会增大输出文件大小 27 | confirm-enable-ocr-workaround = 是否启用 OCR 临时解决方案 28 | confirm-enable-ocr-workaround-description = 当您的扫描/图片版 PDF 文件已进行 OCR 处理,且为白底黑字时,可以尝试启用 OCR 版的临时解决方案。该方案将在译文下方添加白色矩形块,以覆盖原文内容。 29 | confirm-translate-model = 翻译模型 30 | confirm-translate-mode = 翻译模式 31 | confirm-target-language = 目标语言 32 | confirm-yes = 确认 33 | confirm-cancel = 取消 34 | 35 | task-no-pdf = 没有找到可以翻译的 PDF 36 | 37 | column-item = 条目 38 | column-attachment = 附件 39 | column-target-language = 目标语言 40 | column-translate-model = 翻译模型 41 | column-translate-mode = 翻译模式 42 | column-pdfId = 任务 ID 43 | column-status = 任务状态 44 | column-stage = 当前阶段 45 | column-progress = 翻译进度 46 | column-error = 错误信息 47 | column-resultAttachmentId = 结果附件 ID 48 | 49 | task-uncomplete = 任务未完成 50 | task-select-tip = 请选择一个任务 51 | task-copy-success = 复制任务 ID 成功! 52 | task-cancel-success = 取消任务成功 53 | task-cancel-tip = 只能取消未开始的任务 54 | 55 | task-status-queued = 未开始 56 | task-status-uploading = 上传中 57 | task-status-translating = 翻译中 58 | task-status-success = 成功 59 | task-status-failed = 失败 60 | task-status-canceled = 已取消 61 | 62 | task-stage-queued = 排队中 63 | task-stage-uploading = 正在上传 PDF 64 | task-stage-parse-pdf = 正在解析 PDF 65 | task-stage-DetectScannedFile= 正在检测是否为扫描版 66 | task-stage-ParseLayout= 正在解析页面布局 67 | task-stage-ParseParagraphs= 正在解析段落 68 | task-stage-ParseFormulasAndStyles = 正在解析公式&样式 69 | task-stage-RemoveCharDescent = 正在修正字符偏移量 70 | task-stage-TranslateParagraphs = 正在翻译段落 71 | task-stage-Typesetting = 正在排版 72 | task-stage-AddFonts = 正在添加字体 73 | task-stage-GenerateDrawingInstructions = 正在导出 PDF 页面 74 | task-stage-SubsetFont = 正在子集化字体 75 | task-stage-SavePDF = 正在生成 PDF 文件 76 | task-stage-prepareFileDownload = 正在处理文件 77 | task-stage-ParseTable = 正在解析表格 78 | task-stage-WaitingInLine = 正在排队 79 | task-stage-CreateTask = 正在创建任务 80 | task-stage-downloading = 下载中 81 | task-stage-completed = 翻译完成 82 | 83 | task-retry-success = 重试任务已成功加入队列 84 | task-retry-tip = 只能重试失败的任务 85 | -------------------------------------------------------------------------------- /addon/locale/zh-CN/mainWindow.ftl: -------------------------------------------------------------------------------- 1 | item-section-babeldoc-info = 2 | .label = BabelDOC 信息 3 | item-section-babeldoc-info-tooltip = 4 | .tooltiptext = BabelDOC 信息 5 | item-info-status = BabelDOC 翻译状态 6 | -------------------------------------------------------------------------------- /addon/locale/zh-CN/preferences.ftl: -------------------------------------------------------------------------------- 1 | pref-title = 通用 2 | pref-enable-autoTranslate = 3 | .label = 是否启用自动翻译 4 | pref-enable-compatibility = 5 | .label = 启用兼容模式 6 | pref-enable-compatibility-description = 启用后将会改善 PDF 兼容性,但是会增大输出文件大小 7 | pref-enable-ocr-workaround = 8 | .label = 启用 OCR 临时解决方案 9 | pref-enable-ocr-workaround-description = 当您的扫描/图片版 PDF 文件已进行 OCR 处理,且为白底黑字时,可以尝试启用 OCR 版的临时解决方案。该方案将在译文下方添加白色矩形块,以覆盖原文内容。 10 | pref-authkey = 输入授权码 11 | pref-translate-model = 翻译模型 12 | pref-translate-mode = 翻译模式 13 | pref-target-language = 目标语言 14 | pref-test-button = 测试 15 | pref-enable-autoOpenPDF = 16 | .label = 自动打开翻译后的 PDF 17 | pref-enable-autoTranslate-description = 启用后将会自动翻译新添加的 PDF 文件,无法解析为条目的 PDF 文件暂不支持自动翻译 18 | pref-shortcuts-settings = 快捷键 19 | pref-enable-shortcuts = 20 | .label = 启用快捷键 21 | pref-enable-shortcuts-description-1 = Shift + A 翻译所选条目 22 | pref-enable-shortcuts-description-2 = Shift + T 打开翻译任务管理器 23 | 24 | pref-imt-site = 在沉浸式翻译官网个人主页获取授权码 25 | 26 | pref-translate-settings = 翻译 27 | 28 | pref-about = 关于 29 | 30 | pref-about-feedback = 31 | .value = GitHub 32 | pref-about-docs = 33 | .value = 文档 34 | pref-about-version = 35 | .value = { $name } 版本 { $version } Build { $time } 36 | -------------------------------------------------------------------------------- /addon/locale/zh-CN/taskManager.ftl: -------------------------------------------------------------------------------- 1 | taskManager-title = 沉浸式翻译任务 2 | refresh = 3 | .label = 刷新 4 | .tooltiptext = 刷新翻译任务列表 5 | copy-pdf-id = 6 | .label = 复制任务 ID 7 | .tooltiptext = 复制任务 ID,反馈问题 8 | cancel = 9 | .label = 取消 10 | .tooltiptext = 取消未开始的任务 11 | view-pdf = 12 | .label = 查看翻译结果 13 | .tooltiptext = 打开翻译完成的 PDF 14 | retry = 15 | .label = 重试 16 | .tooltiptext = 失败后重试 17 | feedback = 18 | .label = 问题反馈 19 | .tooltiptext = 反馈当前任务的问题 -------------------------------------------------------------------------------- /addon/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "__addonName__", 4 | "version": "__buildVersion__", 5 | "description": "__description__", 6 | "homepage_url": "__homepage__", 7 | "author": "__author__", 8 | "icons": { 9 | "48": "content/icons/favicon@0.5x.png", 10 | "96": "content/icons/favicon.png" 11 | }, 12 | "applications": { 13 | "zotero": { 14 | "id": "__addonID__", 15 | "update_url": "__updateURL__", 16 | "strict_min_version": "6.999", 17 | "strict_max_version": "7.*" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /addon/prefs.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | pref("authkey", ""); 3 | pref("targetLanguage", "zh-CN"); 4 | pref("translateMode", "dual"); 5 | pref("translateModel", "deepseek"); 6 | pref("enhanceCompatibility", false); 7 | pref("autoTranslate", false); 8 | pref("autoOpenPDF", true); 9 | pref("ocrWorkaround", false); 10 | pref("fakeUserId", ""); 11 | pref("enableShortcuts", true); 12 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check Let TS check this config file 2 | 3 | import eslint from "@eslint/js"; 4 | import tseslint from "typescript-eslint"; 5 | 6 | export default tseslint.config( 7 | { 8 | ignores: [ 9 | "dist/**", 10 | "build/**", 11 | ".scaffold/**", 12 | "node_modules/**", 13 | "scripts/", 14 | ], 15 | }, 16 | { 17 | extends: [eslint.configs.recommended, ...tseslint.configs.recommended], 18 | rules: { 19 | "no-restricted-globals": [ 20 | "error", 21 | { message: "Use `Zotero.getMainWindow()` instead.", name: "window" }, 22 | { 23 | message: "Use `Zotero.getMainWindow().document` instead.", 24 | name: "document", 25 | }, 26 | { 27 | message: "Use `Zotero.getActiveZoteroPane()` instead.", 28 | name: "ZoteroPane", 29 | }, 30 | "Zotero_Tabs", 31 | ], 32 | 33 | "@typescript-eslint/ban-ts-comment": [ 34 | "warn", 35 | { 36 | "ts-expect-error": "allow-with-description", 37 | "ts-ignore": "allow-with-description", 38 | "ts-nocheck": "allow-with-description", 39 | "ts-check": "allow-with-description", 40 | }, 41 | ], 42 | "@typescript-eslint/no-unused-vars": "off", 43 | "@typescript-eslint/no-explicit-any": [ 44 | "off", 45 | { 46 | ignoreRestArgs: true, 47 | }, 48 | ], 49 | "@typescript-eslint/no-non-null-assertion": "off", 50 | }, 51 | }, 52 | ); 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zotero-immersivetranslate", 3 | "type": "module", 4 | "version": "0.0.8", 5 | "description": "Zotero BabelDOC plugin, for Immersive Translate Pro members.", 6 | "keywords": [ 7 | "zotero", 8 | "babeldoc", 9 | "plugin", 10 | "pdf-translation", 11 | "translation" 12 | ], 13 | "config": { 14 | "addonName": "Immersive Translate 沉浸式翻译", 15 | "addonID": "zotero@immersivetranslate.com", 16 | "addonRef": "immersivetranslate", 17 | "addonInstance": "ImmersiveTranslate", 18 | "prefsPrefix": "extensions.zotero.immersivetranslate", 19 | "xpiName": "immersive-translate" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/immersive-translate/zotero-immersivetranslate.git" 24 | }, 25 | "author": "immersive-translate", 26 | "bugs": { 27 | "url": "https://github.com/immersive-translate/zotero-immersivetranslate/issues" 28 | }, 29 | "homepage": "https://github.com/immersive-translate/zotero-immersivetranslate#readme", 30 | "license": "AGPL-3.0-or-later", 31 | "scripts": { 32 | "start": "zotero-plugin serve", 33 | "build": "zotero-plugin build && tsc --noEmit", 34 | "build:dev": "zotero-plugin build --dev && tsc --noEmit", 35 | "lint:check": "prettier --check . && eslint .", 36 | "lint:fix": "prettier --write . && eslint . --fix", 37 | "release": "zotero-plugin release", 38 | "test": "echo \"Error: no test specified\" && exit 1", 39 | "update-deps": "npm update --save" 40 | }, 41 | "dependencies": { 42 | "zotero-plugin-toolkit": "^5.0.0-0" 43 | }, 44 | "devDependencies": { 45 | "@eslint/js": "^9.23.0", 46 | "@types/bluebird": "^3.5.42", 47 | "@types/node": "^22.13.13", 48 | "@types/react": "^19.1.3", 49 | "epubjs": "^0.3.93", 50 | "eslint": "^9.23.0", 51 | "pdfjs-dist": "^5.2.133", 52 | "prettier": "^3.5.3", 53 | "typescript": "^5.8.2", 54 | "typescript-eslint": "^8.28.0", 55 | "zotero-plugin-scaffold": "^0.4.1", 56 | "zotero-types": "^4.0.0-beta.3" 57 | }, 58 | "prettier": { 59 | "printWidth": 80, 60 | "tabWidth": 2, 61 | "endOfLine": "lf", 62 | "overrides": [ 63 | { 64 | "files": [ 65 | "*.xhtml" 66 | ], 67 | "options": { 68 | "htmlWhitespaceSensitivity": "css" 69 | } 70 | } 71 | ] 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /screenshots/get-zotero-auth-key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/immersive-translate/zotero-immersivetranslate/073ff6bffdd3a3988352b8f409bdb642a89c6086/screenshots/get-zotero-auth-key.png -------------------------------------------------------------------------------- /screenshots/install.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/immersive-translate/zotero-immersivetranslate/073ff6bffdd3a3988352b8f409bdb642a89c6086/screenshots/install.png -------------------------------------------------------------------------------- /screenshots/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/immersive-translate/zotero-immersivetranslate/073ff6bffdd3a3988352b8f409bdb642a89c6086/screenshots/preview.png -------------------------------------------------------------------------------- /screenshots/right_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/immersive-translate/zotero-immersivetranslate/073ff6bffdd3a3988352b8f409bdb642a89c6086/screenshots/right_menu.png -------------------------------------------------------------------------------- /screenshots/set-zotero-auth-key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/immersive-translate/zotero-immersivetranslate/073ff6bffdd3a3988352b8f409bdb642a89c6086/screenshots/set-zotero-auth-key.png -------------------------------------------------------------------------------- /screenshots/task-modal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/immersive-translate/zotero-immersivetranslate/073ff6bffdd3a3988352b8f409bdb642a89c6086/screenshots/task-modal.png -------------------------------------------------------------------------------- /src/addon.ts: -------------------------------------------------------------------------------- 1 | import { config } from "../package.json"; 2 | import { 3 | DialogHelper, 4 | VirtualizedTableHelper, 5 | LargePrefHelper, 6 | } from "zotero-plugin-toolkit"; 7 | import {} from "zotero-plugin-toolkit"; 8 | import hooks from "./hooks"; 9 | import { createZToolkit } from "./utils/ztoolkit"; 10 | import api from "./api"; 11 | import type { TranslationTaskData } from "./types"; 12 | 13 | class Addon { 14 | public data: { 15 | alive: boolean; 16 | config: typeof config; 17 | // Env type, see build.js 18 | env: "development" | "production"; 19 | ztoolkit: ZToolkit; 20 | locale?: { 21 | current: any; 22 | }; 23 | prefs?: { 24 | window: Window; 25 | }; 26 | dialog?: DialogHelper; 27 | task: { 28 | data?: LargePrefHelper; 29 | window?: Window; 30 | tableHelper?: VirtualizedTableHelper; 31 | translationGlobalQueue: TranslationTaskData[]; 32 | translationTaskList: TranslationTaskData[]; 33 | isQueueProcessing: boolean; 34 | }; 35 | }; 36 | // Lifecycle hooks 37 | public hooks: typeof hooks; 38 | // APIs 39 | public api: typeof api; 40 | 41 | constructor() { 42 | this.data = { 43 | alive: true, 44 | config, 45 | env: __env__, 46 | ztoolkit: createZToolkit(), 47 | task: { 48 | window: undefined, 49 | tableHelper: undefined, 50 | translationGlobalQueue: [], 51 | translationTaskList: [], 52 | isQueueProcessing: false, 53 | }, 54 | }; 55 | this.hooks = hooks; 56 | this.api = api; 57 | } 58 | } 59 | 60 | export default Addon; 61 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { request } from "./request"; 2 | 3 | export function checkAuthKey(params: { apiKey: string }): Promise { 4 | return request({ 5 | url: `/check-key`, 6 | params, 7 | }); 8 | } 9 | 10 | export type UploadUrlResponse = { 11 | result: { 12 | objectKey: string; 13 | preSignedURL: string; 14 | imgUrl: string; 15 | }; 16 | id: number; 17 | exception: string; 18 | status: string; 19 | isCanceled: boolean; 20 | isCompleted: boolean; 21 | isCompletedSuccessfully: boolean; 22 | creationOptions: number; 23 | asyncState: null; 24 | isFaulted: boolean; 25 | }; 26 | 27 | export function getPdfUploadUrl(): Promise { 28 | return request({ 29 | url: `/pdf-upload-url`, 30 | retries: 3, 31 | }); 32 | } 33 | 34 | type UploadPdfRequest = { 35 | uploadUrl: string; 36 | file: File; 37 | }; 38 | 39 | export function uploadPdf(data: UploadPdfRequest) { 40 | return request({ 41 | url: data.uploadUrl, 42 | method: "PUT", 43 | body: data.file, 44 | responseType: "arraybuffer", 45 | retries: 3, 46 | headers: { 47 | "Content-Type": "application/pdf", 48 | }, 49 | }); 50 | } 51 | 52 | export function downloadPdf(url: string) { 53 | return request({ 54 | url, 55 | method: "GET", 56 | retries: 3, 57 | responseType: "arraybuffer", 58 | }); 59 | } 60 | 61 | type CreateTranslateTaskRequest = { 62 | objectKey: string; 63 | pdfOptions: { 64 | conversion_formats: { 65 | html: boolean; 66 | }; 67 | }; 68 | fileName: string; 69 | targetLanguage: string; 70 | requestModel: string; 71 | enhance_compatibility: boolean; 72 | turnstileResponse: string; 73 | OCRWorkaround: boolean; 74 | }; 75 | 76 | export function createTranslateTask( 77 | data: CreateTranslateTaskRequest, 78 | ): Promise { 79 | return request({ 80 | url: "/backend-babel-pdf", 81 | method: "POST", 82 | body: data, 83 | retries: 3, 84 | }); 85 | } 86 | 87 | type GetTranslatePdfCountRequest = { 88 | objectKey: string; 89 | }; 90 | 91 | export function getTranslatePdfCount( 92 | data: GetTranslatePdfCountRequest, 93 | ): Promise { 94 | return request({ 95 | url: `/pdf-count`, 96 | body: data, 97 | }); 98 | } 99 | 100 | type GetTranslateStatusRequest = { 101 | pdfId: string; 102 | }; 103 | 104 | type GetTranslateStatusResponse = { 105 | overall_progress: number; 106 | currentStageName: string; 107 | status: string; 108 | message: string; 109 | num_pages: number; 110 | }; 111 | 112 | export function getTranslateStatus( 113 | data: GetTranslateStatusRequest, 114 | ): Promise { 115 | return request({ 116 | url: `/pdf/${data.pdfId}/process`, 117 | retries: 10, 118 | }); 119 | } 120 | 121 | type GetTranslatePdfResultRequest = { 122 | pdfId: string; 123 | }; 124 | 125 | type GetTranslatePdfResultResponse = { 126 | translationOnlyPdfOssUrl: string; 127 | translationDualPdfOssUrl: string; 128 | waterMask: boolean; 129 | monoFileUrl: string; 130 | }; 131 | 132 | export function getTranslatePdfResult( 133 | data: GetTranslatePdfResultRequest, 134 | ): Promise { 135 | return request({ 136 | url: `/pdf/${data.pdfId}/temp-url`, 137 | retries: 3, 138 | }); 139 | } 140 | 141 | type GetRecordListRequest = { 142 | page?: number; 143 | pageSize?: number; 144 | }; 145 | 146 | type GetRecordListResponse = { 147 | total: number; 148 | list: { 149 | createTime: string; 150 | fileName: string; 151 | pdfStatus: string; 152 | recordId: string; 153 | pageCount: number; 154 | consumed: boolean; 155 | backendStatus: string; 156 | isWaterMark: boolean; 157 | sourceLanguage?: string; 158 | targetLanguage: string; 159 | errMsg?: string; 160 | detailStatus?: string; 161 | }[]; 162 | }; 163 | 164 | export function getRecordList( 165 | params: GetRecordListRequest, 166 | ): Promise { 167 | return request({ 168 | url: `/pdf/record-list`, 169 | params, 170 | }); 171 | } 172 | 173 | export default { 174 | checkAuthKey, 175 | getPdfUploadUrl, 176 | createTranslateTask, 177 | getTranslatePdfCount, 178 | getTranslateStatus, 179 | getTranslatePdfResult, 180 | getRecordList, 181 | uploadPdf, 182 | downloadPdf, 183 | }; 184 | -------------------------------------------------------------------------------- /src/api/request.ts: -------------------------------------------------------------------------------- 1 | import { getPref } from "../utils/prefs"; 2 | import { BASE_URL_TEST, BASE_URL } from "../utils/const"; 3 | 4 | export async function request({ 5 | url, 6 | method = "GET", 7 | body = null, 8 | params = {}, 9 | headers = {}, 10 | responseType = "json", 11 | fullFillOnError = false, 12 | retries = 0, 13 | retryDelay = 1000, 14 | }: { 15 | url: string; 16 | method?: string; 17 | body?: any; 18 | params?: any; 19 | headers?: any; 20 | responseType?: "json" | "text" | "blob" | "arraybuffer"; 21 | fullFillOnError?: boolean | number[]; 22 | retries?: number; 23 | retryDelay?: number; 24 | }) { 25 | let retryCount = 0; 26 | let lastError: any; 27 | 28 | // Helper function to parse response based on responseType 29 | async function parseResponse( 30 | response: Response, 31 | type: "json" | "text" | "blob" | "arraybuffer" = "json", 32 | ) { 33 | try { 34 | let data: any; 35 | if (type === "json") { 36 | data = await response.json(); 37 | } else if (type === "text") { 38 | data = await response.text(); 39 | } else if (type === "blob") { 40 | data = await response.blob(); 41 | } else if (type === "arraybuffer") { 42 | data = await response.arrayBuffer(); 43 | } 44 | return { success: true, data }; 45 | } catch (error: any) { 46 | return { 47 | success: false, 48 | error: new Error( 49 | `Failed to parse response as ${type}: ${error.message}`, 50 | ), 51 | }; 52 | } 53 | } 54 | 55 | // Helper function to extract error message from response data 56 | function extractErrorMessage(data: any): string { 57 | if (!data || typeof data !== "object") { 58 | return "Unknown error"; 59 | } 60 | 61 | if ("message" in data) { 62 | return String(data.message); 63 | } else if ("error" in data) { 64 | return String(data.error); 65 | } else if ("code" in data) { 66 | return `Error code: ${data.code}`; 67 | } 68 | 69 | return "Unknown error"; 70 | } 71 | 72 | while (retryCount <= retries) { 73 | try { 74 | const URL = addon.data.env === "development" ? BASE_URL_TEST : BASE_URL; 75 | const isCustomUrl = url.startsWith("http"); 76 | const queryParams = new URLSearchParams(params); 77 | 78 | // For GET requests, always append params to URL. For other methods, only if not custom URL 79 | let _url; 80 | if (method === "GET" || !isCustomUrl) { 81 | _url = isCustomUrl 82 | ? `${url}${params && Object.keys(params).length > 0 ? `?${queryParams.toString()}` : ""}` 83 | : `${URL}${url}?${queryParams.toString()}`; 84 | } else { 85 | _url = isCustomUrl ? url : `${URL}${url}`; 86 | } 87 | 88 | const requestOptions = { 89 | method, 90 | headers: { 91 | ...(isCustomUrl 92 | ? {} 93 | : { 94 | Authorization: `Bearer ${getPref("authkey")}`, 95 | "Content-Type": "application/json", 96 | }), 97 | ...headers, 98 | }, 99 | ...(method !== "GET" && 100 | method !== "HEAD" && { 101 | body: isCustomUrl ? body : JSON.stringify(body, null, 2), 102 | }), 103 | }; 104 | 105 | const response = await fetch(_url, requestOptions); 106 | 107 | if (!response.ok) { 108 | const errorMessage = `HTTP error ${response.status}: ${response.statusText}`; 109 | lastError = new Error(errorMessage); 110 | 111 | // Try to parse response body for more detailed error information 112 | const parseResult = await parseResponse(response); 113 | if (parseResult.success && parseResult.data) { 114 | const errorData = parseResult.data; 115 | if (typeof errorData === "object" && errorData !== null) { 116 | if ("code" in errorData && errorData.code !== 0) { 117 | lastError = new Error(extractErrorMessage(errorData)); 118 | } 119 | } 120 | } 121 | 122 | // Don't retry for client-side errors (4xx) 123 | if (response.status >= 400 && response.status < 500) { 124 | if (fullFillOnError) { 125 | return { error: lastError, status: response.status }; 126 | } 127 | throw lastError; 128 | } 129 | 130 | // For server errors, attempt retry if we have retries left 131 | if (retryCount < retries) { 132 | retryCount++; 133 | await new Promise((resolve) => setTimeout(resolve, retryDelay)); 134 | continue; 135 | } else { 136 | if (fullFillOnError) { 137 | return { error: lastError, status: response.status }; 138 | } 139 | throw lastError; 140 | } 141 | } 142 | 143 | // Process response based on responseType 144 | const parseResult = await parseResponse(response, responseType); 145 | if (!parseResult.success) { 146 | lastError = parseResult.error; 147 | if (fullFillOnError) { 148 | return { error: lastError }; 149 | } 150 | throw lastError; 151 | } 152 | 153 | const data = parseResult.data; 154 | if (responseType === "arraybuffer") { 155 | return data; 156 | } 157 | 158 | ztoolkit.log("===========", data); 159 | 160 | // Handle the case with data.code 161 | if (data && typeof data === "object" && data !== null) { 162 | // Case: response has code property 163 | if ("code" in data) { 164 | if (data.code === 0) { 165 | return "data" in data ? data.data : data; 166 | } else { 167 | // Case: code is not equal to 0 168 | lastError = new Error(extractErrorMessage(data)); 169 | 170 | if (fullFillOnError) { 171 | return data; 172 | } 173 | 174 | // Try to retry for server errors 175 | if (retryCount < retries) { 176 | retryCount++; 177 | await new Promise((resolve) => setTimeout(resolve, retryDelay)); 178 | continue; 179 | } else { 180 | throw lastError; 181 | } 182 | } 183 | } 184 | // Case: no code property but has data 185 | return data; 186 | } 187 | 188 | // Case: successful response with no structured data 189 | return data; 190 | } catch (error: any) { 191 | lastError = error; 192 | 193 | if (retryCount < retries) { 194 | retryCount++; 195 | await new Promise((resolve) => setTimeout(resolve, retryDelay)); 196 | continue; 197 | } 198 | 199 | if (fullFillOnError) { 200 | return { error: lastError }; 201 | } 202 | 203 | handleError(lastError); 204 | throw lastError; // Explicitly throw to outer layer 205 | } 206 | } 207 | 208 | // This should never happen but just in case 209 | if (lastError) { 210 | throw lastError; 211 | } 212 | 213 | return null; 214 | } 215 | 216 | export function handleError(error: any) { 217 | new ztoolkit.ProgressWindow(addon.data.config.addonName) 218 | .createLine({ 219 | text: `${error}`, 220 | type: "error", 221 | }) 222 | .show(); 223 | } 224 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | import { getString } from "../utils/locale"; 2 | 3 | export const translateModes = [ 4 | { 5 | label: "translateMode-all", 6 | value: "all", 7 | }, 8 | { 9 | label: "translateMode-dua", 10 | value: "dual", 11 | }, 12 | { 13 | label: "translateMode-translation", 14 | value: "translation", 15 | }, 16 | ]; 17 | 18 | export const translateModels = [ 19 | { 20 | label: "translateModel-deepseek", 21 | value: "deepseek", 22 | }, 23 | { 24 | label: "translateModel-doubao", 25 | value: "doubao", 26 | }, 27 | { 28 | label: "translateModel-glm-4-plus", 29 | value: "glm-4-plus", 30 | }, 31 | { 32 | label: "translateModel-OpenAI", 33 | value: "gpt-4.1-mini-2025-04-14", 34 | }, 35 | { 36 | label: "translateModel-Gemini", 37 | value: "gemini-2.0-flash-001", 38 | }, 39 | { 40 | label: "translateModel-glm-4-flash", 41 | value: "glm-4-flash", 42 | }, 43 | ]; 44 | 45 | export function getTranslateModelLabel(model: string) { 46 | const label = translateModels.find((m) => m.value === model)?.label; 47 | if (!label) { 48 | return ""; 49 | } 50 | return getString(label); 51 | } 52 | 53 | export function getTranslateModeLabel(mode: string) { 54 | const label = translateModes.find((m) => m.value === mode)?.label; 55 | if (!label) { 56 | return ""; 57 | } 58 | return getString(label); 59 | } 60 | -------------------------------------------------------------------------------- /src/hooks.ts: -------------------------------------------------------------------------------- 1 | import { getString, initLocale } from "./utils/locale"; 2 | import { 3 | registerPrefs, 4 | registerPrefsScripts, 5 | } from "./modules/preference-window"; 6 | import { registerShortcuts } from "./modules/shortcuts"; 7 | import { createZToolkit } from "./utils/ztoolkit"; 8 | import { registerMenu, registerWindowMenu } from "./modules/menu"; 9 | import { registerToolbar } from "./modules/toolbar"; 10 | import { registerNotifier } from "./modules/notify"; 11 | import { 12 | addTasksToQueue, 13 | startQueueProcessing, 14 | shouldSkipAttachment, 15 | } from "./modules/translate/task"; 16 | import { 17 | loadSavedTranslationData, 18 | restoreUnfinishedTasks, 19 | saveTranslationData, 20 | } from "./modules/translate/persistence"; 21 | import { showTaskManager } from "./modules/translate/task-manager"; 22 | import { initTasks } from "./modules/translate/store"; 23 | import { getPref } from "./utils/prefs"; 24 | 25 | async function onStartup() { 26 | await Promise.all([ 27 | Zotero.initializationPromise, 28 | Zotero.unlockPromise, 29 | Zotero.uiReadyPromise, 30 | ]); 31 | 32 | initLocale(); 33 | 34 | registerPrefs(); 35 | 36 | registerNotifier(["item", "file"]); 37 | 38 | registerShortcuts(); 39 | 40 | initTasks(); 41 | 42 | await Promise.all( 43 | Zotero.getMainWindows().map((win) => onMainWindowLoad(win)), 44 | ); 45 | 46 | // 加载保存的翻译任务和队列数据 47 | loadSavedTranslationData(); 48 | 49 | // 恢复未完成的翻译任务 50 | const restoredCount = restoreUnfinishedTasks(); 51 | if (restoredCount > 0) { 52 | ztoolkit.log(`已恢复${restoredCount}个未完成的翻译任务,准备重新处理`); 53 | 54 | // 启动处理队列 55 | startQueueProcessing(); 56 | } 57 | } 58 | 59 | async function onMainWindowLoad(win: _ZoteroTypes.MainWindow): Promise { 60 | // Create ztoolkit for every window 61 | addon.data.ztoolkit = createZToolkit(); 62 | ztoolkit.basicOptions.log.disableConsole = false; 63 | // @ts-ignore This is a moz feature 64 | win.MozXULElement.insertFTLIfNeeded( 65 | `${addon.data.config.addonRef}-mainWindow.ftl`, 66 | ); 67 | 68 | const popupWin = new ztoolkit.ProgressWindow(addon.data.config.addonName, { 69 | closeOnClick: true, 70 | closeTime: -1, 71 | }) 72 | .createLine({ 73 | text: getString("startup-begin"), 74 | type: "default", 75 | progress: 0, 76 | }) 77 | .show(); 78 | 79 | await Zotero.Promise.delay(1000); 80 | popupWin.changeLine({ 81 | progress: 30, 82 | text: `[30%] ${getString("startup-begin")}`, 83 | }); 84 | 85 | registerMenu(); 86 | 87 | registerWindowMenu(); 88 | 89 | registerToolbar(); 90 | 91 | await Zotero.Promise.delay(1000); 92 | 93 | popupWin.changeLine({ 94 | progress: 100, 95 | text: `[100%] ${getString("startup-finish")}`, 96 | }); 97 | popupWin.startCloseTimer(5000); 98 | } 99 | 100 | async function onMainWindowUnload(win: Window): Promise { 101 | ztoolkit.unregisterAll(); 102 | ztoolkit.Menu.unregisterAll(); 103 | } 104 | 105 | function onShutdown(): void { 106 | // 关闭前保存翻译数据 107 | saveTranslationData(); 108 | ztoolkit.unregisterAll(); 109 | // Remove addon object 110 | addon.data.alive = false; 111 | // @ts-ignore - Plugin instance is not typed 112 | delete Zotero[addon.data.config.addonInstance]; 113 | } 114 | 115 | /** 116 | * This function is just an example of dispatcher for Notify events. 117 | * Any operations should be placed in a function to keep this function clear. 118 | */ 119 | async function onNotify( 120 | event: string, 121 | type: string, 122 | ids: Array, 123 | extraData: { [key: string]: any }, 124 | ) { 125 | ztoolkit.log("notify", event, type, ids, extraData); 126 | const isAutoTranslateEnabled = getPref("autoTranslate"); 127 | ztoolkit.log("isAutoTranslateEnabled", isAutoTranslateEnabled); 128 | if (!isAutoTranslateEnabled) { 129 | return; 130 | } 131 | if (event === "add" && type === "item") { 132 | const newIds = []; 133 | for (const id of ids) { 134 | const item = Zotero.Items.get(id); 135 | const isPDFAttachment = item.isPDFAttachment(); 136 | 137 | if (item.isRegularItem()) { 138 | // ✅ 情况①:解析成功,生成新条目(主条目) 139 | ztoolkit.log("【情况①】创建了主条目:", item.getField("title")); 140 | newIds.push(item.id); 141 | } else if (isPDFAttachment) { 142 | const parentID = item.parentID; 143 | ztoolkit.log("item.attachmentFilename", item.attachmentFilename); 144 | const shouldSkip = shouldSkipAttachment(item); 145 | if (shouldSkip) { 146 | ztoolkit.log("【情况④】跳过翻译结果附件:", item.attachmentFilename); 147 | continue; 148 | } 149 | if (parentID) { 150 | // 📎 情况③:添加到已有条目下的附件 151 | ztoolkit.log( 152 | "【情况③】添加附件到已有条目:", 153 | item.attachmentFilename, 154 | ",父项ID:", 155 | parentID, 156 | ); 157 | // 158 | newIds.push(item.id); 159 | } else { 160 | // ❌ 情况②:无法识别,仅上传为独立附件 161 | ztoolkit.log( 162 | "【情况②】独立附件(无法识别的PDF)暂不支持:", 163 | item.attachmentFilename, 164 | ); 165 | } 166 | } 167 | } 168 | if (newIds.length > 0) { 169 | addTasksToQueue(newIds); 170 | } 171 | } 172 | } 173 | 174 | /** 175 | * This function is just an example of dispatcher for Preference UI events. 176 | * Any operations should be placed in a function to keep this function clear. 177 | * @param type event type 178 | * @param data event data 179 | */ 180 | async function onPrefsEvent(type: string, data: { [key: string]: any }) { 181 | switch (type) { 182 | case "load": 183 | registerPrefsScripts(data.window); 184 | break; 185 | default: 186 | return; 187 | } 188 | } 189 | 190 | function onShortcuts(type: string) { 191 | if (!getPref("enableShortcuts")) { 192 | return; 193 | } 194 | switch (type) { 195 | case "translate": 196 | addTasksToQueue(); 197 | break; 198 | case "showTaskManager": 199 | showTaskManager(); 200 | break; 201 | default: 202 | break; 203 | } 204 | } 205 | 206 | function onTranslate() { 207 | addTasksToQueue(); 208 | } 209 | 210 | function onViewTranslationTasks() { 211 | showTaskManager(); 212 | } 213 | 214 | // Add your hooks here. For element click, etc. 215 | // Keep in mind hooks only do dispatch. Don't add code that does real jobs in hooks. 216 | // Otherwise the code would be hard to read and maintain. 217 | 218 | export default { 219 | onStartup, 220 | onShutdown, 221 | onMainWindowLoad, 222 | onMainWindowUnload, 223 | onNotify, 224 | onPrefsEvent, 225 | onShortcuts, 226 | onTranslate, 227 | onViewTranslationTasks, 228 | }; 229 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { BasicTool } from "zotero-plugin-toolkit"; 2 | import Addon from "./addon"; 3 | import { config } from "../package.json"; 4 | 5 | const basicTool = new BasicTool(); 6 | 7 | // @ts-ignore - Plugin instance is not typed 8 | if (!basicTool.getGlobal("Zotero")[config.addonInstance]) { 9 | _globalThis.addon = new Addon(); 10 | defineGlobal("ztoolkit", () => { 11 | return _globalThis.addon.data.ztoolkit; 12 | }); 13 | // @ts-ignore - Plugin instance is not typed 14 | Zotero[config.addonInstance] = addon; 15 | } 16 | 17 | function defineGlobal(name: Parameters[0]): void; 18 | function defineGlobal(name: string, getter: () => any): void; 19 | function defineGlobal(name: string, getter?: () => any) { 20 | Object.defineProperty(_globalThis, name, { 21 | get() { 22 | return getter ? getter() : basicTool.getGlobal(name); 23 | }, 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /src/modules/language/config.ts: -------------------------------------------------------------------------------- 1 | import type { Language } from "./types"; 2 | 3 | export const zhCNLangMap: Record = { 4 | "zh-CN": "简体中文", 5 | "zh-TW": "繁体中文 - 台湾", 6 | "zh-HK": "繁体中文 - 香港", 7 | en: "英语", 8 | ja: "日语", 9 | ko: "韩语", 10 | fr: "法语", 11 | pl: "波兰语", 12 | ru: "俄语", 13 | es: "西班牙语", 14 | pt: "葡萄牙语", 15 | ms: "马来语", 16 | id: "印度尼西亚语", 17 | tk: "土库曼语", 18 | tl: "菲律宾语(塔加洛语)", 19 | vi: "越南语", 20 | az: "阿塞拜疆语", 21 | kk: "哈萨克语", 22 | de: "德语", 23 | nl: "荷兰语", 24 | ga: "爱尔兰语", 25 | it: "意大利语", 26 | el: "希腊语", 27 | sv: "瑞典语", 28 | da: "丹麦语", 29 | no: "挪威语", 30 | is: "冰岛语", 31 | fi: "芬兰语", 32 | uk: "乌克兰语", 33 | cs: "捷克语", 34 | ro: "罗马尼亚语", 35 | hu: "匈牙利语", 36 | sk: "斯洛伐克语", 37 | hr: "克罗地亚语", 38 | et: "爱沙尼亚语", 39 | lv: "拉脱维亚语", 40 | lt: "立陶宛语", 41 | be: "白俄罗斯语", 42 | mk: "马其顿语", 43 | sq: "阿尔巴尼亚语", 44 | sr: "塞尔维亚语", 45 | sl: "斯洛文尼亚语", 46 | ca: "加泰罗尼亚语", 47 | bg: "保加利亚语", 48 | mt: "马耳他语", 49 | sw: "斯瓦希里语", 50 | am: "阿姆哈拉语", 51 | om: "奥罗莫语", 52 | ti: "提格里尼亚语", 53 | ht: "海地克里奥尔语", 54 | la: "拉丁语", 55 | lo: "老挝语", 56 | ml: "马拉雅拉姆语", 57 | gu: "古吉拉特语", 58 | th: "泰语", 59 | my: "缅甸语", 60 | ta: "泰米尔语", 61 | te: "泰卢固语", 62 | or: "奥里亚语", 63 | hy: "亚美尼亚语", 64 | mn: "蒙古语(西里尔)", 65 | ka: "格鲁吉亚语", 66 | km: "高棉语", 67 | bs: "波斯尼亚语", 68 | lb: "卢森堡语", 69 | rm: "罗曼什语", 70 | tr: "土耳其语", 71 | si: "僧伽罗语", 72 | uz: "乌兹别克语", 73 | ky: "吉尔吉斯语", 74 | tg: "塔吉克语", 75 | ab: "阿布哈兹语", 76 | aa: "阿法尔语", 77 | af: "南非语", 78 | ak: "阿坎语", 79 | an: "阿拉贡语", 80 | av: "阿瓦尔语", 81 | ae: "阿维斯陀语", 82 | ee: "埃维语", 83 | ay: "艾马拉语", 84 | oj: "奥吉布瓦语", 85 | oc: "奥克语", 86 | os: "奥塞梯语", 87 | pi: "巴利语", 88 | ba: "巴什基尔语", 89 | eu: "巴斯克语", 90 | bm: "班巴拉语", 91 | br: "布列塔尼语", 92 | ch: "查莫罗语", 93 | ce: "车臣语", 94 | cv: "楚瓦什语", 95 | tn: "茨瓦纳语", 96 | dv: "迪维希语", 97 | nr: "南恩德贝勒语", 98 | ng: "恩东加语", 99 | fo: "法罗语", 100 | fj: "斐济语", 101 | fy: "弗里西亚语", 102 | ff: "富拉语", 103 | lg: "干达语", 104 | kg: "刚果语", 105 | kl: "格陵兰语", 106 | cu: "教会斯拉夫语", 107 | gn: "瓜拉尼语", 108 | ia: "国际语", 109 | ha: "豪萨语", 110 | hz: "赫雷罗语", 111 | ki: "基库尤语", 112 | rn: "基隆迪语", 113 | rw: "基尼亚卢旺达语", 114 | gl: "加利西亚语", 115 | kr: "卡努里语", 116 | kw: "康沃尔语", 117 | kv: "科米语", 118 | xh: "科萨语", 119 | co: "科西嘉语", 120 | cr: "克里语", 121 | qu: "克丘亚语", 122 | ku: "库尔德语(拉丁)", 123 | kj: "宽亚玛语", 124 | li: "林堡语", 125 | ln: "林加拉语", 126 | gv: "马恩岛语", 127 | mg: "马达加斯加语", 128 | mh: "马绍尔语", 129 | mi: "毛利语", 130 | nv: "纳瓦霍语", 131 | na: "瑙鲁语", 132 | ny: "尼扬贾语", 133 | nn: "挪威尼诺斯克语", 134 | sc: "萨丁尼亚语", 135 | se: "北方萨米语", 136 | sm: "萨摩亚语", 137 | sg: "桑戈语", 138 | sn: "绍纳语", 139 | eo: "世界语", 140 | gd: "苏格兰盖尔语", 141 | so: "索马里语", 142 | st: "南索托语", 143 | tt: "塔塔尔语", 144 | ty: "塔希提语", 145 | to: "汤加语", 146 | tw: "契维语", 147 | wa: "瓦隆语", 148 | cy: "威尔士语", 149 | ve: "文达语", 150 | vo: "沃拉普克语", 151 | ie: "西方国际语", 152 | ho: "希里莫图语", 153 | ig: "伊博语", 154 | io: "伊多语", 155 | iu: "因纽特语", 156 | ik: "伊努皮亚克语", 157 | ii: "四川彝语", 158 | yo: "约鲁巴语", 159 | za: "壮语", 160 | ts: "聪加语", 161 | zu: "祖鲁语", 162 | }; 163 | 164 | export const zhTWLangMap: Record = { 165 | "zh-CN": "簡體中文", 166 | "zh-TW": "繁體中文 - 台灣", 167 | "zh-HK": "繁體中文 - 香港", 168 | en: "英語", 169 | ja: "日語", 170 | ko: "韓語", 171 | fr: "法語", 172 | pl: "波蘭語", 173 | ru: "俄語", 174 | es: "西班牙語", 175 | pt: "葡萄牙語", 176 | ms: "馬來語", 177 | id: "印度尼西亞語", 178 | tk: "土庫曼語", 179 | tl: "菲律賓語(塔加洛語)", 180 | vi: "越南語", 181 | az: "亞塞拜然語", 182 | kk: "哈薩克語", 183 | de: "德語", 184 | nl: "荷蘭語", 185 | ga: "愛爾蘭語", 186 | it: "意大利語", 187 | el: "希臘語", 188 | sv: "瑞典語", 189 | da: "丹麥語", 190 | no: "挪威語", 191 | is: "冰島語", 192 | fi: "芬蘭語", 193 | uk: "烏克蘭語", 194 | cs: "捷克語", 195 | ro: "羅馬尼亞語", 196 | hu: "匈牙利語", 197 | sk: "斯洛伐克語", 198 | hr: "克羅地亞語", 199 | et: "愛沙尼亞語", 200 | lv: "拉脫維亞語", 201 | lt: "立陶宛語", 202 | be: "白俄羅斯語", 203 | mk: "馬其頓語", 204 | sq: "阿爾巴尼亞語", 205 | sr: "塞爾維亞語", 206 | sl: "斯洛維尼亞語", 207 | ca: "加泰羅尼亞語", 208 | bg: "保加利亞語", 209 | mt: "馬耳他語", 210 | sw: "斯瓦希里語", 211 | am: "阿姆哈拉語", 212 | om: "奧羅莫語", 213 | ti: "提格里尼亞語", 214 | ht: "海地克里奧爾語", 215 | la: "拉丁語", 216 | lo: "老撾語", 217 | ml: "馬拉雅拉姆語", 218 | gu: "古吉拉特語", 219 | th: "泰語", 220 | my: "緬甸語", 221 | ta: "泰米爾語", 222 | te: "泰盧固語", 223 | or: "奧里亞語", 224 | hy: "亞美尼亞語", 225 | mn: "蒙古語(西里爾)", 226 | ka: "格魯吉亞語", 227 | km: "高棉語", 228 | bs: "波斯尼亞語", 229 | lb: "盧森堡語", 230 | rm: "羅曼什語", 231 | tr: "土耳其語", 232 | si: "僧伽羅語", 233 | uz: "烏茲別克語", 234 | ky: "吉爾吉斯語", 235 | tg: "塔吉克語", 236 | ab: "阿布哈茲語", 237 | aa: "阿法爾語", 238 | af: "南非語", 239 | ak: "阿坎語", 240 | an: "阿拉貢語", 241 | av: "阿瓦爾語", 242 | ae: "阿維斯陀語", 243 | ee: "埃維語", 244 | ay: "艾馬拉語", 245 | oj: "奧吉布瓦語", 246 | oc: "奧克語", 247 | os: "奧塞梯語", 248 | pi: "巴利語", 249 | ba: "巴什基爾語", 250 | eu: "巴斯克語", 251 | bm: "班巴拉語", 252 | br: "布列塔尼語", 253 | ch: "查莫羅語", 254 | ce: "車臣語", 255 | cv: "楚瓦什語", 256 | tn: "茨瓦納語", 257 | dv: "迪維希語", 258 | nr: "南恩德貝勒語", 259 | ng: "恩東加語", 260 | fo: "法羅語", 261 | fj: "斐濟語", 262 | fy: "弗里西亞語", 263 | ff: "富拉語", 264 | lg: "干達語", 265 | kg: "剛果語", 266 | kl: "格陵蘭語", 267 | cu: "教會斯拉夫語", 268 | gn: "瓜拉尼語", 269 | ia: "國際語", 270 | ha: "豪薩語", 271 | hz: "赫雷羅語", 272 | ki: "基庫尤語", 273 | rn: "基隆迪語", 274 | rw: "基尼亞盧旺達語", 275 | gl: "加利西亞語", 276 | kr: "卡努裡語", 277 | kw: "康沃爾語", 278 | kv: "科米語", 279 | xh: "科薩語", 280 | co: "科西嘉語", 281 | cr: "克里語", 282 | qu: "克丘亞語", 283 | ku: "庫爾德語(拉丁)", 284 | kj: "寬亞瑪語", 285 | li: "林堡語", 286 | ln: "林加拉語", 287 | gv: "馬恩島語", 288 | mg: "馬達加斯加語", 289 | mh: "馬紹爾語", 290 | mi: "毛利語", 291 | nv: "納瓦霍語", 292 | na: "瑙魯語", 293 | ny: "尼揚賈語", 294 | nn: "挪威尼諾斯克語", 295 | sc: "薩丁尼亞語", 296 | se: "北方薩米語", 297 | sm: "薩摩亞語", 298 | sg: "桑戈語", 299 | sn: "紹納語", 300 | eo: "世界語", 301 | gd: "蘇格蘭蓋爾語", 302 | so: "索馬里語", 303 | st: "南索托語", 304 | tt: "塔塔爾語", 305 | ty: "塔希提語", 306 | to: "湯加語", 307 | tw: "契維語", 308 | wa: "瓦隆語", 309 | cy: "威爾士語", 310 | ve: "Tshivenḓa", 311 | vo: "Volapük", 312 | ie: "Interlingue", 313 | ho: "Hiri Motu", 314 | ig: "Igbo", 315 | io: "Ido", 316 | iu: "ᐃᓄᒃᑎᑐᑦ", 317 | ik: "Iñupiaq", 318 | ii: "ꆈꌠꉙ", 319 | yo: "Yorùbá", 320 | za: "Saɯ cueŋƅ", 321 | ts: "Xitsonga", 322 | zu: "isiZulu", 323 | }; 324 | 325 | export const langMap = { 326 | "zh-CN": "Simplified Chinese", 327 | "zh-TW": "Traditional Chinese - Taiwan", 328 | "zh-HK": "Traditional Chinese - Hong Kong", 329 | en: "English", 330 | ja: "Japanese", 331 | ko: "Korean", 332 | fr: "French", 333 | pl: "Polish", 334 | ru: "Russian", 335 | es: "Spanish", 336 | pt: "Portuguese", 337 | ms: "Malay", 338 | id: "Indonesian", 339 | tk: "Turkmen", 340 | tl: "Filipino (Tagalog)", 341 | vi: "Vietnamese", 342 | az: "Azerbaijani", 343 | kk: "Kazakh", 344 | de: "German", 345 | nl: "Dutch", 346 | ga: "Irish", 347 | it: "Italian", 348 | el: "Greek", 349 | sv: "Swedish", 350 | da: "Danish", 351 | no: "Norwegian", 352 | is: "Icelandic", 353 | fi: "Finnish", 354 | uk: "Ukrainian", 355 | cs: "Czech", 356 | ro: "Romanian", 357 | hu: "Hungarian", 358 | sk: "Slovak", 359 | hr: "Croatian", 360 | et: "Estonian", 361 | lv: "Latvian", 362 | lt: "Lithuanian", 363 | be: "Belarusian", 364 | mk: "Macedonian", 365 | sq: "Albanian", 366 | sr: "Serbian", 367 | sl: "Slovenian", 368 | ca: "Catalan", 369 | bg: "Bulgarian", 370 | mt: "Maltese", 371 | sw: "Swahili", 372 | am: "Amharic", 373 | om: "Oromo", 374 | ti: "Tigrinya", 375 | ht: "Haitian Creole", 376 | la: "Latin", 377 | lo: "Lao", 378 | ml: "Malayalam", 379 | gu: "Gujarati", 380 | th: "Thai", 381 | my: "Burmese", 382 | ta: "Tamil", 383 | te: "Telugu", 384 | or: "Oriya", 385 | hy: "Armenian", 386 | mn: "Mongolian (Cyrillic)", 387 | ka: "Georgian", 388 | km: "Khmer", 389 | bs: "Bosnian", 390 | lb: "Luxembourgish", 391 | rm: "Romansh", 392 | tr: "Turkish", 393 | si: "Sinhala", 394 | uz: "Uzbek", 395 | ky: "Kyrgyz", 396 | tg: "Tajik", 397 | ab: "Abkhazian", 398 | aa: "Afar", 399 | af: "Afrikaans", 400 | ak: "Akan", 401 | an: "Aragonese", 402 | av: "Avaric", 403 | ae: "Avestan", 404 | ee: "Ewe", 405 | ay: "Aymara", 406 | oj: "Ojibwa", 407 | oc: "Occitan", 408 | os: "Ossetian", 409 | pi: "Pali", 410 | ba: "Bashkir", 411 | eu: "Basque", 412 | bm: "Bambara", 413 | br: "Breton", 414 | ch: "Chamorro", 415 | ce: "Chechen", 416 | cv: "Chuvash", 417 | tn: "Tswana", 418 | dv: "Divehi", 419 | nr: "Ndebele, South", 420 | ng: "Ndonga", 421 | fo: "Faroese", 422 | fj: "Fijian", 423 | fy: "Frisian, Western", 424 | ff: "Fulah", 425 | lg: "Ganda", 426 | kg: "Kongo", 427 | kl: "Kalaallisut", 428 | cu: "Church Slavic", 429 | gn: "Guarani", 430 | ia: "Interlingua", 431 | ha: "Hausa", 432 | hz: "Herero", 433 | ki: "Kikuyu", 434 | rn: "Rundi", 435 | rw: "Kinyarwanda", 436 | gl: "Galician", 437 | kr: "Kanuri", 438 | kw: "Cornish", 439 | kv: "Komi", 440 | xh: "Xhosa", 441 | co: "Corsican", 442 | cr: "Cree", 443 | qu: "Quechua", 444 | ku: "Kurdish (Latin)", 445 | kj: "Kuanyama", 446 | li: "Limburgan", 447 | ln: "Lingala", 448 | gv: "Manx", 449 | mg: "Malagasy", 450 | mh: "Marshallese", 451 | mi: "Maori", 452 | nv: "Diné bizaad", 453 | na: "Dorerin Naoero", 454 | ny: "Chichewa", 455 | nn: "Nynorsk", 456 | sc: "Sardu", 457 | se: "Davvisámegiella", 458 | sm: "Gagana fa'a Samoa", 459 | sg: "Sängö", 460 | sn: "chiShona", 461 | eo: "Esperanto", 462 | gd: "Gàidhlig", 463 | so: "Soomaali", 464 | st: "Sesotho", 465 | tt: "Татар", 466 | ty: "Reo Tahiti", 467 | to: "Lea faka-Tonga", 468 | tw: "Twi", 469 | wa: "Walon", 470 | cy: "Cymraeg", 471 | ve: "Tshivenḓa", 472 | vo: "Volapük", 473 | ie: "Interlingue", 474 | ho: "Hiri Motu", 475 | ig: "Igbo", 476 | io: "Ido", 477 | iu: "ᐃᓄᒃᑎᑐᑦ", 478 | ik: "Iñupiaq", 479 | ii: "ꆈꌠꉙ", 480 | yo: "Yorùbá", 481 | za: "Saɯ cueŋƅ", 482 | ts: "Xitsonga", 483 | zu: "isiZulu", 484 | }; 485 | 486 | export const nativeLangMap: Record = { 487 | "zh-CN": "简体中文", 488 | "zh-TW": "繁體中文 - 台灣", 489 | "zh-HK": "繁體中文 - 香港", 490 | ja: "日本語", 491 | ko: "한국어", 492 | fr: "Français", 493 | pl: "Polski", 494 | ru: "Русский", 495 | es: "Español", 496 | pt: "Português", 497 | ms: "Bahasa Melayu", 498 | id: "Bahasa Indonesia", 499 | tk: "Türkmençe", 500 | tl: "Tagalog", 501 | vi: "Tiếng Việt", 502 | az: "Azərbaycan dili", 503 | kk: "Қазақ тілі", 504 | de: "Deutsch", 505 | nl: "Nederlands", 506 | ga: "Gaeilge", 507 | it: "Italiano", 508 | el: "Ελληνικά", 509 | sv: "Svenska", 510 | da: "Dansk", 511 | no: "Norsk", 512 | is: "Íslenska", 513 | fi: "Suomi", 514 | uk: "Українська", 515 | cs: "Čeština", 516 | ro: "Română", 517 | hu: "Magyar", 518 | sk: "Slovenčina", 519 | hr: "Hrvatski", 520 | et: "Eesti", 521 | lv: "Latviešu", 522 | lt: "Lietuvių", 523 | be: "Беларуская", 524 | mk: "Македонски", 525 | sq: "Shqip", 526 | sr: "Српски", 527 | sl: "Slovenščina", 528 | ca: "Català", 529 | bg: "Български", 530 | mt: "Malti", 531 | sw: "Kiswahili", 532 | am: "አማርኛ", 533 | om: "Afaan Oromoo", 534 | ti: "ትግርኛ", 535 | ht: "Kreyòl Ayisyen", 536 | la: "Latina", 537 | lo: "ລາວ", 538 | ml: "മലയാളം", 539 | gu: "ગુજરાતી", 540 | th: "ไทย", 541 | my: "မြန်မာဘာသာ", 542 | ta: "தமிழ்", 543 | te: "తెలుగు", 544 | or: "ଓଡ଼ିଆ", 545 | hy: "Հայերեն", 546 | mn: "Монгол", 547 | ka: "ქართული", 548 | km: "ខ្មែរ", 549 | bs: "Bosanski", 550 | lb: "Lëtzebuergesch", 551 | rm: "Rumantsch", 552 | tr: "Türkçe", 553 | si: "සිංහල", 554 | uz: "O'zbek", 555 | ky: "Кыргызча", 556 | tg: "Тоҷикӣ", 557 | ab: "Аҧсуа", 558 | aa: "Afar", 559 | af: "Afrikaans", 560 | ak: "Akan", 561 | an: "Aragonés", 562 | av: "Авар", 563 | ae: "Avestan", 564 | ee: "Eʋegbe", 565 | ay: "Aymar aru", 566 | oj: "ᐊᓂᔑᓈᐯᒧᐎᓐ", 567 | oc: "Occitan", 568 | os: "Ирон", 569 | pi: "पालि", 570 | ba: "Башҡорт", 571 | eu: "Euskara", 572 | bm: "Bamanankan", 573 | br: "Brezhoneg", 574 | ch: "Chamoru", 575 | ce: "Нохчийн", 576 | cv: "Чӑваш", 577 | tn: "Setswana", 578 | dv: "ދިވެހި", 579 | nr: "isiNdebele", 580 | ng: "Owambo", 581 | fo: "Føroyskt", 582 | fj: "Vosa Vakaviti", 583 | fy: "Frysk", 584 | ff: "Fulfulde", 585 | lg: "Luganda", 586 | kg: "Kikongo", 587 | kl: "Kalaallisut", 588 | cu: "Церковнослове́нский", 589 | gn: "Avañe'ẽ", 590 | ia: "Interlingua", 591 | ha: "هَوُسَ", 592 | hz: "Otjiherero", 593 | ki: "Gĩkũyũ", 594 | rn: "Kirundi", 595 | rw: "Kinyarwanda", 596 | gl: "Galego", 597 | kr: "Kanuri", 598 | kw: "Kernewek", 599 | kv: "Коми", 600 | xh: "isiXhosa", 601 | co: "Corsu", 602 | cr: "ᓀᐦᐃᔭᐍᐏᐣ", 603 | qu: "Runa Simi", 604 | ku: "Kurdî", 605 | kj: "Kuanyama", 606 | li: "Limburgs", 607 | ln: "Lingála", 608 | gv: "Gaelg", 609 | mg: "Malagasy", 610 | mh: "Kajin M̧ajeļ", 611 | mi: "Māori", 612 | nv: "Diné bizaad", 613 | na: "Dorerin Naoero", 614 | ny: "Chichewa", 615 | nn: "Nynorsk", 616 | sc: "Sardu", 617 | se: "Davvisámegiella", 618 | sm: "Gagana fa'a Samoa", 619 | sg: "Sängö", 620 | sn: "chiShona", 621 | eo: "Esperanto", 622 | gd: "Gàidhlig", 623 | so: "Soomaali", 624 | st: "Sesotho", 625 | tt: "Татар", 626 | ty: "Reo Tahiti", 627 | to: "Lea faka-Tonga", 628 | tw: "Twi", 629 | wa: "Walon", 630 | cy: "Cymraeg", 631 | ve: "Tshivenḓa", 632 | vo: "Volapük", 633 | ie: "Interlingue", 634 | ho: "Hiri Motu", 635 | ig: "Igbo", 636 | io: "Ido", 637 | iu: "ᐃᓄᒃᑎᑐᑦ", 638 | ik: "Iñupiaq", 639 | ii: "ꆈꌠꉙ", 640 | yo: "Yorùbá", 641 | za: "Saɯ cueŋƅ", 642 | ts: "Xitsonga", 643 | zu: "isiZulu", 644 | en: "English", 645 | }; 646 | -------------------------------------------------------------------------------- /src/modules/language/index.ts: -------------------------------------------------------------------------------- 1 | import { Language } from "./types"; 2 | import { langMap, nativeLangMap, zhCNLangMap, zhTWLangMap } from "./config"; 3 | 4 | export const getLanguages: () => Language[] = () => { 5 | return Object.keys(langMap) as Language[]; 6 | }; 7 | 8 | export const getLanguageOptions: (interfaceLanguage: Language) => { 9 | value: string; 10 | label: string; 11 | }[] = (interfaceLanguage) => { 12 | return getLanguages().map((lang) => { 13 | return { 14 | value: lang, 15 | label: getLanguageName(lang, interfaceLanguage), 16 | }; 17 | }); 18 | }; 19 | 20 | export function getLanguageName(lang: Language, interfaceLanguage: Language) { 21 | const nativeLang = nativeLangMap[lang] || lang; 22 | const fallbackLang = langMap[lang] || lang; 23 | const zhLang = zhCNLangMap[lang]; 24 | const zhTWLang = zhTWLangMap[lang]; 25 | const internalNames: Record = { 26 | "zh-CN": zhLang, 27 | "zh-TW": zhTWLang, 28 | en: fallbackLang, 29 | }; 30 | 31 | if (lang === interfaceLanguage) { 32 | return nativeLang; 33 | } 34 | 35 | const locale = internalNames[interfaceLanguage] ?? fallbackLang; 36 | 37 | return `${locale} (${nativeLang})`; 38 | } 39 | 40 | export { langMap, nativeLangMap, zhCNLangMap, zhTWLangMap } from "./config"; 41 | -------------------------------------------------------------------------------- /src/modules/language/types.ts: -------------------------------------------------------------------------------- 1 | import { langMap } from "./config"; 2 | 3 | export type Language = keyof typeof langMap; 4 | -------------------------------------------------------------------------------- /src/modules/menu.ts: -------------------------------------------------------------------------------- 1 | import { getString } from "../utils/locale"; 2 | 3 | export function registerMenu() { 4 | ztoolkit.Menu.unregister("zotero-itemmenu-babeldoc-translate"); 5 | const menuIcon = `chrome://${addon.data.config.addonRef}/content/icons/favicon@0.5x.png`; 6 | // item menuitem with icon 7 | ztoolkit.Menu.register("item", { 8 | tag: "menuitem", 9 | id: "zotero-itemmenu-babeldoc-translate", 10 | label: getString("menuitem-translate"), 11 | commandListener: () => addon.hooks.onTranslate(), 12 | icon: menuIcon, 13 | }); 14 | } 15 | 16 | export function registerWindowMenu() { 17 | ztoolkit.Menu.unregister("zotero-menuview-babeldoc-translate-separator"); 18 | ztoolkit.Menu.unregister("zotero-menuview-babeldoc-translate-menuitem"); 19 | ztoolkit.Menu.register("menuView", { 20 | id: "zotero-menuview-babeldoc-translate-separator", 21 | tag: "menuseparator", 22 | }); 23 | // menu->File menuitem 24 | ztoolkit.Menu.register("menuView", { 25 | id: "zotero-menuview-babeldoc-translate-menuitem", 26 | tag: "menuitem", 27 | label: getString("menuView-tasks"), 28 | commandListener: () => addon.hooks.onViewTranslationTasks(), 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /src/modules/notify.ts: -------------------------------------------------------------------------------- 1 | export function registerNotifier(types: _ZoteroTypes.Notifier.Type[]) { 2 | const callback = { 3 | notify: async ( 4 | event: string, 5 | type: string, 6 | ids: number[] | string[], 7 | extraData: { [key: string]: any }, 8 | ) => { 9 | if (!addon?.data.alive) { 10 | unregisterNotifier(notifierID); 11 | return; 12 | } 13 | addon.hooks.onNotify(event, type, ids, extraData); 14 | }, 15 | }; 16 | 17 | // Register the callback in Zotero as an item observer 18 | const notifierID = Zotero.Notifier.registerObserver(callback, types); 19 | 20 | Zotero.Plugins.addObserver({ 21 | shutdown: ({ id }) => { 22 | if (id === addon.data.config.addonID) unregisterNotifier(notifierID); 23 | }, 24 | }); 25 | } 26 | 27 | function unregisterNotifier(notifierID: string) { 28 | Zotero.Notifier.unregisterObserver(notifierID); 29 | } 30 | -------------------------------------------------------------------------------- /src/modules/preference-window.ts: -------------------------------------------------------------------------------- 1 | import { config } from "../../package.json"; 2 | import { getString } from "../utils/locale"; 3 | import { getPref, setPref } from "../utils/prefs"; 4 | import { showDialog } from "../utils/dialog"; 5 | import { getLanguages, getLanguageName } from "./language"; 6 | import { translateModes, translateModels } from "../config"; 7 | import type { Language } from "./language/types"; 8 | 9 | export function registerPrefs() { 10 | Zotero.PreferencePanes.register({ 11 | pluginID: addon.data.config.addonID, 12 | src: rootURI + "content/preferences.xhtml", 13 | label: getString("prefs-title"), 14 | image: `chrome://${addon.data.config.addonRef}/content/icons/favicon.png`, 15 | }); 16 | } 17 | 18 | export async function registerPrefsScripts(_window: Window) { 19 | // This function is called when the prefs window is opened 20 | // See addon/content/preferences.xhtml onpaneload 21 | if (!addon.data.prefs) { 22 | addon.data.prefs = { 23 | window: _window, 24 | }; 25 | } else { 26 | addon.data.prefs.window = _window; 27 | } 28 | buildPrefsPane(); 29 | bindPrefEvents(); 30 | } 31 | 32 | function buildPrefsPane() { 33 | const doc = addon.data.prefs?.window?.document; 34 | if (!doc) { 35 | return; 36 | } 37 | ztoolkit.UI.replaceElement( 38 | { 39 | tag: "menulist", 40 | id: `${config.addonRef}-target-language`, 41 | attributes: { 42 | value: getPref("targetLanguage") as string, 43 | native: "true", 44 | }, 45 | styles: { 46 | maxWidth: "250px", 47 | }, 48 | children: [ 49 | { 50 | tag: "menupopup", 51 | children: getLanguages().map((lang) => { 52 | const nativeLang = getLanguageName(lang, Zotero.locale as Language); 53 | return { 54 | tag: "menuitem", 55 | attributes: { 56 | label: nativeLang, 57 | value: lang, 58 | }, 59 | }; 60 | }), 61 | }, 62 | ], 63 | listeners: [ 64 | { 65 | type: "command", 66 | listener: (e: Event) => { 67 | ztoolkit.log(e); 68 | setPref("targetLanguage", (e.target as XUL.MenuList).value); 69 | }, 70 | }, 71 | ], 72 | }, 73 | doc.querySelector(`#${config.addonRef}-target-language-placeholder`)!, 74 | ); 75 | 76 | ztoolkit.UI.replaceElement( 77 | { 78 | tag: "menulist", 79 | id: `${config.addonRef}-translate-mode`, 80 | attributes: { 81 | value: getPref("translateMode") as string, 82 | native: "true", 83 | }, 84 | styles: { 85 | maxWidth: "250px", 86 | }, 87 | children: [ 88 | { 89 | tag: "menupopup", 90 | children: translateModes.map((item) => { 91 | return { 92 | tag: "menuitem", 93 | attributes: { 94 | label: getString(item.label), 95 | value: item.value, 96 | }, 97 | }; 98 | }), 99 | }, 100 | ], 101 | listeners: [ 102 | { 103 | type: "command", 104 | listener: (e: Event) => { 105 | ztoolkit.log(e); 106 | setPref("translateMode", (e.target as XUL.MenuList).value); 107 | }, 108 | }, 109 | ], 110 | }, 111 | doc.querySelector(`#${config.addonRef}-translate-mode-placeholder`)!, 112 | ); 113 | 114 | ztoolkit.UI.replaceElement( 115 | { 116 | tag: "menulist", 117 | id: `${config.addonRef}-translate-model`, 118 | attributes: { 119 | value: getPref("translateModel") as string, 120 | native: "true", 121 | }, 122 | styles: { 123 | maxWidth: "250px", 124 | }, 125 | children: [ 126 | { 127 | tag: "menupopup", 128 | children: translateModels.map((item) => { 129 | return { 130 | tag: "menuitem", 131 | attributes: { 132 | label: getString(item.label), 133 | value: item.value, 134 | }, 135 | }; 136 | }), 137 | }, 138 | ], 139 | listeners: [ 140 | { 141 | type: "command", 142 | listener: (e: Event) => { 143 | ztoolkit.log(e); 144 | setPref("translateModel", (e.target as XUL.MenuList).value); 145 | }, 146 | }, 147 | ], 148 | }, 149 | doc.querySelector(`#${config.addonRef}-translate-model-placeholder`)!, 150 | ); 151 | } 152 | 153 | function bindPrefEvents() { 154 | addon.data 155 | .prefs!.window.document?.querySelector( 156 | `#zotero-prefpane-${config.addonRef}-authkey`, 157 | ) 158 | ?.addEventListener("change", (e: Event) => { 159 | ztoolkit.log(e); 160 | setPref("authkey", (e.target as HTMLInputElement).value); 161 | }); 162 | 163 | addon.data 164 | .prefs!.window.document?.querySelector( 165 | `#zotero-prefpane-${config.addonRef}-test-button`, 166 | ) 167 | ?.addEventListener("command", async (e: Event) => { 168 | try { 169 | const result = await addon.api.checkAuthKey({ 170 | apiKey: getPref("authkey"), 171 | }); 172 | if (result) { 173 | showDialog({ 174 | title: getString("pref-test-success"), 175 | }); 176 | } else { 177 | showDialog({ 178 | title: getString("pref-test-failed"), 179 | message: getString("pref-test-failed-description"), 180 | }); 181 | } 182 | } catch (error) { 183 | ztoolkit.log(error); 184 | showDialog({ 185 | title: getString("pref-test-failed"), 186 | message: getString("pref-test-failed-description"), 187 | }); 188 | } 189 | }); 190 | } 191 | -------------------------------------------------------------------------------- /src/modules/prompt.ts: -------------------------------------------------------------------------------- 1 | export function registerPrompt() { 2 | ztoolkit.Prompt.register([ 3 | { 4 | name: "BabelDOC Translate", 5 | label: "BabelDOC Translate", 6 | callback(prompt) { 7 | ztoolkit.getGlobal("alert")("Command triggered!"); 8 | }, 9 | }, 10 | ]); 11 | } 12 | 13 | export function registerAnonymousCommandExample(window: Window) { 14 | ztoolkit.Prompt.register([ 15 | { 16 | id: "search", 17 | callback: async (prompt) => { 18 | // https://github.com/zotero/zotero/blob/7262465109c21919b56a7ab214f7c7a8e1e63909/chrome/content/zotero/integration/quickFormat.js#L589 19 | function getItemDescription(item: Zotero.Item) { 20 | const nodes = []; 21 | let str = ""; 22 | let author, 23 | authorDate = ""; 24 | if (item.firstCreator) { 25 | author = authorDate = item.firstCreator; 26 | } 27 | let date = item.getField("date", true, true) as string; 28 | if (date && (date = date.substr(0, 4)) !== "0000") { 29 | authorDate += " (" + parseInt(date) + ")"; 30 | } 31 | authorDate = authorDate.trim(); 32 | if (authorDate) nodes.push(authorDate); 33 | 34 | const publicationTitle = item.getField( 35 | "publicationTitle", 36 | false, 37 | true, 38 | ); 39 | if (publicationTitle) { 40 | nodes.push(`${publicationTitle}`); 41 | } 42 | let volumeIssue = item.getField("volume"); 43 | const issue = item.getField("issue"); 44 | if (issue) volumeIssue += "(" + issue + ")"; 45 | if (volumeIssue) nodes.push(volumeIssue); 46 | 47 | const publisherPlace = []; 48 | let field; 49 | if ((field = item.getField("publisher"))) publisherPlace.push(field); 50 | if ((field = item.getField("place"))) publisherPlace.push(field); 51 | if (publisherPlace.length) nodes.push(publisherPlace.join(": ")); 52 | 53 | const pages = item.getField("pages"); 54 | if (pages) nodes.push(pages); 55 | 56 | if (!nodes.length) { 57 | const url = item.getField("url"); 58 | if (url) nodes.push(url); 59 | } 60 | 61 | // compile everything together 62 | for (let i = 0, n = nodes.length; i < n; i++) { 63 | const node = nodes[i]; 64 | 65 | if (i != 0) str += ", "; 66 | 67 | if (typeof node === "object") { 68 | const label = 69 | Zotero.getMainWindow().document.createElement("label"); 70 | label.setAttribute("value", str); 71 | label.setAttribute("crop", "end"); 72 | str = ""; 73 | } else { 74 | str += node; 75 | } 76 | } 77 | if (str.length) str += "."; 78 | return str; 79 | } 80 | function filter(ids: number[]) { 81 | ids = ids.filter(async (id) => { 82 | const item = (await Zotero.Items.getAsync(id)) as Zotero.Item; 83 | return item.isRegularItem() && !(item as any).isFeedItem; 84 | }); 85 | return ids; 86 | } 87 | const text = prompt.inputNode.value; 88 | prompt.showTip("Searching..."); 89 | const s = new Zotero.Search(); 90 | s.addCondition("quicksearch-titleCreatorYear", "contains", text); 91 | s.addCondition("itemType", "isNot", "attachment"); 92 | let ids = await s.search(); 93 | // prompt.exit will remove current container element. 94 | // @ts-ignore ignore 95 | prompt.exit(); 96 | const container = prompt.createCommandsContainer(); 97 | container.classList.add("suggestions"); 98 | ids = filter(ids); 99 | console.log(ids.length); 100 | if (ids.length == 0) { 101 | const s = new Zotero.Search(); 102 | const operators = [ 103 | "is", 104 | "isNot", 105 | "true", 106 | "false", 107 | "isInTheLast", 108 | "isBefore", 109 | "isAfter", 110 | "contains", 111 | "doesNotContain", 112 | "beginsWith", 113 | ]; 114 | let hasValidCondition = false; 115 | let joinMode = "all"; 116 | if (/\s*\|\|\s*/.test(text)) { 117 | joinMode = "any"; 118 | } 119 | text.split(/\s*(&&|\|\|)\s*/g).forEach((conditinString: string) => { 120 | const conditions = conditinString.split(/\s+/g); 121 | if ( 122 | conditions.length == 3 && 123 | operators.indexOf(conditions[1]) != -1 124 | ) { 125 | hasValidCondition = true; 126 | s.addCondition( 127 | "joinMode", 128 | joinMode as _ZoteroTypes.Search.Operator, 129 | "", 130 | ); 131 | s.addCondition( 132 | conditions[0] as string, 133 | conditions[1] as _ZoteroTypes.Search.Operator, 134 | conditions[2] as string, 135 | ); 136 | } 137 | }); 138 | if (hasValidCondition) { 139 | ids = await s.search(); 140 | } 141 | } 142 | ids = filter(ids); 143 | console.log(ids.length); 144 | if (ids.length > 0) { 145 | ids.forEach((id: number) => { 146 | const item = Zotero.Items.get(id); 147 | const title = item.getField("title"); 148 | const ele = ztoolkit.UI.createElement(window.document!, "div", { 149 | namespace: "html", 150 | classList: ["command"], 151 | listeners: [ 152 | { 153 | type: "mousemove", 154 | listener: function () { 155 | // @ts-ignore ignore 156 | prompt.selectItem(this); 157 | }, 158 | }, 159 | { 160 | type: "click", 161 | listener: () => { 162 | prompt.promptNode.style.display = "none"; 163 | ztoolkit.getGlobal("Zotero_Tabs").select("zotero-pane"); 164 | ztoolkit.getGlobal("ZoteroPane").selectItem(item.id); 165 | }, 166 | }, 167 | ], 168 | styles: { 169 | display: "flex", 170 | flexDirection: "column", 171 | justifyContent: "start", 172 | }, 173 | children: [ 174 | { 175 | tag: "span", 176 | styles: { 177 | fontWeight: "bold", 178 | overflow: "hidden", 179 | textOverflow: "ellipsis", 180 | whiteSpace: "nowrap", 181 | }, 182 | properties: { 183 | innerText: title, 184 | }, 185 | }, 186 | { 187 | tag: "span", 188 | styles: { 189 | overflow: "hidden", 190 | textOverflow: "ellipsis", 191 | whiteSpace: "nowrap", 192 | }, 193 | properties: { 194 | innerHTML: getItemDescription(item), 195 | }, 196 | }, 197 | ], 198 | }); 199 | container.appendChild(ele); 200 | }); 201 | } else { 202 | // @ts-ignore ignore 203 | prompt.exit(); 204 | prompt.showTip("Not Found."); 205 | } 206 | }, 207 | }, 208 | ]); 209 | } 210 | 211 | export function registerConditionalCommandExample() { 212 | ztoolkit.Prompt.register([ 213 | { 214 | name: "Conditional Command Test", 215 | label: "Plugin Template", 216 | // The when function is executed when Prompt UI is woken up by `Shift + P`, and this command does not display when false is returned. 217 | when: () => { 218 | const items = ztoolkit.getGlobal("ZoteroPane").getSelectedItems(); 219 | return items.length > 0; 220 | }, 221 | callback(prompt) { 222 | prompt.inputNode.placeholder = "Hello World!"; 223 | const items = ztoolkit.getGlobal("ZoteroPane").getSelectedItems(); 224 | ztoolkit.getGlobal("alert")( 225 | `You select ${items.length} items!\n\n${items 226 | .map( 227 | (item, index) => 228 | String(index + 1) + ". " + item.getDisplayTitle(), 229 | ) 230 | .join("\n")}`, 231 | ); 232 | }, 233 | }, 234 | ]); 235 | } 236 | -------------------------------------------------------------------------------- /src/modules/shortcuts.ts: -------------------------------------------------------------------------------- 1 | export function registerShortcuts() { 2 | ztoolkit.Keyboard.register((ev, data) => { 3 | if (ev.shiftKey && ev.key === "A") { 4 | addon.hooks.onShortcuts("translate"); 5 | } 6 | if (ev.shiftKey && ev.key === "T") { 7 | addon.hooks.onShortcuts("showTaskManager"); 8 | } 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/toolbar.ts: -------------------------------------------------------------------------------- 1 | import { UITool } from "zotero-plugin-toolkit"; 2 | import { getString } from "../utils/locale"; 3 | import { showTaskManager } from "./translate/task-manager"; 4 | 5 | export function registerToolbar() { 6 | const ui = new UITool(); 7 | const document = ztoolkit.getGlobal("document"); 8 | const toolbarIcon = `chrome://${addon.data.config.addonRef}/content/icons/icon.svg`; 9 | const ariaBtn = ui.createElement(document, "toolbarbutton", { 10 | id: "zotero-tb-imt", 11 | removeIfExists: true, 12 | attributes: { 13 | class: "zotero-tb-button", 14 | tooltiptext: getString("menuView-tasks"), 15 | style: `list-style-image: url(${toolbarIcon})`, 16 | }, 17 | listeners: [ 18 | { 19 | type: "click", 20 | listener: () => { 21 | showTaskManager(); 22 | }, 23 | }, 24 | ], 25 | }); 26 | const toolbarNode = document.getElementById("zotero-tb-note-add"); 27 | if (toolbarNode) { 28 | toolbarNode.after(ariaBtn); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/modules/translate/confirm-dialog.ts: -------------------------------------------------------------------------------- 1 | import { translateModels, translateModes } from "../../config"; 2 | import { getString } from "../../utils/locale"; 3 | import { getPref, setPref } from "../../utils/prefs"; 4 | import { getLanguageOptions } from "../language"; 5 | import type { Language } from "../language/types"; 6 | 7 | export async function showConfirmationDialog(): Promise<{ 8 | action: "confirm" | "cancel"; 9 | data?: { 10 | targetLanguage: Language; 11 | translateMode: string; 12 | translateModel: string; 13 | enhanceCompatibility: boolean; 14 | ocrWorkaround: boolean; 15 | }; 16 | }> { 17 | const dialogData: { [key: string | number]: any } = { 18 | targetLanguage: getPref("targetLanguage"), 19 | translateMode: getPref("translateMode"), 20 | translateModel: getPref("translateModel"), 21 | enhanceCompatibility: getPref("enhanceCompatibility"), 22 | ocrWorkaround: getPref("ocrWorkaround"), 23 | }; 24 | const currentTargetLang = getPref("targetLanguage") as Language; 25 | const dialogHelper = new ztoolkit.Dialog(11, 4) 26 | .addCell(0, 0, { 27 | tag: "h2", 28 | properties: { 29 | innerHTML: getString("confirm-options"), 30 | }, 31 | styles: { 32 | width: "300px", 33 | }, 34 | }) 35 | .addCell(1, 0, { 36 | tag: "label", 37 | namespace: "html", 38 | properties: { 39 | innerHTML: getString("confirm-target-language"), 40 | }, 41 | styles: { 42 | width: "200px", 43 | }, 44 | }) 45 | .addCell( 46 | 2, 47 | 0, 48 | { 49 | tag: "select", 50 | id: "targetLanguage", 51 | attributes: { 52 | "data-bind": "targetLanguage", 53 | "data-prop": "value", 54 | }, 55 | children: getLanguageOptions(Zotero.locale as Language).map( 56 | (lang: { value: string; label: string }) => ({ 57 | tag: "option", 58 | properties: { 59 | value: lang.value, 60 | innerHTML: lang.label, 61 | }, 62 | }), 63 | ), 64 | styles: { 65 | width: "200px", 66 | height: "30px", 67 | margin: "3px 0", 68 | }, 69 | }, 70 | false, 71 | ) 72 | .addCell(3, 0, { 73 | tag: "label", 74 | namespace: "html", 75 | properties: { 76 | innerHTML: getString("confirm-translate-mode"), 77 | }, 78 | styles: { 79 | width: "200px", 80 | }, 81 | }) 82 | .addCell( 83 | 4, 84 | 0, 85 | { 86 | tag: "select", 87 | id: "translateMode", 88 | attributes: { 89 | "data-bind": "translateMode", 90 | "data-prop": "value", 91 | }, 92 | children: translateModes.map( 93 | (mode: { value: string; label: string }) => ({ 94 | tag: "option", 95 | properties: { 96 | value: mode.value, 97 | innerHTML: getString(mode.label), 98 | }, 99 | }), 100 | ), 101 | styles: { 102 | width: "200px", 103 | height: "30px", 104 | margin: "3px 0", 105 | }, 106 | }, 107 | false, 108 | ) 109 | .addCell(5, 0, { 110 | tag: "label", 111 | namespace: "html", 112 | properties: { 113 | innerHTML: getString("confirm-translate-model"), 114 | }, 115 | styles: { 116 | width: "200px", 117 | }, 118 | }) 119 | .addCell( 120 | 6, 121 | 0, 122 | { 123 | tag: "select", 124 | id: "translateModel", 125 | attributes: { 126 | "data-bind": "translateModel", 127 | "data-prop": "value", 128 | }, 129 | children: translateModels.map( 130 | (model: { value: string; label: string }) => ({ 131 | tag: "option", 132 | properties: { 133 | value: model.value, 134 | innerHTML: getString(model.label), 135 | }, 136 | }), 137 | ), 138 | styles: { 139 | width: "200px", 140 | height: "30px", 141 | margin: "3px 0", 142 | }, 143 | }, 144 | false, 145 | ) 146 | .addCell( 147 | 7, 148 | 0, 149 | { 150 | tag: "input", 151 | namespace: "html", 152 | id: "enhanceCompatibility", 153 | attributes: { 154 | "data-bind": "enhanceCompatibility", 155 | "data-prop": "checked", 156 | type: "checkbox", 157 | }, 158 | properties: { label: getString("confirm-enable-compatibility") }, 159 | }, 160 | false, 161 | ) 162 | .addCell(7, 1, { 163 | tag: "label", 164 | namespace: "html", 165 | attributes: { 166 | for: "enhanceCompatibility", 167 | }, 168 | properties: { innerHTML: getString("confirm-enable-compatibility") }, 169 | styles: { 170 | width: "200px", 171 | }, 172 | }) 173 | .addCell(8, 0, { 174 | tag: "p", 175 | namespace: "html", 176 | properties: { 177 | innerHTML: getString("confirm-enable-compatibility-description"), 178 | }, 179 | styles: { 180 | width: "400px", 181 | }, 182 | }) 183 | .addCell( 184 | 9, 185 | 0, 186 | { 187 | tag: "input", 188 | namespace: "html", 189 | id: "ocrWorkaround", 190 | attributes: { 191 | "data-bind": "ocrWorkaround", 192 | "data-prop": "checked", 193 | type: "checkbox", 194 | }, 195 | properties: { label: getString("confirm-enable-ocr-workaround") }, 196 | }, 197 | false, 198 | ) 199 | .addCell(9, 1, { 200 | tag: "label", 201 | namespace: "html", 202 | attributes: { 203 | for: "ocrWorkaround", 204 | }, 205 | properties: { innerHTML: getString("confirm-enable-ocr-workaround") }, 206 | styles: { 207 | width: "200px", 208 | }, 209 | }) 210 | .addCell(10, 0, { 211 | tag: "p", 212 | namespace: "html", 213 | properties: { 214 | innerHTML: getString("confirm-enable-ocr-workaround-description"), 215 | }, 216 | styles: { 217 | width: "400px", 218 | }, 219 | }) 220 | .addButton(getString("confirm-yes"), "confirm") 221 | .addButton(getString("confirm-cancel"), "cancel") 222 | .setDialogData(dialogData) 223 | .open(getString("confirm-title")); 224 | 225 | addon.data.dialog = dialogHelper; 226 | await dialogData.unloadLock.promise; 227 | addon.data.dialog = undefined; 228 | if (addon.data.alive) { 229 | if (dialogData._lastButtonId === "confirm") { 230 | setPref("targetLanguage", dialogData.targetLanguage); 231 | setPref("translateMode", dialogData.translateMode); 232 | setPref("translateModel", dialogData.translateModel); 233 | setPref("enhanceCompatibility", dialogData.enhanceCompatibility); 234 | setPref("ocrWorkaround", dialogData.ocrWorkaround); 235 | return { 236 | action: "confirm", 237 | data: dialogData as { 238 | targetLanguage: Language; 239 | translateMode: string; 240 | translateModel: string; 241 | enhanceCompatibility: boolean; 242 | ocrWorkaround: boolean; 243 | }, 244 | }; 245 | } else { 246 | return { 247 | action: "cancel", 248 | }; 249 | } 250 | } 251 | return { 252 | action: "cancel", 253 | }; 254 | } 255 | -------------------------------------------------------------------------------- /src/modules/translate/persistence.ts: -------------------------------------------------------------------------------- 1 | import { setTask, clearTasks, getTaskKeys, getTaskText } from "./store"; 2 | import type { TranslationTaskData } from "../../types"; 3 | 4 | /** 5 | * 加载已保存的翻译任务数据 6 | */ 7 | export function loadSavedTranslationData() { 8 | try { 9 | // 加载翻译任务列表 10 | const taskKeys = getTaskKeys(); 11 | const savedTaskList = taskKeys.map((key) => getTaskText(key)); 12 | if (savedTaskList && savedTaskList.length > 0) { 13 | // 在加载到全局变量前进行去重 14 | const dedupedTasks = removeDuplicateTasks(savedTaskList); 15 | 16 | // 记录清理信息 17 | if (savedTaskList.length !== dedupedTasks.length) { 18 | ztoolkit.log( 19 | `加载时清理了${savedTaskList.length - dedupedTasks.length}条重复记录,保留${dedupedTasks.length}条唯一记录`, 20 | ); 21 | } 22 | 23 | // 将去重后的数据赋值给全局变量 24 | addon.data.task.translationTaskList = dedupedTasks; 25 | ztoolkit.log( 26 | "已加载保存的翻译任务列表", 27 | addon.data.task.translationTaskList, 28 | ); 29 | } 30 | } catch (error) { 31 | ztoolkit.log("加载保存的翻译数据时出错", error); 32 | // 如果出错,使用空数组初始化 33 | addon.data.task.translationTaskList = []; 34 | } 35 | } 36 | 37 | /** 38 | * 从任务列表中移除重复数据 39 | * @param tasks 待处理的任务列表 40 | * @returns 去重后的任务列表 41 | */ 42 | function removeDuplicateTasks(tasks: any[]): any[] { 43 | if (!tasks || tasks.length === 0) { 44 | return []; 45 | } 46 | 47 | // 创建一个映射,记录每个attachmentId对应的最新任务索引 48 | const latestTaskIndices = new Map(); 49 | 50 | // 从后向前遍历,确保保留最新的记录 51 | for (let i = tasks.length - 1; i >= 0; i--) { 52 | const task = tasks[i]; 53 | const attachmentId = task.attachmentId; 54 | 55 | if (!latestTaskIndices.has(attachmentId)) { 56 | latestTaskIndices.set(attachmentId, i); 57 | } 58 | } 59 | 60 | // 根据最新任务索引创建新的任务列表 61 | return Array.from(latestTaskIndices.values()) 62 | .sort((a, b) => a - b) // 按原顺序排列 63 | .map((index) => tasks[index]); 64 | } 65 | 66 | /** 67 | * 恢复未完成的翻译任务 68 | * 从translationTaskList中找出未完成的任务恢复到队列中 69 | * @returns 恢复的任务数量 70 | */ 71 | export function restoreUnfinishedTasks(): number { 72 | try { 73 | // 清空当前队列,避免重复 74 | addon.data.task.translationGlobalQueue = []; 75 | 76 | // 找出状态不是success或failed的任务 77 | const unfinishedTasks = addon.data.task.translationTaskList.filter( 78 | (task: any) => { 79 | const status = task.status || ""; 80 | return ( 81 | status !== "success" && status !== "failed" && status !== "canceled" 82 | ); 83 | }, 84 | ); 85 | 86 | if (unfinishedTasks.length === 0) { 87 | ztoolkit.log("没有未完成的翻译任务需要恢复"); 88 | return 0; 89 | } 90 | 91 | ztoolkit.log( 92 | `找到${unfinishedTasks.length}个未完成的翻译任务,开始检查是否可恢复`, 93 | ); 94 | 95 | // 检查附件是否仍然存在 96 | let numRestored = 0; 97 | for (const task of unfinishedTasks) { 98 | const attachmentId = task.attachmentId; 99 | 100 | try { 101 | const attachment = Zotero.Items.get(attachmentId); 102 | 103 | if (!attachment || !attachment.isAttachment()) { 104 | ztoolkit.log( 105 | `附件ID ${attachmentId} (${task.attachmentFilename}) 已不存在,跳过恢复`, 106 | ); 107 | continue; 108 | } 109 | 110 | const isInQueue = addon.data.task.translationGlobalQueue.find( 111 | (t: any) => t.attachmentId === attachmentId, 112 | ); 113 | if (isInQueue) { 114 | ztoolkit.log("任务已存在于队列中,跳过恢复"); 115 | continue; 116 | } else { 117 | addon.data.task.translationGlobalQueue.push(task); 118 | numRestored++; 119 | } 120 | } catch (error) { 121 | ztoolkit.log( 122 | `检查附件ID ${attachmentId} 是否存在时出错,跳过恢复`, 123 | error, 124 | ); 125 | } 126 | } 127 | ztoolkit.log(`已恢复${numRestored}个未完成任务`); 128 | return numRestored; 129 | } catch (error) { 130 | ztoolkit.log("恢复未完成任务时出错", error); 131 | return 0; 132 | } 133 | } 134 | 135 | /** 136 | * 保存当前的翻译任务数据 137 | */ 138 | export function saveTranslationData() { 139 | clearTasks(); 140 | try { 141 | // 保存翻译任务列表 142 | if ( 143 | addon.data.task.translationTaskList && 144 | addon.data.task.translationTaskList.length > 0 145 | ) { 146 | addon.data.task.translationTaskList 147 | .filter( 148 | (task: any) => 149 | task.status !== "success" && 150 | task.status !== "failed" && 151 | task.status !== "canceled", 152 | ) 153 | .forEach((task: TranslationTaskData) => setTask(task)); 154 | } 155 | } catch (error) { 156 | ztoolkit.log("保存翻译数据时出错", error); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/modules/translate/store.ts: -------------------------------------------------------------------------------- 1 | import { config } from "../../../package.json"; 2 | import { TranslationTaskData } from "../../types"; 3 | 4 | function initTasks() { 5 | addon.data.task.data = new ztoolkit.LargePrefObject( 6 | `${config.prefsPrefix}.taskKeys`, 7 | `${config.prefsPrefix}.task.`, 8 | "parser", 9 | ); 10 | } 11 | 12 | function clearTasks() { 13 | const taskKeys = getTaskKeys(); 14 | for (const key of taskKeys) { 15 | removeTask(key); 16 | } 17 | } 18 | 19 | function getTaskKeys(): string[] { 20 | return addon.data.task.data?.getKeys() || []; 21 | } 22 | 23 | function setTaskKeys(taskKeys: string[]): void { 24 | addon.data.task.data?.setKeys(taskKeys); 25 | } 26 | 27 | function getTaskText(keyName: string): string { 28 | return addon.data.task.data?.getValue(keyName) || ""; 29 | } 30 | 31 | function setTask(task: TranslationTaskData): void { 32 | addon.data.task.data?.setValue(task.attachmentId.toString(), task); 33 | } 34 | 35 | function removeTask(keyName: string | undefined): void { 36 | if (!keyName) { 37 | return; 38 | } 39 | addon.data.task.data?.deleteKey(keyName); 40 | } 41 | 42 | export { getTaskKeys, getTaskText, setTask, removeTask, initTasks, clearTasks }; 43 | -------------------------------------------------------------------------------- /src/modules/translate/task-manager.ts: -------------------------------------------------------------------------------- 1 | import { isWindowAlive } from "../../utils/window"; 2 | import { config } from "../../../package.json"; 3 | import { getString } from "../../utils/locale"; 4 | import { saveTranslationData } from "./persistence"; 5 | import { Status, TranslationTaskData } from "../../types"; 6 | import { getTranslateModeLabel, getTranslateModelLabel } from "../../config"; 7 | import { Language } from "../language/types"; 8 | import { getLanguageName } from "../language"; 9 | import { showDialog } from "../../utils/dialog"; 10 | import { startQueueProcessing } from "./task"; 11 | import { APP_SITE_URL, TEST_APP_SITE_URL } from "../../utils/const"; 12 | 13 | /** 14 | * 显示翻译任务列表的弹窗 15 | */ 16 | export async function showTaskManager() { 17 | // 创建对话框 18 | if (isWindowAlive(addon.data.task.window)) { 19 | addon.data.task.window?.focus(); 20 | refresh(); 21 | } else { 22 | const windowArgs = { 23 | _initPromise: Zotero.Promise.defer(), 24 | }; 25 | const win = Zotero.getMainWindow().openDialog( 26 | `chrome://${config.addonRef}/content/taskManager.xhtml`, 27 | `${config.addonRef}-taskManager`, 28 | `chrome,centerscreen,resizable,status,dialog=no`, 29 | windowArgs, 30 | )!; 31 | await windowArgs._initPromise.promise; 32 | addon.data.task.window = win; 33 | addon.data.task.tableHelper = new ztoolkit.VirtualizedTable(win!) 34 | .setContainerId("table-container") 35 | .setProp({ 36 | id: "manager-table", 37 | // Do not use setLocale, as it modifies the Zotero.Intl.strings 38 | // Set locales directly to columns 39 | columns: [ 40 | { 41 | dataKey: "parentItemTitle", 42 | label: getString("column-item"), 43 | fixedWidth: false, 44 | }, 45 | { 46 | dataKey: "attachmentFilename", 47 | label: getString("column-attachment"), 48 | fixedWidth: false, 49 | }, 50 | { 51 | dataKey: "targetLanguage", 52 | label: getString("column-target-language"), 53 | fixedWidth: false, 54 | }, 55 | { 56 | dataKey: "translateModel", 57 | label: getString("column-translate-model"), 58 | fixedWidth: false, 59 | }, 60 | { 61 | dataKey: "translateMode", 62 | label: getString("column-translate-mode"), 63 | fixedWidth: false, 64 | }, 65 | { 66 | dataKey: "pdfId", 67 | label: getString("column-pdfId"), 68 | fixedWidth: false, 69 | }, 70 | { 71 | dataKey: "status", 72 | label: getString("column-status"), 73 | fixedWidth: false, 74 | }, 75 | { 76 | dataKey: "stage", 77 | label: getString("column-stage"), 78 | fixedWidth: false, 79 | }, 80 | { 81 | dataKey: "progress", 82 | label: getString("column-progress"), 83 | fixedWidth: false, 84 | }, 85 | { 86 | dataKey: "error", 87 | label: getString("column-error"), 88 | fixedWidth: false, 89 | }, 90 | ].map((column) => 91 | Object.assign(column, { 92 | label: column.label, 93 | }), 94 | ), 95 | showHeader: true, 96 | multiSelect: false, 97 | staticColumns: false, 98 | disableFontSizeScaling: true, 99 | }) 100 | .setProp("getRowCount", () => addon.data.task.translationTaskList.length) 101 | .setProp("getRowData", (index) => { 102 | const task = addon.data.task.translationTaskList[index]; 103 | return { 104 | status: getStatusText(task.status), 105 | progress: `${task.progress || "0"}%`, 106 | parentItemTitle: task.parentItemTitle || "-", 107 | attachmentFilename: task.attachmentFilename || "", 108 | targetLanguage: 109 | getLanguageName(task.targetLanguage, Zotero.locale as Language) || 110 | "", 111 | translateModel: getTranslateModelLabel(task.translateModel) || "", 112 | translateMode: getTranslateModeLabel(task.translateMode) || "", 113 | stage: getStageText(task.stage) || "", 114 | pdfId: task.pdfId || "-", 115 | error: task.error || "-", 116 | resultAttachmentId: task.resultAttachmentId?.toString() || "", 117 | }; 118 | }) 119 | .setProp("onActivate", () => { 120 | const tasks = getSelectedTasks(); 121 | if (tasks.length > 0) { 122 | const task = tasks[0]; 123 | if (task.pdfId) { 124 | new ztoolkit.Clipboard().addText(task.pdfId, "text/unicode").copy(); 125 | showDialog({ 126 | title: getString("task-copy-success"), 127 | }); 128 | } 129 | } 130 | return true; 131 | }) 132 | .setProp( 133 | "getRowString", 134 | (index) => 135 | addon.data.task.translationTaskList[index].parentItemTitle || "", 136 | ) 137 | .render(); 138 | const refreshButton = win.document.querySelector( 139 | "#refresh", 140 | ) as HTMLButtonElement; 141 | const copyPdfIdButton = win.document.querySelector( 142 | "#copy-pdf-id", 143 | ) as HTMLButtonElement; 144 | const cancelButton = win.document.querySelector( 145 | "#cancel", 146 | ) as HTMLButtonElement; 147 | const viewPdfButton = win.document.querySelector( 148 | "#view-pdf", 149 | ) as HTMLButtonElement; 150 | const retryButton = win.document.querySelector( 151 | "#retry", 152 | ) as HTMLButtonElement; 153 | const feedbackButton = win.document.querySelector( 154 | "#feedback", 155 | ) as HTMLButtonElement; 156 | viewPdfButton.addEventListener("click", (ev) => { 157 | const tasks = getSelectedTasks(); 158 | if (tasks.length > 0) { 159 | const task = tasks[0]; 160 | if (task.resultAttachmentId) { 161 | const resultAttachment = Zotero.Items.get(task.resultAttachmentId); 162 | if (resultAttachment) { 163 | Zotero.Reader.open(resultAttachment.id); 164 | } 165 | } else { 166 | showDialog({ 167 | title: getString("task-uncomplete"), 168 | }); 169 | } 170 | } else { 171 | showDialog({ 172 | title: getString("task-select-tip"), 173 | }); 174 | } 175 | }); 176 | 177 | retryButton.addEventListener("click", (ev) => { 178 | const tasks = getSelectedTasks(); 179 | if (tasks.length > 0) { 180 | const task = tasks[0]; 181 | if (task.status === "failed") { 182 | // Update task status to queued 183 | updateTaskInList(task.attachmentId, { 184 | error: "", 185 | }); 186 | 187 | // Add back to the global queue if not already there 188 | if ( 189 | !addon.data.task.translationGlobalQueue.some( 190 | (t) => t.attachmentId === task.attachmentId, 191 | ) 192 | ) { 193 | addon.data.task.translationGlobalQueue.unshift(task); 194 | } 195 | 196 | startQueueProcessing(); 197 | refresh(); 198 | 199 | showDialog({ 200 | title: getString("task-retry-success"), 201 | }); 202 | } else { 203 | showDialog({ 204 | title: getString("task-retry-tip"), 205 | }); 206 | } 207 | } else { 208 | showDialog({ 209 | title: getString("task-select-tip"), 210 | }); 211 | } 212 | }); 213 | 214 | feedbackButton.addEventListener("click", (ev) => { 215 | const tasks = getSelectedTasks(); 216 | if (tasks.length > 0) { 217 | const task = tasks[0]; 218 | if (task.pdfId && task.status !== "translating") { 219 | const APP_URL = 220 | addon.data.env === "development" ? TEST_APP_SITE_URL : APP_SITE_URL; 221 | Zotero.launchURL(`${APP_URL}/babel-doc/${task.pdfId}?from=zotero`); 222 | } else { 223 | Zotero.launchURL( 224 | `https://github.com/immersive-translate/zotero-immersivetranslate?tab=readme-ov-file#%E5%8F%8D%E9%A6%88`, 225 | ); 226 | } 227 | } else { 228 | showDialog({ 229 | title: getString("task-select-tip"), 230 | }); 231 | } 232 | }); 233 | 234 | refreshButton.addEventListener("click", (ev) => { 235 | refresh(); 236 | }); 237 | copyPdfIdButton.addEventListener("click", (ev) => { 238 | const tasks = getSelectedTasks(); 239 | if (tasks.length > 0) { 240 | const task = tasks[0]; 241 | if (task.pdfId) { 242 | new ztoolkit.Clipboard().addText(task.pdfId, "text/unicode").copy(); 243 | showDialog({ 244 | title: getString("task-copy-success"), 245 | }); 246 | } 247 | } else { 248 | showDialog({ 249 | title: getString("task-select-tip"), 250 | }); 251 | } 252 | }); 253 | cancelButton.addEventListener("click", (ev) => { 254 | const tasks = getSelectedTasks(); 255 | if (tasks.length > 0) { 256 | const task = tasks[0]; 257 | if (task.status === "queued") { 258 | cancelTask(task); 259 | refresh(); 260 | showDialog({ 261 | title: getString("task-cancel-success"), 262 | }); 263 | } else { 264 | showDialog({ 265 | title: getString("task-cancel-tip"), 266 | }); 267 | } 268 | } else { 269 | showDialog({ 270 | title: getString("task-select-tip"), 271 | }); 272 | } 273 | }); 274 | createWindowBoundInterval( 275 | () => { 276 | refresh(); 277 | }, 278 | 500, 279 | win, 280 | ); 281 | } 282 | } 283 | 284 | async function updateTable() { 285 | const keys = 286 | addon.data.task.tableHelper?.treeInstance.selection.selected?.keys() || []; 287 | let id = undefined; 288 | for (const key of keys) { 289 | id = key; 290 | } 291 | return new Promise((resolve) => { 292 | addon.data.task.tableHelper?.render(id, (_: any) => { 293 | resolve(); 294 | }); 295 | }); 296 | } 297 | 298 | async function refresh() { 299 | await updateTable(); 300 | } 301 | 302 | // Helper function to update a task in the translationTaskList 303 | export function updateTaskInList( 304 | attachmentId: number, 305 | updates: { 306 | status?: Status; 307 | stage?: string; 308 | pdfId?: string; 309 | progress?: number; 310 | resultAttachmentId?: number; 311 | error?: string; 312 | }, 313 | ) { 314 | if (!addon.data.task.translationTaskList) return; 315 | 316 | // Find the task from the end of the array (most recent task) - simple approach 317 | let taskIndex = -1; 318 | for (let i = addon.data.task.translationTaskList.length - 1; i >= 0; i--) { 319 | if (addon.data.task.translationTaskList[i].attachmentId === attachmentId) { 320 | taskIndex = i; 321 | break; 322 | } 323 | } 324 | 325 | if (taskIndex !== -1) { 326 | addon.data.task.translationTaskList[taskIndex] = { 327 | ...addon.data.task.translationTaskList[taskIndex], 328 | ...updates, 329 | }; 330 | } 331 | 332 | // Call saveTranslationData after updating the task list 333 | saveTranslationData(); 334 | } 335 | 336 | function getSelectedTasks() { 337 | const keys = 338 | addon.data.task.tableHelper?.treeInstance.selection.selected?.keys() || []; 339 | const datas = []; 340 | for (const key of keys) { 341 | const data = addon.data.task.translationTaskList[key]; 342 | datas.push(data); 343 | } 344 | return datas; 345 | } 346 | 347 | function cancelTask(task: TranslationTaskData) { 348 | updateTaskInList(task.attachmentId, { 349 | status: "canceled", 350 | }); 351 | addon.data.task.translationGlobalQueue.splice( 352 | addon.data.task.translationGlobalQueue.findIndex( 353 | (t) => t.attachmentId === task.attachmentId, 354 | ), 355 | 1, 356 | ); 357 | saveTranslationData(); 358 | } 359 | 360 | /** 361 | * Creates a recurring interval that automatically cleans up when the window is closed 362 | * @param callback Function to execute at each interval 363 | * @param delay Time in milliseconds between executions 364 | * @param window Window to attach the interval to 365 | * @returns Interval ID that can be used with clearInterval if needed 366 | */ 367 | export function createWindowBoundInterval( 368 | callback: () => void, 369 | delay: number, 370 | window: Window, 371 | ): ReturnType { 372 | const intervalId = setInterval(callback, delay); 373 | 374 | // Add unload listener to clean up the interval when the window closes 375 | window.addEventListener( 376 | "unload", 377 | () => { 378 | clearInterval(intervalId); 379 | }, 380 | { once: true }, 381 | ); 382 | 383 | return intervalId; 384 | } 385 | 386 | function getStatusText(status?: string) { 387 | const statusMap: Record = { 388 | queued: getString("task-status-queued"), 389 | uploading: getString("task-status-uploading"), 390 | translating: getString("task-status-translating"), 391 | success: getString("task-status-success"), 392 | failed: getString("task-status-failed"), 393 | canceled: getString("task-status-canceled"), 394 | }; 395 | 396 | if (!status) return ""; 397 | 398 | return statusMap[status]; 399 | } 400 | 401 | function getStageText(stage?: string) { 402 | if (!stage) return ""; 403 | const translationMap: { [key: string]: string } = { 404 | queued: getString("task-stage-queued"), 405 | uploading: getString("task-stage-uploading"), 406 | downloading: getString("task-stage-downloading"), 407 | completed: getString("task-stage-completed"), 408 | "Parse PDF and Create Intermediate Representation": getString( 409 | "task-stage-parse-pdf", 410 | ), 411 | DetectScannedFile: getString("task-stage-DetectScannedFile"), 412 | "Parse Page Layout": getString("task-stage-ParseLayout"), 413 | "Parse Paragraphs": getString("task-stage-ParseParagraphs"), 414 | "Parse Formulas and Styles": getString("task-stage-ParseFormulasAndStyles"), 415 | "Remove Char Descent": getString("task-stage-RemoveCharDescent"), 416 | "Translate Paragraphs": getString("task-stage-TranslateParagraphs"), 417 | Typesetting: getString("task-stage-Typesetting"), 418 | "Add Fonts": getString("task-stage-AddFonts"), 419 | "Generate drawing instructions": getString( 420 | "task-stage-GenerateDrawingInstructions", 421 | ), 422 | "Subset font": getString("task-stage-SubsetFont"), 423 | "Save PDF": getString("task-stage-SavePDF"), 424 | "prepare file download": getString("task-stage-prepareFileDownload"), 425 | "Prepare File Download": getString("task-stage-SavePDF"), 426 | "Parse Table": getString("task-stage-ParseTable"), 427 | "Waiting in line": getString("task-stage-WaitingInLine"), 428 | "Create Task": getString("task-stage-CreateTask"), 429 | }; 430 | 431 | return translationMap[stage] || stage; 432 | } 433 | -------------------------------------------------------------------------------- /src/modules/translate/task-monitor.ts: -------------------------------------------------------------------------------- 1 | import { TranslationTaskData } from "../../types"; 2 | import { getPref } from "../../utils/prefs"; 3 | import { updateTaskInList } from "./task-manager"; 4 | 5 | const ATTR_TAG = "BabelDOC_translated"; 6 | 7 | // Add a task monitor singleton to track all translation tasks 8 | export const TranslationTaskMonitor = { 9 | activeTasks: new Map< 10 | string, 11 | { 12 | taskData: TranslationTaskData; 13 | parentItem?: Zotero.Item; 14 | } 15 | >(), 16 | pollingInterval: 3000, 17 | isPolling: false, 18 | 19 | // Add a task to the monitor 20 | addTask( 21 | pdfId: string, 22 | taskData: TranslationTaskData, 23 | parentItem?: Zotero.Item, 24 | ) { 25 | this.activeTasks.set(pdfId, { taskData, parentItem }); 26 | 27 | // Start polling if not already running 28 | if (!this.isPolling) { 29 | this.startPolling(); 30 | } 31 | 32 | ztoolkit.log( 33 | `Added task to monitor: ${pdfId} (${taskData.attachmentFilename})`, 34 | ); 35 | }, 36 | 37 | // Start the central polling process 38 | async startPolling() { 39 | if (this.isPolling) return; 40 | 41 | this.isPolling = true; 42 | ztoolkit.log( 43 | `Starting centralized translation task monitor with ${this.activeTasks.size} tasks`, 44 | ); 45 | 46 | // Maximum number of concurrent requests to prevent overloading 47 | const MAX_CONCURRENT_REQUESTS = 6; 48 | 49 | while (this.activeTasks.size > 0) { 50 | // Get all current tasks 51 | const taskEntries = Array.from(this.activeTasks.entries()); 52 | // Process tasks in batches 53 | for (let i = 0; i < taskEntries.length; i += MAX_CONCURRENT_REQUESTS) { 54 | const batch = taskEntries.slice(i, i + MAX_CONCURRENT_REQUESTS); 55 | const batchPromises = batch.map(([pdfId, { taskData, parentItem }]) => 56 | this.checkTaskProgress(pdfId, taskData, parentItem), 57 | ); 58 | 59 | // Wait for the current batch to complete before processing the next batch 60 | await Promise.all(batchPromises); 61 | } 62 | 63 | // Wait before the next polling cycle 64 | await Zotero.Promise.delay(this.pollingInterval); 65 | } 66 | 67 | this.isPolling = false; 68 | ztoolkit.log("All translation tasks completed, stopping monitor"); 69 | }, 70 | 71 | // Check progress for a single task 72 | async checkTaskProgress( 73 | pdfId: string, 74 | taskData: TranslationTaskData, 75 | parentItem?: Zotero.Item, 76 | ) { 77 | try { 78 | const processStatus = await addon.api.getTranslateStatus({ pdfId }); 79 | const attachmentId = taskData.attachmentId; 80 | const attachmentFilename = taskData.attachmentFilename; 81 | 82 | ztoolkit.log( 83 | `Polling: Status for ${pdfId} (${attachmentFilename}):`, 84 | processStatus, 85 | ); 86 | 87 | // Update status and stage on parent item 88 | const currentStage = `${processStatus.currentStageName || "queued"}`; 89 | 90 | updateTaskInList(attachmentId, { 91 | status: "translating", 92 | stage: currentStage, 93 | progress: processStatus.overall_progress || 0, 94 | }); 95 | 96 | // --- Check for Success --- 97 | if ( 98 | processStatus.status === "ok" && 99 | processStatus.overall_progress === 100 100 | ) { 101 | ztoolkit.log( 102 | `Translation completed for PDF ID: ${pdfId} (${attachmentFilename})`, 103 | ); 104 | updateTaskInList(attachmentId, { 105 | status: "translating", 106 | stage: "downloading", 107 | progress: 100, 108 | }); 109 | 110 | // Download the result 111 | try { 112 | downloadTranslateResult({ pdfId, taskData, parentItem }); 113 | // Remove task from monitor after success 114 | this.activeTasks.delete(pdfId); 115 | } catch (downloadError: any) { 116 | ztoolkit.log( 117 | `ERROR: Failed to download result for ${pdfId} (${attachmentFilename}):`, 118 | downloadError.message || downloadError, 119 | ); 120 | updateTaskInList(attachmentId, { 121 | status: "failed", 122 | error: downloadError.message || "Failed to download result", 123 | }); 124 | // Remove failed task from monitor 125 | this.activeTasks.delete(pdfId); 126 | } 127 | return; 128 | } 129 | 130 | // --- Check for Failure --- 131 | if ( 132 | processStatus.status && 133 | processStatus.status !== "" && 134 | processStatus.status !== "ok" 135 | ) { 136 | const errorMsg = `Translation failed with status: ${processStatus.status}`; 137 | ztoolkit.log( 138 | `ERROR: ${errorMsg} for PDF ID: ${pdfId} (${attachmentFilename}).`, 139 | ); 140 | updateTaskInList(attachmentId, { 141 | status: "failed", 142 | error: processStatus.message, 143 | }); 144 | // Remove failed task from monitor 145 | this.activeTasks.delete(pdfId); 146 | } 147 | } catch (error: any) { 148 | // Handle API call errors 149 | const taskInfo = this.activeTasks.get(pdfId); 150 | if (taskInfo) { 151 | ztoolkit.log( 152 | `ERROR: During polling check for PDF ID ${pdfId} (${taskInfo.taskData.attachmentFilename}):`, 153 | error.message || error, 154 | ); 155 | 156 | updateTaskInList(taskInfo.taskData.attachmentId, { 157 | status: "failed", 158 | error: error.message || "Error checking translation status", 159 | }); 160 | } 161 | // Remove failed task from monitor 162 | this.activeTasks.delete(pdfId); 163 | } 164 | }, 165 | }; 166 | 167 | // Modified signature to use taskData 168 | async function downloadTranslateResult({ 169 | pdfId, 170 | taskData, // Use TranslationTaskData 171 | parentItem: parentItem, // Rename item to parentItem for clarity 172 | }: { 173 | pdfId: string; 174 | taskData: TranslationTaskData; 175 | parentItem?: Zotero.Item; 176 | }) { 177 | try { 178 | const result = await addon.api.getTranslatePdfResult({ pdfId }); 179 | ztoolkit.log( 180 | `Download Result Info for ${taskData.attachmentFilename}:`, 181 | result, 182 | ); 183 | const translateMode = taskData.translateMode; 184 | // Update the task status 185 | updateTaskInList(taskData.attachmentId, { 186 | status: "translating", 187 | stage: "downloading", 188 | progress: 100, 189 | }); 190 | 191 | // Handle the case where we want to download both types 192 | if (translateMode === "all") { 193 | // Download and process both PDF types 194 | const dualResult = await downloadAndProcessPdf({ 195 | fileUrl: result.translationDualPdfOssUrl, 196 | mode: "dual", 197 | pdfId, 198 | taskData, 199 | parentItem, 200 | }); 201 | 202 | const translationOnlyResult = await downloadAndProcessPdf({ 203 | fileUrl: result.translationOnlyPdfOssUrl, 204 | mode: "translation", 205 | pdfId, 206 | taskData, 207 | parentItem, 208 | }); 209 | 210 | // Update final status using the ID of the translation-only version 211 | updateTaskInList(taskData.attachmentId, { 212 | status: "success", 213 | stage: "completed", 214 | progress: 100, 215 | resultAttachmentId: dualResult.id, 216 | }); 217 | 218 | return; 219 | } 220 | 221 | // For single PDF download, determine which URL to use 222 | const fileUrl = 223 | translateMode === "dual" 224 | ? result.translationDualPdfOssUrl 225 | : result.translationOnlyPdfOssUrl; 226 | 227 | // Use the common helper function for single file download too 228 | const attachment = await downloadAndProcessPdf({ 229 | fileUrl, 230 | mode: translateMode, 231 | pdfId, 232 | taskData, 233 | parentItem, 234 | }); 235 | 236 | // Update final status in taskList 237 | updateTaskInList(taskData.attachmentId, { 238 | status: "success", 239 | stage: "completed", 240 | progress: 100, 241 | resultAttachmentId: attachment.id, 242 | }); 243 | } catch (error: any) { 244 | ztoolkit.log( 245 | `ERROR: Failed download/process for PDF ID ${pdfId} (${taskData.attachmentFilename}):`, 246 | error.message || error, 247 | ); 248 | throw error; 249 | } 250 | } 251 | 252 | // Helper function to download and process a single PDF file 253 | async function downloadAndProcessPdf({ 254 | fileUrl, 255 | mode, 256 | pdfId, 257 | taskData, 258 | parentItem, 259 | }: { 260 | fileUrl: string; 261 | mode: string; 262 | pdfId: string; 263 | taskData: TranslationTaskData; 264 | parentItem?: Zotero.Item; 265 | }) { 266 | if (!fileUrl) { 267 | throw new Error( 268 | `No download URL found for ${mode} mode (${taskData.attachmentFilename}).`, 269 | ); 270 | } 271 | 272 | ztoolkit.log( 273 | `File URL for ${taskData.attachmentFilename} (${mode}):`, 274 | fileUrl, 275 | ); 276 | 277 | const fileBuffer = await addon.api.downloadPdf(fileUrl); 278 | ztoolkit.log( 279 | `File downloaded for ${taskData.attachmentFilename} (${mode}, Size: ${fileBuffer.byteLength})`, 280 | ); 281 | 282 | if (fileBuffer.byteLength === 0) { 283 | throw new Error( 284 | `Downloaded file is empty for ${taskData.attachmentFilename} (${mode}).`, 285 | ); 286 | } 287 | 288 | const originalFilename = taskData.attachmentFilename; 289 | const baseName = originalFilename.replace(/\.pdf$/i, ""); 290 | const targetLanguage = taskData.targetLanguage; 291 | const fileName = `${baseName}_${targetLanguage}_${mode}.pdf`; 292 | 293 | const tempDir = PathUtils.tempDir || Zotero.getTempDirectory().path; 294 | const tempPath = PathUtils.join(tempDir, fileName); 295 | ztoolkit.log(`Writing downloaded file to temp path: ${tempPath}`); 296 | 297 | await IOUtils.write(tempPath, new Uint8Array(fileBuffer)); 298 | 299 | ztoolkit.log(`Importing attachment to item: ${taskData.parentItemId}`); 300 | 301 | const attachment = await Zotero.Attachments.importFromFile({ 302 | file: tempPath, 303 | parentItemID: taskData.parentItemId || undefined, 304 | collections: taskData.parentItemId 305 | ? undefined 306 | : Zotero.Items.get(taskData.attachmentId).getCollections(), 307 | libraryID: parentItem?.libraryID, 308 | title: fileName, 309 | contentType: "application/pdf", 310 | }); 311 | 312 | attachment.setTags([ATTR_TAG, pdfId]); 313 | 314 | ztoolkit.log( 315 | `Attachment created (ID: ${attachment.id}) for ${taskData.attachmentFilename} (${mode})`, 316 | ); 317 | 318 | await attachment.saveTx(); 319 | 320 | if (getPref("autoOpenPDF")) { 321 | Zotero.Reader.open(attachment.id); 322 | } 323 | 324 | try { 325 | await IOUtils.remove(tempPath); 326 | ztoolkit.log(`Removed temporary file: ${tempPath}`); 327 | } catch (removeError: any) { 328 | ztoolkit.log( 329 | `WARNING: Failed to remove temporary file ${tempPath}:`, 330 | removeError.message || removeError, 331 | ); 332 | } 333 | 334 | return attachment; 335 | } 336 | -------------------------------------------------------------------------------- /src/modules/translate/task.ts: -------------------------------------------------------------------------------- 1 | import { saveTranslationData } from "./persistence"; 2 | import { showTaskManager, updateTaskInList } from "./task-manager"; 3 | import type { TranslationTaskData } from "../../types"; 4 | import { getPref } from "../../utils/prefs"; 5 | import { showConfirmationDialog } from "./confirm-dialog"; 6 | import { translatePDF } from "./translate"; 7 | import { TranslationTaskMonitor } from "./task-monitor"; 8 | import { getString } from "../../utils/locale"; 9 | import { showDialog } from "../../utils/dialog"; 10 | import { Language } from "../language/types"; 11 | import { report } from "../../utils/report"; 12 | 13 | const ATTR_TAG = "BabelDOC_translated"; 14 | 15 | /** 16 | * Check if attachment is already in the translation task list 17 | */ 18 | export function isAttachmentInTaskList(attachmentId: number): boolean { 19 | return !!addon.data.task.translationTaskList?.find( 20 | (task) => 21 | task.attachmentId === attachmentId && 22 | task.status !== "success" && 23 | task.status !== "failed", 24 | ); 25 | } 26 | 27 | export async function addTasksToQueue(ids?: number[]) { 28 | const authkey = getPref("authkey"); 29 | if (!authkey) { 30 | showDialog({ 31 | title: getString("pref-test-failed-description"), 32 | }); 33 | return; 34 | } 35 | const result = await addon.api.checkAuthKey({ 36 | apiKey: authkey, 37 | }); 38 | if (!result) { 39 | showDialog({ 40 | title: getString("pref-test-failed-description"), 41 | }); 42 | return; 43 | } 44 | 45 | // 根据是否传入 ids 参数选择不同的获取任务方式 46 | const tasksToQueue = 47 | ids && ids.length > 0 48 | ? await getTranslationTasksByIds(ids) 49 | : await getTranslationTasks(); 50 | 51 | if (tasksToQueue.length === 0) { 52 | ztoolkit.log("No valid PDF attachments found to add to the queue."); 53 | showDialog({ 54 | title: getString("task-no-pdf"), 55 | }); 56 | return; 57 | } 58 | const translateMode = getPref("translateMode"); 59 | const translateModel = getPref("translateModel"); 60 | const targetLanguage = getPref("targetLanguage") as Language; 61 | const enhanceCompatibility = getPref("enhanceCompatibility"); 62 | const ocrWorkaround = getPref("ocrWorkaround"); 63 | const confirmResult = await showConfirmationDialog(); 64 | if (confirmResult.action === "cancel") { 65 | return; 66 | } 67 | 68 | report("zotero_plugin_translate", [ 69 | { 70 | name: "zotero_plugin_translate", 71 | params: { 72 | trigger: "right_menu", 73 | translation_service: 74 | confirmResult.data?.translateModel || translateModel, 75 | translate_mode: confirmResult.data?.translateMode || translateMode, 76 | target_language: confirmResult.data?.targetLanguage || targetLanguage, 77 | }, 78 | }, 79 | ]); 80 | tasksToQueue.forEach((task) => { 81 | task.translateMode = confirmResult.data?.translateMode || translateMode; 82 | task.translateModel = confirmResult.data?.translateModel || translateModel; 83 | task.targetLanguage = confirmResult.data?.targetLanguage || targetLanguage; 84 | task.enhanceCompatibility = 85 | confirmResult.data?.enhanceCompatibility || enhanceCompatibility; 86 | task.ocrWorkaround = confirmResult.data?.ocrWorkaround || ocrWorkaround; 87 | }); 88 | ztoolkit.log(`Adding ${tasksToQueue.length} translation tasks to the queue.`); 89 | addon.data.task.translationGlobalQueue.push(...tasksToQueue); // Add new tasks 90 | 91 | // Deep clone the tasks to translationTaskList for tracking 92 | if (!addon.data.task.translationTaskList) { 93 | addon.data.task.translationTaskList = []; 94 | } 95 | const clonedTasks = tasksToQueue.map((task) => 96 | JSON.parse(JSON.stringify(task)), 97 | ); 98 | addon.data.task.translationTaskList.push(...clonedTasks); 99 | 100 | // Save the updated queues 101 | saveTranslationData(); 102 | 103 | startQueueProcessing(); 104 | } 105 | 106 | // get translation tasks by specific attachment ids 107 | async function getTranslationTasksByIds( 108 | ids: number[], 109 | ): Promise { 110 | const tasks: TranslationTaskData[] = []; 111 | 112 | for (const id of ids) { 113 | try { 114 | const item = Zotero.Items.get(id); 115 | if (!item) { 116 | ztoolkit.log(`Item with id ${id} not found, skipping.`); 117 | continue; 118 | } 119 | 120 | const itemTasks = await processItemForTranslation(item); 121 | tasks.push(...itemTasks); 122 | } catch (error) { 123 | ztoolkit.log(`Error processing item with id ${id}:`, error); 124 | continue; 125 | } 126 | } 127 | 128 | ztoolkit.log("Found tasks by ids (after refined deduplication):", tasks); 129 | return tasks; 130 | } 131 | 132 | // get translation tasks from selected items 133 | async function getTranslationTasks(): Promise { 134 | const selectedItems = Zotero.getActiveZoteroPane().getSelectedItems(); 135 | const tasks: TranslationTaskData[] = []; 136 | 137 | for (const item of selectedItems) { 138 | try { 139 | const itemTasks = await processItemForTranslation(item); 140 | tasks.push(...itemTasks); 141 | } catch (error) { 142 | ztoolkit.log(`Error processing selected item ${item.id}:`, error); 143 | continue; 144 | } 145 | } 146 | 147 | ztoolkit.log("Found tasks (after refined deduplication):", tasks); 148 | return tasks; 149 | } 150 | 151 | // 处理单个条目,提取其中的PDF附件并创建翻译任务 152 | async function processItemForTranslation( 153 | item: Zotero.Item, 154 | ): Promise { 155 | const tasks: TranslationTaskData[] = []; 156 | let parentItem: Zotero.Item | null = null; 157 | const attachmentsToProcess: Zotero.Item[] = []; 158 | 159 | if (item.isRegularItem()) { 160 | parentItem = item; 161 | const attachmentIds = item.getAttachments(false); 162 | // 只处理PDF附件 163 | for (const attachmentId of attachmentIds) { 164 | const attachment = Zotero.Items.get(attachmentId); 165 | if (shouldSkipAttachment(attachment)) { 166 | continue; 167 | } 168 | if (attachment && attachment.isPDFAttachment()) { 169 | attachmentsToProcess.push(attachment); 170 | } 171 | } 172 | } else if (item.isPDFAttachment()) { 173 | if (shouldSkipAttachment(item)) { 174 | return tasks; 175 | } 176 | const parentItemId = item.parentItemID; 177 | 178 | if (parentItemId) { 179 | const potentialParent = Zotero.Items.get(parentItemId); 180 | if (potentialParent && potentialParent.isRegularItem()) { 181 | parentItem = potentialParent; 182 | attachmentsToProcess.push(item); 183 | } else { 184 | ztoolkit.log( 185 | `Attachment ${item.id} has no valid parent item, skipping.`, 186 | ); 187 | return tasks; 188 | } 189 | } else { 190 | attachmentsToProcess.push(item); 191 | } 192 | } 193 | 194 | for (const attachment of attachmentsToProcess) { 195 | const task = await createTranslationTask(attachment, parentItem); 196 | if (task) { 197 | tasks.push(task); 198 | } 199 | } 200 | 201 | return tasks; 202 | } 203 | 204 | // 检查附件是否应该跳过 205 | export function shouldSkipAttachment(attachment: Zotero.Item): boolean { 206 | const hasTranslatedTag = attachment 207 | .getTags() 208 | .find((tagItem) => tagItem.tag === ATTR_TAG); 209 | const hasNameSuffix = 210 | attachment.attachmentFilename?.endsWith("_dual.pdf") || 211 | attachment.attachmentFilename?.endsWith("_translation.pdf"); 212 | if (hasTranslatedTag || hasNameSuffix) { 213 | ztoolkit.log( 214 | `Attachment ${attachment.id} is already a translation result, skipping.`, 215 | ); 216 | return true; 217 | } 218 | return false; 219 | } 220 | 221 | // 为单个附件创建翻译任务 222 | async function createTranslationTask( 223 | attachment: Zotero.Item, 224 | parentItem: Zotero.Item | null, 225 | ): Promise { 226 | const attachmentId = attachment.id; 227 | const attachmentFilename = 228 | attachment.attachmentFilename || `Attachment ${attachmentId}`; 229 | 230 | // Check attachment is already in the translation task list? 231 | const isInTaskList = isAttachmentInTaskList(attachmentId); 232 | // TODO 检查是否是已成功的翻译任务,给予提示 233 | // 1. 在 tasklist 中,并且状态是成功 234 | // 2. 有同名称的带 babeldoc tag 的翻译结果附件 235 | if (isInTaskList) { 236 | ztoolkit.log( 237 | `Attachment ${attachmentId} (${attachmentFilename}) is already in the translation task list, skipping.`, 238 | ); 239 | return null; 240 | } 241 | // --- End Deduplication Checks --- 242 | 243 | // Proceed only if not deduplicated 244 | const exists = await attachment.fileExists(); 245 | if (!exists) { 246 | ztoolkit.log( 247 | `Attachment file does not exist for ${attachmentId}, skipping.`, 248 | ); 249 | return null; 250 | } 251 | 252 | const filePath = await attachment.getFilePathAsync(); 253 | if (!filePath || !attachmentFilename) { 254 | ztoolkit.log( 255 | `Could not get path or valid filename for attachment ${attachmentId}, skipping.`, 256 | ); 257 | return null; 258 | } 259 | 260 | const translateMode = getPref("translateMode"); 261 | const translateModel = getPref("translateModel"); 262 | const targetLanguage = getPref("targetLanguage") as Language; 263 | const enhanceCompatibility = getPref("enhanceCompatibility"); 264 | const ocrWorkaround = getPref("ocrWorkaround"); 265 | 266 | return { 267 | parentItemId: parentItem?.id, 268 | parentItemTitle: parentItem?.getField("title"), 269 | attachmentId: attachmentId, 270 | attachmentFilename: attachmentFilename, 271 | attachmentPath: filePath, 272 | status: "queued", 273 | targetLanguage: targetLanguage, 274 | translateModel: translateModel, 275 | translateMode: translateMode, 276 | enhanceCompatibility: enhanceCompatibility, 277 | ocrWorkaround: ocrWorkaround, 278 | }; 279 | } 280 | 281 | export async function startQueueProcessing() { 282 | if ( 283 | addon.data.task.isQueueProcessing || 284 | addon.data.task.translationGlobalQueue.length === 0 285 | ) { 286 | return; // Already running or queue empty 287 | } 288 | showTaskManager(); 289 | addon.data.task.isQueueProcessing = true; 290 | ztoolkit.log("Starting queue processing loop."); 291 | // Use Zotero.Promise.delay(0).then() to avoid deep recursion and yield 292 | Zotero.Promise.delay(0).then(processNextItem); 293 | } 294 | 295 | async function processNextItem() { 296 | if (addon.data.task.translationGlobalQueue.length === 0) { 297 | addon.data.task.isQueueProcessing = false; 298 | ztoolkit.log("Translation queue empty. Stopping processing loop."); 299 | saveTranslationData(); // Save the empty queue state 300 | return; 301 | } 302 | 303 | // Rename queueItem to taskData for clarity 304 | const taskData = addon.data.task.translationGlobalQueue.shift(); 305 | 306 | // Save queue state after removing an item 307 | saveTranslationData(); 308 | 309 | if (!taskData) { 310 | Zotero.Promise.delay(0).then(processNextItem); 311 | return; 312 | } 313 | 314 | // Get the parent item using parentItemId from taskData 315 | const parentItem = taskData.parentItemId 316 | ? Zotero.Items.get(taskData.parentItemId) 317 | : undefined; 318 | 319 | ztoolkit.log( 320 | `Processing task for attachment: ${taskData.attachmentFilename} (Parent: ${taskData.parentItemTitle}, ID: ${taskData.parentItemId})`, 321 | ); 322 | 323 | try { 324 | // 如果任务已经有pdfId,说明是程序重启后恢复的任务,并且已经创建了翻译任务 325 | if (taskData.pdfId) { 326 | ztoolkit.log( 327 | `恢复已有pdfId(${taskData.pdfId})的任务: ${taskData.attachmentFilename},直接进入监控阶段`, 328 | ); 329 | updateTaskInList(taskData.attachmentId, { 330 | status: "translating", 331 | }); 332 | // 直接启动监控任务 333 | TranslationTaskMonitor.addTask(taskData.pdfId, taskData, parentItem); 334 | } else { 335 | // 常规流程 - 从上传开始 336 | await translatePDF(taskData, parentItem); 337 | ztoolkit.log( 338 | `Initiated processing for: ${taskData.attachmentFilename}. Moving to next queue item.`, 339 | ); 340 | } 341 | } catch (error: any) { 342 | ztoolkit.log( 343 | `ERROR: Failed to initiate translation for ${taskData.attachmentFilename}:`, 344 | error.message || error, 345 | ); 346 | updateTaskInList(taskData.attachmentId, { 347 | status: "failed", 348 | error: error.message || error, 349 | }); 350 | } finally { 351 | Zotero.Promise.delay(0).then(processNextItem); 352 | } 353 | } 354 | -------------------------------------------------------------------------------- /src/modules/translate/translate.ts: -------------------------------------------------------------------------------- 1 | import type { TranslationTaskData } from "../../types"; 2 | import { updateTaskInList } from "./task-manager"; 3 | import { TranslationTaskMonitor } from "./task-monitor"; 4 | 5 | export async function translatePDF( 6 | taskData: TranslationTaskData, 7 | parentItem?: Zotero.Item, 8 | ): Promise { 9 | // Update task status in taskList 10 | updateTaskInList(taskData.attachmentId, { 11 | status: "uploading", 12 | stage: "uploading", 13 | progress: 0, 14 | }); 15 | 16 | // --- Upload --- 17 | const uploadInfo = await uploadAttachmentFile( 18 | taskData.attachmentPath, 19 | taskData.attachmentFilename, 20 | ); // Wait for upload 21 | if (!uploadInfo) { 22 | throw new Error(`Upload failed for ${taskData.attachmentFilename}`); 23 | } 24 | ztoolkit.log(`Upload successful for: ${taskData.attachmentFilename}`); 25 | 26 | // --- Create Task --- 27 | const pdfId = await addon.api.createTranslateTask({ 28 | objectKey: uploadInfo.result.objectKey, 29 | pdfOptions: { conversion_formats: { html: true } }, 30 | fileName: taskData.attachmentFilename, 31 | targetLanguage: taskData.targetLanguage, 32 | requestModel: taskData.translateModel, 33 | enhance_compatibility: taskData.enhanceCompatibility, 34 | turnstileResponse: "", 35 | OCRWorkaround: taskData.ocrWorkaround, 36 | }); 37 | 38 | ztoolkit.log( 39 | `Translation task created for ${taskData.attachmentFilename}. PDF ID: ${pdfId}`, 40 | ); 41 | // Update taskData with pdfId for the monitoring task 42 | updateTaskInList(taskData.attachmentId, { 43 | status: "translating", 44 | stage: "queued", 45 | progress: 0, 46 | pdfId: pdfId, 47 | }); 48 | 49 | // Add task to centralized monitor instead of launching a separate monitoring process 50 | TranslationTaskMonitor.addTask(pdfId, taskData, parentItem); 51 | 52 | ztoolkit.log( 53 | `Task added to background monitor: ${pdfId} (${taskData.attachmentFilename}).`, 54 | ); 55 | } 56 | 57 | // Renamed and refactored to upload a single file 58 | async function uploadAttachmentFile( 59 | attachmentPath: string, 60 | attachmentFilename: string, 61 | ): Promise { 62 | // Returns single upload info or throws error 63 | ztoolkit.log(`Uploading attachment: ${attachmentFilename}`); 64 | 65 | try { 66 | const uploadInfo = await addon.api.getPdfUploadUrl(); 67 | if (!attachmentPath) 68 | throw new Error( 69 | `File path is missing for attachment ${attachmentFilename}`, 70 | ); 71 | const fileContents = await IOUtils.read(attachmentPath); // Read the specific path 72 | if (!fileContents) 73 | throw new Error(`Failed to read file contents for ${attachmentFilename}`); 74 | 75 | if (typeof File === "undefined") { 76 | throw new Error("File constructor not available in this environment."); 77 | } 78 | const file = new File([fileContents], attachmentFilename, { 79 | type: "application/pdf", 80 | }); 81 | 82 | await addon.api.uploadPdf({ 83 | uploadUrl: uploadInfo.result.preSignedURL, 84 | file, 85 | }); 86 | ztoolkit.log(`Successfully uploaded ${attachmentFilename}`); 87 | return uploadInfo; // Return the info for the uploaded file 88 | } catch (error: any) { 89 | ztoolkit.log( 90 | `ERROR: Upload failed for ${attachmentFilename}:`, 91 | error.message || error, 92 | ); 93 | throw error; // Re-throw to be caught by handleSingleItemTranslation -> processNextItem 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Language } from "./modules/language/types"; 2 | 3 | export type Status = 4 | | "uploading" 5 | | "queued" 6 | | "translating" 7 | | "success" 8 | | "failed" 9 | | "canceled"; 10 | 11 | export type Stage = "queued" | "processing" | "success" | "failed"; 12 | 13 | export type TranslationTaskData = { 14 | parentItemId?: number; 15 | parentItemTitle?: string; 16 | attachmentId: number; 17 | attachmentFilename: string; 18 | attachmentPath: string; 19 | targetLanguage: Language; 20 | translateModel: string; 21 | translateMode: string; 22 | enhanceCompatibility: boolean; 23 | ocrWorkaround: boolean; 24 | pdfId?: string; 25 | status?: Status; 26 | stage?: string; 27 | progress?: number; 28 | error?: string; 29 | resultAttachmentId?: number; 30 | }; 31 | -------------------------------------------------------------------------------- /src/utils/const.ts: -------------------------------------------------------------------------------- 1 | export const HOST_NAME = "immersivetranslate.com"; 2 | export const APP_SITE_URL = "https://app.immersivetranslate.com"; 3 | export const TEST_APP_SITE_URL = "https://test-app.immersivetranslate.com"; 4 | 5 | const OLD_GA_MEASUREMENT_ID = __OLD_GA_MEASUREMENT_ID__ || ""; 6 | const OLD_GA_API_SECRET = __OLD_GA_API_SECRET__ || ""; 7 | const NEW_GA_MEASUREMENT_ID = __NEW_GA_MEASUREMENT_ID__ || ""; 8 | const NEW_GA_API_SECRET = __NEW_GA_API_SECRET__ || ""; 9 | 10 | export const BASE_URL_TEST = `https://test-api2.${HOST_NAME}/zotero`; 11 | export const BASE_URL = `https://api2.${HOST_NAME}/zotero`; 12 | 13 | export const SELF_SERVICE_COLLECT_URL = `https://analytics.${HOST_NAME}/collect`; 14 | 15 | export function getGMurls() { 16 | if (!NEW_GA_MEASUREMENT_ID || NEW_GA_MEASUREMENT_ID === "undefined") { 17 | ztoolkit.log("Warning: env not inject success!"); 18 | return []; 19 | } 20 | if (addon.data.env === "development") { 21 | return [ 22 | `https://www.google-analytics.com/debug/mp/collect?measurement_id=${OLD_GA_MEASUREMENT_ID}&api_secret=${OLD_GA_API_SECRET}`, 23 | `https://www.google-analytics.com/debug/mp/collect?measurement_id=${NEW_GA_MEASUREMENT_ID}&api_secret=${NEW_GA_API_SECRET}`, 24 | ]; 25 | } 26 | return [ 27 | `https://www.google-analytics.com/mp/collect?measurement_id=${OLD_GA_MEASUREMENT_ID}&api_secret=${OLD_GA_API_SECRET}`, 28 | `https://www.google-analytics.com/mp/collect?measurement_id=${NEW_GA_MEASUREMENT_ID}&api_secret=${NEW_GA_API_SECRET}`, 29 | ]; 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/dialog.ts: -------------------------------------------------------------------------------- 1 | import { getString } from "./locale"; 2 | 3 | export function showDialog({ 4 | title, 5 | message, 6 | }: { 7 | title: string; 8 | message?: string; 9 | }) { 10 | new ztoolkit.Dialog(3, 4) 11 | .addCell(0, 0, { 12 | tag: "h2", 13 | properties: { 14 | innerHTML: title, 15 | }, 16 | styles: { 17 | width: "300px", 18 | }, 19 | }) 20 | .addCell(1, 0, { 21 | tag: "label", 22 | namespace: "html", 23 | properties: { 24 | innerHTML: message, 25 | }, 26 | styles: { 27 | width: "300px", 28 | }, 29 | }) 30 | .addButton(getString("confirm-yes"), "confirm") 31 | .open(title); 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/fake_user.ts: -------------------------------------------------------------------------------- 1 | import { getPref, setPref } from "./prefs"; 2 | 3 | export async function generateId(length: number) { 4 | let result = ""; 5 | const characters = 6 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 7 | const charactersLength = characters.length; 8 | let counter = 0; 9 | while (counter < length) { 10 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 11 | counter += 1; 12 | } 13 | return result; 14 | } 15 | 16 | export const getInstallInfo = async () => { 17 | const userId = getPref("fakeUserId"); 18 | if (userId) { 19 | return { 20 | fakeUserId: userId, 21 | }; 22 | } 23 | const fakeUserId = await generateId(64); 24 | setPref("fakeUserId", fakeUserId); 25 | return { 26 | fakeUserId: fakeUserId, 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /src/utils/locale.ts: -------------------------------------------------------------------------------- 1 | import { config } from "../../package.json"; 2 | 3 | export { initLocale, getString, getLocaleID }; 4 | 5 | /** 6 | * Initialize locale data 7 | */ 8 | function initLocale() { 9 | const l10n = new ( 10 | typeof Localization === "undefined" 11 | ? ztoolkit.getGlobal("Localization") 12 | : Localization 13 | )([`${config.addonRef}-addon.ftl`], true); 14 | addon.data.locale = { 15 | current: l10n, 16 | }; 17 | } 18 | 19 | /** 20 | * Get locale string, see https://firefox-source-docs.mozilla.org/l10n/fluent/tutorial.html#fluent-translation-list-ftl 21 | * @param localString ftl key 22 | * @param options.branch branch name 23 | * @param options.args args 24 | * @example 25 | * ```ftl 26 | * # addon.ftl 27 | * addon-static-example = This is default branch! 28 | * .branch-example = This is a branch under addon-static-example! 29 | * addon-dynamic-example = 30 | { $count -> 31 | [one] I have { $count } apple 32 | *[other] I have { $count } apples 33 | } 34 | * ``` 35 | * ```js 36 | * getString("addon-static-example"); // This is default branch! 37 | * getString("addon-static-example", { branch: "branch-example" }); // This is a branch under addon-static-example! 38 | * getString("addon-dynamic-example", { args: { count: 1 } }); // I have 1 apple 39 | * getString("addon-dynamic-example", { args: { count: 2 } }); // I have 2 apples 40 | * ``` 41 | */ 42 | function getString(localString: string): string; 43 | function getString(localString: string, branch: string): string; 44 | function getString( 45 | localeString: string, 46 | options: { branch?: string | undefined; args?: Record }, 47 | ): string; 48 | function getString(...inputs: any[]) { 49 | if (inputs.length === 1) { 50 | return _getString(inputs[0]); 51 | } else if (inputs.length === 2) { 52 | if (typeof inputs[1] === "string") { 53 | return _getString(inputs[0], { branch: inputs[1] }); 54 | } else { 55 | return _getString(inputs[0], inputs[1]); 56 | } 57 | } else { 58 | throw new Error("Invalid arguments"); 59 | } 60 | } 61 | 62 | function _getString( 63 | localeString: string, 64 | options: { branch?: string | undefined; args?: Record } = {}, 65 | ): string { 66 | const localStringWithPrefix = `${config.addonRef}-${localeString}`; 67 | const { branch, args } = options; 68 | const pattern = addon.data.locale?.current.formatMessagesSync([ 69 | { id: localStringWithPrefix, args }, 70 | ])[0]; 71 | if (!pattern) { 72 | return localStringWithPrefix; 73 | } 74 | if (branch && pattern.attributes) { 75 | for (const attr of pattern.attributes) { 76 | if (attr.name === branch) { 77 | return attr.value; 78 | } 79 | } 80 | return pattern.attributes[branch] || localStringWithPrefix; 81 | } else { 82 | return pattern.value || localStringWithPrefix; 83 | } 84 | } 85 | 86 | function getLocaleID(id: string) { 87 | return `${config.addonRef}-${id}`; 88 | } 89 | -------------------------------------------------------------------------------- /src/utils/prefs.ts: -------------------------------------------------------------------------------- 1 | import { config } from "../../package.json"; 2 | 3 | type PluginPrefsMap = _ZoteroTypes.Prefs["PluginPrefsMap"]; 4 | 5 | const PREFS_PREFIX = config.prefsPrefix; 6 | 7 | /** 8 | * Get preference value. 9 | * Wrapper of `Zotero.Prefs.get`. 10 | * @param key 11 | */ 12 | export function getPref(key: K) { 13 | return Zotero.Prefs.get(`${PREFS_PREFIX}.${key}`, true) as PluginPrefsMap[K]; 14 | } 15 | 16 | /** 17 | * Set preference value. 18 | * Wrapper of `Zotero.Prefs.set`. 19 | * @param key 20 | * @param value 21 | */ 22 | export function setPref( 23 | key: K, 24 | value: PluginPrefsMap[K], 25 | ) { 26 | return Zotero.Prefs.set(`${PREFS_PREFIX}.${key}`, value, true); 27 | } 28 | 29 | /** 30 | * Clear preference value. 31 | * Wrapper of `Zotero.Prefs.clear`. 32 | * @param key 33 | */ 34 | export function clearPref(key: string) { 35 | return Zotero.Prefs.clear(`${PREFS_PREFIX}.${key}`, true); 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/report.ts: -------------------------------------------------------------------------------- 1 | import { request } from "../api/request"; 2 | import { getInstallInfo } from "./fake_user"; 3 | import { getGMurls, SELF_SERVICE_COLLECT_URL } from "./const"; 4 | import { version } from "../../package.json"; 5 | 6 | export interface EventInterface { 7 | name: string; 8 | params?: Record; 9 | } 10 | 11 | export async function report(key: string, events: EventInterface[]) { 12 | try { 13 | const { fakeUserId } = await getInstallInfo(); 14 | const urls = getGMurls(); 15 | 16 | const formattedEvents = await formatEvents({ 17 | events, 18 | }); 19 | 20 | urls.forEach(async (url) => { 21 | await request({ 22 | responseType: "text", 23 | url: url, 24 | method: "POST", 25 | fullFillOnError: true, 26 | body: JSON.stringify({ 27 | client_id: fakeUserId, 28 | user_id: fakeUserId, 29 | events: formattedEvents, 30 | }), 31 | }); 32 | }); 33 | 34 | reportToSelfService(fakeUserId, formattedEvents); 35 | } catch (e) { 36 | ztoolkit.log(`report error`, e); 37 | } 38 | } 39 | 40 | function reportToSelfService(fakeUserId: string, events: EventInterface[]) { 41 | try { 42 | events.forEach((event) => { 43 | const params: Record = { 44 | ...event.params, 45 | event_name: event.name, 46 | device_id: fakeUserId, 47 | }; 48 | const nonce = Date.now() + (Math.random() * 100).toFixed(0); 49 | 50 | request({ 51 | url: SELF_SERVICE_COLLECT_URL, 52 | method: "POST", 53 | responseType: "text", 54 | fullFillOnError: true, 55 | body: JSON.stringify({ 56 | nonce: nonce, 57 | subject: "user_behaviour", 58 | logs: [JSON.stringify(params)], 59 | }), 60 | }); 61 | }); 62 | } catch (error) { 63 | ztoolkit.log(`report self service error`, error); 64 | } 65 | } 66 | 67 | async function formatEvents(options: { events: EventInterface[] }) { 68 | const { events } = options; 69 | const systemInfo = await Zotero.getSystemInfo(); 70 | const os_version = (await Zotero.getOSVersion()).split(" "); 71 | const formattedEvents = events.map((event) => { 72 | const currentParam: Record = event.params || {}; 73 | currentParam.os_name = os_version[0] || "unknown"; 74 | currentParam.os_version = os_version[1] || "unknown"; 75 | if (version) { 76 | currentParam.version = version; 77 | } 78 | return { 79 | ...event, 80 | params: currentParam, 81 | } as EventInterface; 82 | }); 83 | 84 | return formattedEvents; 85 | } 86 | -------------------------------------------------------------------------------- /src/utils/window.ts: -------------------------------------------------------------------------------- 1 | export { isWindowAlive }; 2 | 3 | /** 4 | * Check if the window is alive. 5 | * Useful to prevent opening duplicate windows. 6 | * @param win 7 | */ 8 | function isWindowAlive(win?: Window) { 9 | return win && !Components.utils.isDeadWrapper(win) && !win.closed; 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/ztoolkit.ts: -------------------------------------------------------------------------------- 1 | import { ZoteroToolkit } from "zotero-plugin-toolkit"; 2 | import { config } from "../../package.json"; 3 | 4 | export { createZToolkit }; 5 | 6 | function createZToolkit() { 7 | const _ztoolkit = new ZoteroToolkit(); 8 | /** 9 | * Alternatively, import toolkit modules you use to minify the plugin size. 10 | * You can add the modules under the `MyToolkit` class below and uncomment the following line. 11 | */ 12 | // const _ztoolkit = new MyToolkit(); 13 | initZToolkit(_ztoolkit); 14 | return _ztoolkit; 15 | } 16 | 17 | function initZToolkit(_ztoolkit: ReturnType) { 18 | const env = __env__; 19 | _ztoolkit.basicOptions.log.prefix = `[${config.addonName}]`; 20 | _ztoolkit.basicOptions.log.disableConsole = env === "production"; 21 | _ztoolkit.UI.basicOptions.ui.enableElementJSONLog = __env__ === "development"; 22 | _ztoolkit.UI.basicOptions.ui.enableElementDOMLog = __env__ === "development"; 23 | // Getting basicOptions.debug will load global modules like the debug bridge. 24 | // since we want to deprecate it, should avoid using it unless necessary. 25 | // _ztoolkit.basicOptions.debug.disableDebugBridgePassword = 26 | // __env__ === "development"; 27 | _ztoolkit.basicOptions.api.pluginID = config.addonID; 28 | _ztoolkit.ProgressWindow.setIconURI( 29 | "default", 30 | `chrome://${config.addonRef}/content/icons/favicon.png`, 31 | ); 32 | } 33 | 34 | import { BasicTool, unregister } from "zotero-plugin-toolkit"; 35 | import { UITool } from "zotero-plugin-toolkit"; 36 | 37 | class MyToolkit extends BasicTool { 38 | UI: UITool; 39 | 40 | constructor() { 41 | super(); 42 | this.UI = new UITool(this); 43 | } 44 | 45 | unregisterAll() { 46 | unregister(this); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "zotero-types/entries/sandbox/", 3 | "include": ["src", "typings"], 4 | "exclude": ["build", "addon"] 5 | } 6 | -------------------------------------------------------------------------------- /typings/global.d.ts: -------------------------------------------------------------------------------- 1 | declare const _globalThis: { 2 | [key: string]: any; 3 | Zotero: _ZoteroTypes.Zotero; 4 | ztoolkit: ZToolkit; 5 | addon: typeof addon; 6 | }; 7 | 8 | declare type ZToolkit = ReturnType< 9 | typeof import("../src/utils/ztoolkit").createZToolkit 10 | >; 11 | 12 | declare const ztoolkit: ZToolkit; 13 | 14 | declare const rootURI: string; 15 | 16 | declare const addon: import("../src/addon").default; 17 | 18 | declare const __env__: "production" | "development"; 19 | declare const __NEW_GA_MEASUREMENT_ID__: string; 20 | declare const __NEW_GA_API_SECRET__: string; 21 | declare const __OLD_GA_MEASUREMENT_ID__: string; 22 | declare const __OLD_GA_API_SECRET__: string; 23 | -------------------------------------------------------------------------------- /typings/prefs.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by zotero-plugin-scaffold 2 | /* prettier-ignore */ 3 | /* eslint-disable */ 4 | // @ts-nocheck 5 | 6 | // prettier-ignore 7 | declare namespace _ZoteroTypes { 8 | interface Prefs { 9 | PluginPrefsMap: { 10 | "authkey": string; 11 | "targetLanguage": string; 12 | "translateMode": string; 13 | "translateModel": string; 14 | "enhanceCompatibility": boolean; 15 | "autoTranslate": boolean; 16 | "autoOpenPDF": boolean; 17 | "ocrWorkaround": boolean; 18 | "fakeUserId": string; 19 | "enableShortcuts": boolean; 20 | }; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /zotero-plugin.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "zotero-plugin-scaffold"; 2 | import pkg from "./package.json"; 3 | 4 | export default defineConfig({ 5 | source: ["src", "addon"], 6 | dist: "dist", 7 | name: pkg.config.addonName, 8 | id: pkg.config.addonID, 9 | namespace: pkg.config.addonRef, 10 | updateURL: `https://github.com/{{owner}}/{{repo}}/releases/download/release/${ 11 | pkg.version.includes("-") ? "update-beta.json" : "update.json" 12 | }`, 13 | xpiName: pkg.config.xpiName, 14 | xpiDownloadLink: 15 | "https://github.com/{{owner}}/{{repo}}/releases/download/v{{version}}/{{xpiName}}.xpi", 16 | 17 | build: { 18 | assets: ["addon/**/*.*"], 19 | define: { 20 | ...pkg.config, 21 | author: pkg.author, 22 | description: pkg.description, 23 | homepage: pkg.homepage, 24 | buildVersion: pkg.version, 25 | buildTime: "{{buildTime}}", 26 | }, 27 | prefs: { 28 | prefix: pkg.config.prefsPrefix, 29 | }, 30 | esbuildOptions: [ 31 | { 32 | entryPoints: ["src/index.ts"], 33 | define: { 34 | __env__: `"${process.env.NODE_ENV}"`, 35 | __NEW_GA_MEASUREMENT_ID__: `"${process.env.NEW_GA_MEASUREMENT_ID}"`, 36 | __NEW_GA_API_SECRET__: `"${process.env.NEW_GA_API_SECRET}"`, 37 | __OLD_GA_MEASUREMENT_ID__: `"${process.env.OLD_GA_MEASUREMENT_ID}"`, 38 | __OLD_GA_API_SECRET__: `"${process.env.OLD_GA_API_SECRET}"`, 39 | }, 40 | bundle: true, 41 | target: "firefox115", 42 | outfile: `dist/addon/content/scripts/${pkg.config.addonRef}.js`, 43 | }, 44 | ], 45 | }, 46 | 47 | // If you need to see a more detailed log, uncomment the following line: 48 | // logLevel: "trace", 49 | }); 50 | --------------------------------------------------------------------------------