├── .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 | [](https://www.zotero.org)
4 | [](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 | 
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 | 
26 |
27 | 2. 在 [沉浸式翻译](https://immersivetranslate.com/profile) 官网个人主页获取 Zotero 授权码
28 |
29 | 
30 |
31 | 3. 在插件的设置页面,粘贴你的 Zotero 授权码,点击 `测试` 按钮,如果显示 `测试成功`,则说明配置成功
32 |
33 | 
34 |
35 | 4. 在设置页面配置目标语言、翻译模型、翻译模式等等。
36 |
37 | 5. 在 Zotero 的文献管理页面,右键文件,出现右键菜单,选择 `使用沉浸式翻译`
38 |
39 | 
40 |
41 | 6. 在弹出的窗口中二次确认,之后会出现任务管理窗口,显示翻译任务的进度。
42 |
43 | 
44 |
45 | ## 翻译任务管理
46 |
47 | 在任务管理窗口,可以查看翻译任务的进度及结果。
48 |
49 | 
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 |
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 |
--------------------------------------------------------------------------------