diff --git a/.eslintrc.json b/.eslintrc.json
deleted file mode 100644
index bd779ad..0000000
--- a/.eslintrc.json
+++ /dev/null
@@ -1,41 +0,0 @@
-{
- "env": {
- "browser": true,
- "es2021": true
- },
- "root": true,
- "extends": [
- "eslint:recommended",
- "plugin:@typescript-eslint/recommended",
- "prettier"
- ],
- "overrides": [],
- "parser": "@typescript-eslint/parser",
- "parserOptions": {
- "ecmaVersion": "latest",
- "sourceType": "module"
- },
- "plugins": ["@typescript-eslint"],
- "rules": {
- "@typescript-eslint/ban-ts-comment": [
- "warn",
- {
- "ts-expect-error": "allow-with-description",
- "ts-ignore": "allow-with-description",
- "ts-nocheck": "allow-with-description",
- "ts-check": "allow-with-description"
- }
- ],
- "@typescript-eslint/no-unused-vars": "off",
- "@typescript-eslint/no-explicit-any": ["off", { "ignoreRestArgs": true }],
- "@typescript-eslint/no-non-null-assertion": "off"
- },
- "ignorePatterns": [
- "**/build/**",
- "**/dist/**",
- "**/node_modules/**",
- "**/scripts/**",
- "**/*.js",
- "**/*.bak"
- ]
-}
diff --git a/.github/renovate.json b/.github/renovate.json
new file mode 100644
index 0000000..1289413
--- /dev/null
+++ b/.github/renovate.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+ "extends": [
+ "config:recommended",
+ ":semanticPrefixChore",
+ ":prHourlyLimitNone",
+ ":prConcurrentLimitNone",
+ ":enableVulnerabilityAlerts",
+ ":dependencyDashboard",
+ "schedule:weekends"
+ ],
+ "packageRules": [
+ {
+ "matchPackageNames": ["zotero-plugin-toolkit", "zotero-types"],
+ "automerge": true
+ }
+ ],
+ "git-submodules": {
+ "enabled": true
+ }
+}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..a57bde9
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,43 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - v**
+
+permissions:
+ contents: write
+ issues: write
+ pull-requests: write
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ env:
+ GITHUB_TOKEN: ${{ secrets.GitHub_TOKEN }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Install deps
+ run: npm install
+
+ - name: Release to GitHub
+ run: |
+ npm run release -- --no-increment --no-git --github.release --ci --VV
+ sleep 1s
+
+ - name: Notify release
+ uses: apexskier/github-release-commenter@v1
+ continue-on-error: true
+ with:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ comment-template: |
+ :rocket: _This ticket has been resolved in {release_tag}. See {release_link} for release notes._
diff --git a/.gitignore b/.gitignore
index d277962..4c3136c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,8 @@
-**/build
+build
+logs
node_modules
package-lock.json
-zotero-cmd.json
\ No newline at end of file
+pnpm-lock.yaml
+yarn.lock
+zotero-cmd.json
+.DS_Store
\ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..153e309
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,7 @@
+build
+logs
+node_modules
+package-lock.json
+yarn.lock
+pnpm-lock.yaml
+# zotero-cmd.json
diff --git a/.prettierrc b/.prettierrc
deleted file mode 100644
index 75fa134..0000000
--- a/.prettierrc
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "tabWidth": 2
-}
diff --git a/.release-it.json b/.release-it.json
deleted file mode 100644
index 69da605..0000000
--- a/.release-it.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "npm": {
- "publish": false
- },
- "github": {
- "release": true,
- "assets": ["build/*.xpi"]
- },
- "hooks": {
- "before:init": "npm run lint",
- "after:bump": "npm run build",
- "after:release": "echo Successfully released ${name} v${version} to ${repo.repository}."
- }
-}
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 1663cf3..8b7f88b 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -7,23 +7,16 @@
{
"type": "node",
"request": "launch",
- "name": "StartDev",
+ "name": "Start",
"runtimeExecutable": "npm",
- "runtimeArgs": ["run", "start-watch"]
+ "runtimeArgs": ["run", "start"]
},
{
"type": "node",
"request": "launch",
- "name": "Restart",
+ "name": "Build",
"runtimeExecutable": "npm",
- "runtimeArgs": ["run", "restart"]
- },
- {
- "type": "node",
- "request": "launch",
- "name": "Restart in Prod Mode",
- "runtimeExecutable": "npm",
- "runtimeArgs": ["run", "restart-prod"]
+ "runtimeArgs": ["run", "build"]
}
]
}
diff --git a/addon/bootstrap.js b/addon/bootstrap.js
index 1b50280..a96e40e 100644
--- a/addon/bootstrap.js
+++ b/addon/bootstrap.js
@@ -40,6 +40,7 @@ async function startup({ id, version, resourceURI, rootURI }, reason) {
`${rootURI}/chrome/content/scripts/__addonRef__.js`,
ctx,
);
+ Zotero.__addonInstance__.hooks.onStartup();
}
async function onMainWindowLoad({ window }, reason) {
diff --git a/addon/manifest.json b/addon/manifest.json
index a92439a..0c356c6 100644
--- a/addon/manifest.json
+++ b/addon/manifest.json
@@ -12,7 +12,7 @@
"applications": {
"zotero": {
"id": "__addonID__",
- "update_url": "__updaterdf__",
+ "update_url": "__updateURL__",
"strict_min_version": "6.999",
"strict_max_version": "7.0.*"
}
diff --git a/doc/README-zhCN.md b/doc/README-zhCN.md
new file mode 100644
index 0000000..606de38
--- /dev/null
+++ b/doc/README-zhCN.md
@@ -0,0 +1,420 @@
+# Zotero Plugin Template
+
+[](https://www.zotero.org)
+[](https://github.com/windingwind/zotero-plugin-template)
+
+这是 [Zotero](https://www.zotero.org/) 的插件模板.
+
+[English](../README.md) | [简体中文](./README-zhCN.md)
+
+📖 [插件开发文档](https://zotero.yuque.com/books/share/8d230829-6004-4934-b4c6-685a7001bfa0/vec88d) (中文版,已过时)
+
+[📖 Zotero 7 插件开发文档](https://www.zotero.org/support/dev/zotero_7_for_developers)
+
+🛠️ [Zotero 插件工具包](https://github.com/windingwind/zotero-plugin-toolkit) | [API 文档](https://github.com/windingwind/zotero-plugin-toolkit/blob/master/docs/zotero-plugin-toolkit.md)
+
+ℹ️ [Zotero 类型定义](https://github.com/windingwind/zotero-types)
+
+📜 [Zotero 源代码](https://github.com/zotero/zotero)
+
+📌 [Zotero 插件模板](https://github.com/windingwind/zotero-plugin-template) (即本仓库)
+
+> [!tip]
+> 👁 Watch 本仓库,以及时收到修复或更新的通知.
+
+## 使用此模板构建的插件
+
+[](https://github.com/windingwind/zotero-better-notes)
+[](https://github.com/windingwind/zotero-pdf-preview)
+[](https://github.com/windingwind/zotero-pdf-translate)
+[](https://github.com/windingwind/zotero-tag)
+[](https://github.com/iShareStuff/ZoteroTheme)
+[](https://github.com/MuiseDestiny/zotero-reference)
+[](https://github.com/MuiseDestiny/zotero-citation)
+[](https://github.com/MuiseDestiny/ZoteroStyle)
+[](https://github.com/volatile-static/Chartero)
+[](https://github.com/l0o0/tara)
+[](https://github.com/redleafnew/delitemwithatt)
+[](https://github.com/redleafnew/zotero-updateifsE)
+[](https://github.com/northword/zotero-format-metadata)
+[](https://github.com/inciteful-xyz/inciteful-zotero-plugin)
+[](https://github.com/MuiseDestiny/zotero-gpt)
+[](https://github.com/zoushucai/zotero-journalabbr)
+[](https://github.com/MuiseDestiny/zotero-figure)
+[](https://github.com/l0o0/jasminum)
+[](https://github.com/lifan0127/ai-research-assistant)
+
+[](https://github.com/daeh/zotero-markdb-connect)
+
+如果你正在使用此库,我建议你将这个标志 ([](https://github.com/windingwind/zotero-plugin-template)) 放在 README 文件中:
+
+```md
+[](https://github.com/windingwind/zotero-plugin-template)
+```
+
+## Features 特性
+
+- 事件驱动、函数式编程的可扩展框架;
+- 简单易用,开箱即用;
+- ⭐[新特性!]自动热重载!每当修改源码时,都会自动编译并重新加载插件;[详情请跳转→](#自动热重载)
+- `src/modules/examples.ts` 中有丰富的示例,涵盖了插件中常用的大部分API (使用的插件工具包 zotero-plugin-toolkit,仓库地址 https://github.com/windingwind/zotero-plugin-toolkit);
+- TypeScript 支持:
+ - 为使用 JavaScript 编写的Zotero源码提供全面的类型定义支持 (使用类型定义包 zotero-types,仓库地址 https://github.com/windingwind/zotero-types);
+ - 全局变量和环境设置;
+- 插件开发/构建/发布工作流:
+ - 自动生成/更新插件id和版本、更新配置和设置环境变量 (`development`/`production`);
+ - 自动在 Zotero 中构建和重新加载代码;
+ - 自动发布到GitHub (使用[release-it](https://github.com/release-it/release-it));
+- 集成Prettier和ES Lint;
+
+> [!warning]
+> Zotero本地化已升级(`dtd` 已弃用,我们将不再使用 `.properties`). 主分支将只支持 Zotero 7.0.0-beta.12 或更高版本. 如果需要支持 Zotero 6,你可能需要同时使用`dtd`、`properties` 和`ftl`. 请参考此库的 `zotero6-bootstrap` 分支.
+
+## Examples 示例
+
+此库提供了 [zotero-plugin-toolkit](https://github.com/windingwind/zotero-plugin-toolkit) 中API的示例.
+
+在 `src/examples.ts` 中搜索`@example` 查看示例. 这些示例在 `src/hooks.ts` 中调用演示.
+
+### 基本示例(Basic Examples)
+
+- registerNotifier
+- registerPrefs, unregisterPrefs
+
+### 快捷键示例(Shortcut Keys Examples)
+
+- registerShortcuts
+- exampleShortcutLargerCallback
+- exampleShortcutSmallerCallback
+- exampleShortcutConflictionCallback
+
+### UI示例(UI Examples)
+
+
+
+- registerStyleSheet(the official make-it-red example)
+- registerRightClickMenuItem
+- registerRightClickMenuPopup
+- registerWindowMenuWithSeprator
+- registerExtraColumn
+- registerExtraColumnWithCustomCell
+- registerCustomItemBoxRow
+- registerLibraryTabPanel
+- registerReaderTabPanel
+
+### 首选项面板示例(Preference Pane Examples)
+
+
+
+- Preferences bindings
+- UI Events
+- Table
+- Locale
+
+详情参见 [`src/modules/preferenceScript.ts`](./src/modules/preferenceScript.ts)
+
+### 帮助示例(HelperExamples)
+
+
+
+- dialogExample
+- clipboardExample
+- filePickerExample
+- progressWindowExample
+- vtableExample(See Preference Pane Examples)
+
+### 指令行示例(PromptExamples)
+
+Obsidian风格的指令输入模块,它通过接受文本来运行插件,并在弹出窗口中显示可选项.
+
+使用 `Shift+P` 激活.
+
+
+
+- registerAlertPromptExample
+
+## Quick Start Guide 快速入门指南
+
+### 0 前置要求(Requirement)
+
+1. 安装测试版 Zotero:https://www.zotero.org/support/beta_builds
+2. 安装 Node.js(https://nodejs.org/en/)和 Git(https://git-scm.com/)
+
+> [!note]
+> 本指南假定你已经对 Zotero 插件的基本结构和工作原理有初步的了解. 如果你还不了解,请先参考官方文档(https://www.zotero.org/support/dev/zotero_7_for_developers)和官方插件样例 Make It Red(仓库地址 https://github.com/zotero/make-it-red).
+
+### 1 创建你的仓库(Create Your Repo)
+
+1. 点击 `Use this template`;
+2. 使用 `git clone` 克隆上一步生成的仓库;
+
+ 💡 从 GitHub Codespace 开始
+
+ _GitHub CodeSpace_ 使你可以直接开始开发而无需在本地下载代码/IDE/依赖.
+
+ 重复下列步骤,仅需三十秒即可开始构建你的第一个插件!
+
+ - 去 [homepage](https://github.com/windingwind/zotero-plugin-template)顶部,点击绿色按钮`Use this template`,点击 `Open in codespace`, 你需要登录你的GitHub账号.
+ - 等待 codespace 加载.
+
+
+
+3. 进入项目文件夹;
+
+### 2 配置模板和开发环境(Config Template Settings and Enviroment)
+
+1. 修改 `./package.json` 中的设置,包括:
+
+ ```json5
+ {
+ version: "", // to 0.0.0
+ author: "",
+ description: "",
+ homepage: "",
+ config: {
+ addonName: "", // name to be displayed in the plugin manager
+ addonID: "", // ID to avoid conflict. IMPORTANT!
+ addonRef: "", // e.g. Element ID prefix
+ addonInstance: "", // the plugin's root instance: Zotero.${addonInstance}
+ prefsPrefix: "extensions.zotero.${addonRef}", // the prefix of prefs
+ releasePage: "", // URL to releases
+ updateJSON: "", // URL to update.json
+ },
+ }
+ ```
+
+ > [!warning]
+ > 注意设置 addonID 和 addonRef 以避免冲突.
+
+如果你需要在GitHub以外的地方托管你的 XPI 包,请删除 `releasePage` 并添加 `updateLink`,并将值设置为你的 XPI 下载地址.
+
+2. 复制 Zotero 启动配置,填入 Zotero 可执行文件路径和 profile 路径.
+
+ > (可选项) 此操作仅需执行一次: 使用 `/path/to/zotero -p` 启动 Zotero,创建一个新的配置文件并用作开发配置文件.
+ > 将配置文件的路径 `profilePath` 放入 `zotero-cmd.json` 中,以指定要使用的配置文件.
+
+ ```sh
+ cp ./scripts/zotero-cmd-template.json ./scripts/zotero-cmd.json
+ vim ./scripts/zotero-cmd.json
+ ```
+
+3. 运行 `npm install` 以安装相关依赖
+
+ > 如果你使用 `pnpm` 作为包管理器,你需要添加 `public-hoist-pattern[]=*@types/bluebird*` 到`.npmrc`, 详情请查看 zotero-types(https://github.com/windingwind/zotero-types?tab=readme-ov-file#usage)的文档.
+
+### 3 开始开发(Coding)
+
+使用 `npm start` 启动开发服务器,它将:
+
+- 在开发模式下预构建插件
+- 启动 Zotero ,并让其从 `build/` 中加载插件
+- 打开开发者工具(devtool)
+- 监听 `src/**` 和 `addon/**`.
+ - 如果 `src/**` 修改了,运行 esbuild 并且重新加载
+ - 如果 `addon/**` 修改了,(在开发模式下)重新构建插件并且重新加载
+
+#### 自动热重载
+
+厌倦了无休止的重启吗?忘掉它,拥抱热加载!
+
+1. 运行 `npm start`.
+2. 编码. (是的,就这么简单)
+
+当检测到 `src` 或 `addon` 中的文件修改时,插件将自动编译并重新加载.
+
+
+💡 将此功能添加到现有插件的步骤
+
+1. 复制 `scripts/**.mjs`
+2. 复制 `server` 、`build` 和 `stop` 命令到 `package.json`
+3. 运行 `npm install --save-dev chokidar`
+4. 结束.
+
+
+
+#### 在 Zotero 中 Debug
+
+你还可以:
+
+- 在 Tools->Developer->Run Javascript 中测试代码片段;
+
+- 使用 `Zotero.debug()` 调试输出. 在 Help->Debug Output Logging->View Output 查看输出;
+
+- 调试 UI. Zotero 建立在 Firefox XUL 框架之上. 使用 [XUL Explorer](https://udn.realityripple.com/docs/Archive/Mozilla/XUL_Explorer) 等软件调试 XUL UI.
+
+ > XUL 文档:
+
+### 4 构建(Build)
+
+运行 `npm run build` 在生产模式下构建插件,构建的结果位于 `build/` 目录中.
+
+`scripts/build.mjs` 的运行步骤:
+
+- 创建/清空 `build/`
+- 复制 `addon/**` 到 `build/addon/**`
+- 替换占位符:使用 `replace-in-file` 去替换在 `package.json` 中定义的关键字和配置 (`xhtml`、`.flt` 等)
+- 准备本地化文件以避免冲突,查看官方文档了解更多(https://www.zotero.org/support/dev/zotero_7_for_developers#avoiding_localization_conflicts)
+ - 重命名`**/*.flt` 为 `**/${addonRef}-*.flt`
+ - 在每个消息前加上 `addonRef-`
+- 使用 Esbuild 来将 `.ts` 源码构建为 `.js`,从 `src/index.ts` 构建到`./build/addon/chrome/content/scripts`
+- (仅在生产模式下工作) 压缩 `./build/addon` 目录为 `./build/*.xpi`
+- (仅在生产模式下工作) 准备 `update.json` 或 `update-beta.json`
+
+> [!note]
+>
+> **Dev & prod 两者有什么区别?**
+>
+> - 此环境变量存储在 `Zotero.${addonInstance}.data.env` 中,控制台输出在生产模式下被禁用.
+> - 你可以根据此变量决定用户无法查看/使用的内容.
+> - 在生产模式下,构建脚本将自动打包插件并更新 `update.json`.
+
+### 5 发布(Release)
+
+如果要构建和发布插件,运行如下指令:
+
+```shell
+# A release-it command: version increase, npm run build, git push, and GitHub release
+# release-it: https://github.com/release-it/release-it
+npm run release
+```
+
+> [!note]
+> 在此模板中,release-it 被配置为在本地升级版本、构建、推送提交和 git 标签,随后GitHub Action 将重新构建插件并将 XPI 发布到 GitHub Release.
+>
+> 如果你需要发布一个本地构建的 XPI,将 `package.json` 中的 `release-it.github.release` 设置为 `true`,然后移除 `.github/workflows/release.yml`. 此外,你还需要设置环境变量 `GITHUB_TOKEN`,获取 GitHub Token(https://github.com/settings/tokens).
+
+#### 关于预发布
+
+该模板将 `prerelease` 定义为插件的测试版,当你在 release-it 中选择 `prerelease` 版本 (版本号中带有 `-` ),构建脚本将创建一个 `update-beta.json` 给预发布版本使用,这将确保常规版本的用户不会自动更新到测试版,只有手动下载并安装了测试版的用户才能自动更新到下一个测试版. 当下一个正式版本更新时,脚本将同步更新 `update.json` 和 `update-beta.json`,这将使正式版和测试版用户都可以更新到最新的正式版.
+
+> [!warning]
+> 严格来说,区分 Zotero 6 和 Zotero 7 兼容的插件版本应该通过 `update.json` 的 `addons.__addonID__.updates[]` 中分别配置 `applications.zotero.strict_min_version`,这样 Zotero 才能正确识别,详情在 Zotero 7 开发文档(https://www.zotero.org/support/dev/zotero_7_for_developers#updaterdf_updatesjson)获取.
+
+## Details 更多细节
+
+### 关于Hooks(About Hooks)
+
+> 可以在 [`src/hooks.ts`](https://github.com/windingwind/zotero-plugin-template/blob/main/src/hooks.ts) 中查看更多
+
+1. 当在 Zotero 中触发安装/启用/启动时,`bootstrap.js` > `startup` 被调用
+ - 等待 Zotero 就绪
+ - 加载 `index.js` (插件代码的主入口,从 `index.ts` 中构建)
+ - 如果是 Zotero 7 以上的版本则注册资源
+2. 主入口 `index.js` 中,插件对象被注入到 `Zotero` ,并且 `hooks.ts` > `onStartup` 被调用.
+ - 初始化插件需要的资源,包括通知监听器、首选项面板和UI元素.
+3. 当在 Zotero 中触发卸载/禁用时,`bootstrap.js` > `shutdown` 被调用.
+ - `events.ts` > `onShutdown` 被调用. 移除 UI 元素、首选项面板或插件创建的任何内容.
+ - 移除脚本并释放资源.
+
+### 关于全局变量(About Global Variables)
+
+> 可以在 [`src/index.ts`](https://github.com/windingwind/zotero-plugin-template/blob/main/src/index.ts)中查看更多
+
+bootstrap插件在沙盒中运行,但沙盒中没有默认的全局变量,例如 `Zotero` 或 `window` 等我们曾在overlay插件环境中使用的变量.
+
+此模板将以下变量注册到全局范围:
+
+```ts
+Zotero, ZoteroPane, Zotero_Tabs, window, document, rootURI, ztoolkit, addon;
+```
+
+### 创建元素 API(Create Elements API)
+
+插件模板为 bootstrap 插件提供了一些新的API. 我们有两个原因使用这些 API,而不是使用 `createElement/createElementNS`:
+
+- 在 bootstrap 模式下,插件必须在推出(禁用或卸载)时清理所有 UI 元素,这非常麻烦. 使用 `createElement`,插件模板将维护这些元素. 仅仅在退出时 `unregisterAll` .
+- Zotero 7 需要 createElement()/createElementNS() → createXULElement() 来表示其他的 XUL 元素,而 Zotero 6 并不支持 `createXULElement`. 类似于 React.createElement 的API `createElement` 检测 namespace(xul/html/svg) 并且自动创建元素,返回元素为对应的 TypeScript 元素类型.
+
+```ts
+createElement(document, "div"); // returns HTMLDivElement
+createElement(document, "hbox"); // returns XUL.Box
+createElement(document, "button", { namespace: "xul" }); // manually set namespace. returns XUL.Button
+```
+
+### 关于 Zotero API(About Zotero API)
+
+Zotero 文档已过时且不完整,克隆 https://github.com/zotero/zotero 并全局搜索关键字.
+
+> ⭐[zotero-types](https://github.com/windingwind/zotero-types) 提供了最常用的 Zotero API,在默认情况下它被包含在此模板中. 你的 IDE 将为大多数的 API 提供提醒.
+
+猜你需要:查找所需 API的技巧
+
+在 `.xhtml`/`.flt` 文件中搜索 UI 标签,然后在 locale 文件中找到对应的键. ,然后在 `.js`/`.jsx` 文件中搜索此键.
+
+### 目录结构(Directory Structure)
+
+本部分展示了模板的目录结构.
+
+- 所有的 `.js/.ts` 代码都在 `./src`;
+- 插件配置文件:`./addon/manifest.json`;
+- UI 文件: `./addon/chrome/content/*.xhtml`.
+- 区域设置文件: `./addon/locale/**/*.flt`;
+- 首选项文件: `./addon/prefs.js`;
+ > 不要在 `prefs.js` 中换行
+
+```shell
+.
+|-- .eslintrc.json # eslint conf
+|-- .gitattributes # git conf
+|-- .github/ # github conf
+|-- .gitignore # git conf
+|-- .prettierrc # prettier conf
+|-- .release-it.json # release-it conf
+|-- .vscode # vs code conf
+| |-- extensions.json
+| |-- launch.json
+| |-- setting.json
+| `-- toolkit.code-snippets
+|-- package-lock.json # npm conf
+|-- package.json # npm conf
+|-- LICENSE
+|-- README.md
+|-- addon
+| |-- bootstrap.js # addon load/unload script, like a main.c
+| |-- chrome
+| | `-- content
+| | |-- icons/
+| | |-- preferences.xhtml # preference panel
+| | `-- zoteroPane.css
+| |-- locale # locale
+| | |-- en-US
+| | | |-- addon.ftl
+| | | `-- preferences.ftl
+| | `-- zh-CN
+| | |-- addon.ftl
+| | `-- preferences.ftl
+| |-- manifest.json # addon config
+| `-- prefs.js
+|-- build/ # build dir
+|-- scripts # scripts for dev
+| |-- build.mjs # script to build plugin
+| |-- scripts.mjs # scripts send to Zotero, such as reload, openDevTool, etc
+| |-- server.mjs # script to start a development server
+| |-- start.mjs # script to start Zotero process
+| |-- stop.mjs # script to kill Zotero process
+| |-- utils.mjs # utils functions for dev scripts
+| |-- update-template.json # template of `update.json`
+| `-- zotero-cmd-template.json # template of local env
+|-- src # source code
+| |-- addon.ts # base class
+| |-- hooks.ts # lifecycle hooks
+| |-- index.ts # main entry
+| |-- modules # sub modules
+| | |-- examples.ts
+| | `-- preferenceScript.ts
+| `-- utils # utilities
+| |-- locale.ts
+| |-- prefs.ts
+| |-- wait.ts
+| `-- window.ts
+|-- tsconfig.json # https://code.visualstudio.com/docs/languages/jsconfig
+|-- typings # ts typings
+| `-- global.d.ts
+`-- update.json
+```
+
+## Disclaimer 免责声明
+
+在 AGPL 下使用此代码. 不提供任何保证. 遵守你所在地区的法律!
+
+如果你想更改许可,请通过 与我联系.
diff --git a/package.json b/package.json
index 39ff559..77479b6 100644
--- a/package.json
+++ b/package.json
@@ -8,26 +8,17 @@
"addonRef": "zoterotldr",
"addonInstance": "ZoteroTLDR",
"prefsPrefix": "extensions.zotero.zoterotldr",
- "releasepage": "https://github.com/syt2/zotero-tldr/releases/latest/download/zotero-tldr.xpi",
- "updaterdf": "https://raw.githubusercontent.com/syt2/zotero-tldr/main/update.json"
+ "releasepage": "https://github.com/syt2/zotero-tldr/releases",
+ "updateJSON": "https://raw.githubusercontent.com/syt2/zotero-tldr/main/update.json"
},
"main": "src/index.ts",
"scripts": {
- "build-dev": "cross-env NODE_ENV=development node scripts/build.mjs",
- "build-prod": "cross-env NODE_ENV=production node scripts/build.mjs",
- "build": "concurrently -c auto npm:build-prod npm:tsc",
- "tsc": "tsc --noEmit",
- "start": "node scripts/start.mjs",
- "start-watch": "npm run build-dev && concurrently -c auto npm:start npm:watch",
+ "start": "node scripts/server.mjs",
+ "build": "tsc --noEmit && node scripts/build.mjs production",
"stop": "node scripts/stop.mjs",
- "restart-dev": "npm run build-dev && npm run stop && npm run start",
- "restart-prod": "npm run build-prod && npm run stop && npm run start",
- "restart": "npm run restart-dev",
- "reload": "npm run build-dev && node scripts/reload.mjs",
- "watch": "chokidar \"src/**\" \"addon/**\" -c \"npm run reload\"",
- "release": "release-it",
"lint": "prettier --write . && eslint . --ext .ts --fix",
"test": "echo \"Error: no test specified\" && exit 1",
+ "release": "release-it --only-version --preReleaseId=beta",
"update-deps": "npm update --save"
},
"repository": {
@@ -41,23 +32,103 @@
},
"homepage": "https://github.com/syt2/zotero-tldr#readme",
"dependencies": {
- "zotero-plugin-toolkit": "^2.3.29"
+ "zotero-plugin-toolkit": "^2.3.15"
},
"devDependencies": {
- "@types/node": "^20.4.2",
- "@typescript-eslint/eslint-plugin": "^6.0.0",
- "@typescript-eslint/parser": "^6.0.0",
- "chokidar-cli": "^3.0.0",
- "compressing": "^1.9.0",
- "concurrently": "^8.2.0",
- "cross-env": "^7.0.3",
- "esbuild": "^0.18.12",
- "eslint": "^8.44.0",
- "eslint-config-prettier": "^9.0.0",
- "prettier": "^3.0.0",
- "release-it": "^16.1.0",
- "replace-in-file": "^7.0.1",
- "typescript": "^5.1.6",
- "zotero-types": "^1.3.11"
+ "@types/node": "^20.10.4",
+ "@typescript-eslint/eslint-plugin": "^7.3.1",
+ "@typescript-eslint/parser": "^7.1.1",
+ "chokidar": "^3.5.3",
+ "compressing": "^1.10.0",
+ "esbuild": "^0.20.1",
+ "eslint": "^8.55.0",
+ "eslint-config-prettier": "^9.1.0",
+ "prettier": "^3.1.1",
+ "release-it": "^17.0.1",
+ "replace-in-file": "^7.0.2",
+ "typescript": "^5.3.3",
+ "zotero-types": "^1.3.10"
+ },
+ "eslintConfig": {
+ "env": {
+ "browser": true,
+ "es2021": true
+ },
+ "root": true,
+ "extends": [
+ "eslint:recommended",
+ "plugin:@typescript-eslint/recommended",
+ "prettier"
+ ],
+ "overrides": [],
+ "parser": "@typescript-eslint/parser",
+ "parserOptions": {
+ "ecmaVersion": "latest",
+ "sourceType": "module"
+ },
+ "plugins": [
+ "@typescript-eslint"
+ ],
+ "rules": {
+ "@typescript-eslint/ban-ts-comment": [
+ "warn",
+ {
+ "ts-expect-error": "allow-with-description",
+ "ts-ignore": "allow-with-description",
+ "ts-nocheck": "allow-with-description",
+ "ts-check": "allow-with-description"
+ }
+ ],
+ "@typescript-eslint/no-unused-vars": "off",
+ "@typescript-eslint/no-explicit-any": [
+ "off",
+ {
+ "ignoreRestArgs": true
+ }
+ ],
+ "@typescript-eslint/no-non-null-assertion": "off"
+ },
+ "ignorePatterns": [
+ "**/build/**",
+ "**/logs/**",
+ "**/dist/**",
+ "**/node_modules/**",
+ "**/scripts/**",
+ "**/*.js",
+ "**/*.bak"
+ ]
+ },
+ "prettier": {
+ "printWidth": 80,
+ "tabWidth": 2,
+ "endOfLine": "lf",
+ "overrides": [
+ {
+ "files": [
+ "*.xhtml"
+ ],
+ "options": {
+ "htmlWhitespaceSensitivity": "css"
+ }
+ }
+ ]
+ },
+ "release-it": {
+ "git": {
+ "tagName": "v${version}"
+ },
+ "npm": {
+ "publish": false
+ },
+ "github": {
+ "release": false,
+ "assets": [
+ "build/*.xpi"
+ ]
+ },
+ "hooks": {
+ "before:init": "npm run lint",
+ "after:bump": "npm run build"
+ }
}
}
\ No newline at end of file
diff --git a/scripts/build.mjs b/scripts/build.mjs
index c747a9d..aab5c37 100644
--- a/scripts/build.mjs
+++ b/scripts/build.mjs
@@ -1,121 +1,29 @@
-import { build } from "esbuild";
-import { zip } from "compressing";
-import path from "path";
+import details from "../package.json" assert { type: "json" };
import {
- existsSync,
- lstatSync,
- writeFileSync,
- readFileSync,
- mkdirSync,
- readdirSync,
- rmSync,
- renameSync,
-} from "fs";
+ Logger,
+ clearFolder,
+ copyFileSync,
+ copyFolderRecursiveSync,
+ dateFormat,
+} from "./utils.mjs";
+import { zip } from "compressing";
+import { build } from "esbuild";
+import { existsSync, readdirSync, renameSync } from "fs";
+import path from "path";
import { env, exit } from "process";
import replaceInFile from "replace-in-file";
+
const { replaceInFileSync } = replaceInFile;
-import details from "../package.json" assert { type: "json" };
-const { name, author, description, homepage, version, config } = details;
+process.env.NODE_ENV =
+ process.argv[2] === "production" ? "production" : "development";
-const t = new Date();
-const buildTime = dateFormat("YYYY-mm-dd HH:MM:SS", new Date());
const buildDir = "build";
+const { name, author, description, homepage, version, config } = details;
const isPreRelease = version.includes("-");
-function copyFileSync(source, target) {
- var targetFile = target;
-
- // If target is a directory, a new file with the same name will be created
- if (existsSync(target)) {
- if (lstatSync(target).isDirectory()) {
- targetFile = path.join(target, path.basename(source));
- }
- }
-
- writeFileSync(targetFile, readFileSync(source));
-}
-
-function copyFolderRecursiveSync(source, target) {
- var files = [];
-
- // Check if folder needs to be created or integrated
- var targetFolder = path.join(target, path.basename(source));
- if (!existsSync(targetFolder)) {
- mkdirSync(targetFolder);
- }
-
- // Copy
- if (lstatSync(source).isDirectory()) {
- files = readdirSync(source);
- files.forEach(function (file) {
- var curSource = path.join(source, file);
- if (lstatSync(curSource).isDirectory()) {
- copyFolderRecursiveSync(curSource, targetFolder);
- } else {
- copyFileSync(curSource, targetFolder);
- }
- });
- }
-}
-
-function clearFolder(target) {
- if (existsSync(target)) {
- rmSync(target, { recursive: true, force: true });
- }
-
- mkdirSync(target, { recursive: true });
-}
-
-function dateFormat(fmt, date) {
- let ret;
- const opt = {
- "Y+": date.getFullYear().toString(),
- "m+": (date.getMonth() + 1).toString(),
- "d+": date.getDate().toString(),
- "H+": date.getHours().toString(),
- "M+": date.getMinutes().toString(),
- "S+": date.getSeconds().toString(),
- };
- for (let k in opt) {
- ret = new RegExp("(" + k + ")").exec(fmt);
- if (ret) {
- fmt = fmt.replace(
- ret[1],
- ret[1].length == 1 ? opt[k] : opt[k].padStart(ret[1].length, "0"),
- );
- }
- }
- return fmt;
-}
-
-function renameLocaleFiles() {
- const localeDir = path.join(buildDir, "addon/locale");
- const localeFolders = readdirSync(localeDir, { withFileTypes: true })
- .filter((dirent) => dirent.isDirectory())
- .map((dirent) => dirent.name);
-
- for (const localeSubFolder of localeFolders) {
- const localeSubDir = path.join(localeDir, localeSubFolder);
- const localeSubFiles = readdirSync(localeSubDir, {
- withFileTypes: true,
- })
- .filter((dirent) => dirent.isFile())
- .map((dirent) => dirent.name);
-
- for (const localeSubFile of localeSubFiles) {
- if (localeSubFile.endsWith(".ftl")) {
- renameSync(
- path.join(localeSubDir, localeSubFile),
- path.join(localeSubDir, `${config.addonRef}-${localeSubFile}`),
- );
- }
- }
- }
-}
-
-function replaceString() {
+function replaceString(buildTime) {
const replaceFrom = [
/__author__/g,
/__description__/g,
@@ -125,15 +33,20 @@ function replaceString() {
];
const replaceTo = [author, description, homepage, version, buildTime];
+ config.updateURL = isPreRelease
+ ? config.updateJSON.replace("update.json", "update-beta.json")
+ : config.updateJSON;
+
replaceFrom.push(
...Object.keys(config).map((k) => new RegExp(`__${k}__`, "g")),
);
replaceTo.push(...Object.values(config));
- const optionsAddon = {
+ const replaceResult = replaceInFileSync({
files: [
`${buildDir}/addon/**/*.xhtml`,
`${buildDir}/addon/**/*.html`,
+ `${buildDir}/addon/**/*.css`,
`${buildDir}/addon/**/*.json`,
`${buildDir}/addon/prefs.js`,
`${buildDir}/addon/manifest.json`,
@@ -142,137 +55,189 @@ function replaceString() {
from: replaceFrom,
to: replaceTo,
countMatches: true,
- };
-
- if (!isPreRelease) {
- optionsAddon.files.push("update.json");
- }
-
- const replaceResult = replaceInFileSync(optionsAddon);
-
- const localeMessage = new Set();
- const localeMessageMiss = new Set();
-
- const replaceResultFlt = replaceInFileSync({
- files: [`${buildDir}/addon/locale/**/*.ftl`],
- processor: (fltContent) => {
- const lines = fltContent.split("\n");
- const prefixedLines = lines.map((line) => {
- // https://regex101.com/r/lQ9x5p/1
- const match = line.match(
- /^(?[a-zA-Z]\S*)([ ]*=[ ]*)(?.*)$/m,
- );
- if (match) {
- localeMessage.add(match.groups.message);
- return `${config.addonRef}-${line}`;
- } else {
- return line;
- }
- });
- return prefixedLines.join("\n");
- },
});
- const replaceResultXhtml = replaceInFileSync({
- files: [`${buildDir}/addon/**/*.xhtml`],
+ // Logger.debug(
+ // "[Build] Run replace in ",
+ // replaceResult.filter((f) => f.hasChanged).map((f) => `${f.file} : ${f.numReplacements} / ${f.numMatches}`),
+ // );
+}
+
+function prepareLocaleFiles() {
+ // Prefix Fluent messages in xhtml
+ const MessagesInHTML = new Set();
+ replaceInFileSync({
+ files: [`${buildDir}/addon/**/*.xhtml`, `${buildDir}/addon/**/*.html`],
processor: (input) => {
const matchs = [...input.matchAll(/(data-l10n-id)="(\S*)"/g)];
matchs.map((match) => {
- if (localeMessage.has(match[2])) {
- input = input.replace(
- match[0],
- `${match[1]}="${config.addonRef}-${match[2]}"`,
- );
- } else {
- localeMessageMiss.add(match[2]);
- }
+ input = input.replace(
+ match[0],
+ `${match[1]}="${config.addonRef}-${match[2]}"`,
+ );
+ MessagesInHTML.add(match[2]);
});
return input;
},
});
- console.log(
- "[Build] Run replace in ",
- replaceResult
- .filter((f) => f.hasChanged)
- .map((f) => `${f.file} : ${f.numReplacements} / ${f.numMatches}`),
- replaceResultFlt.filter((f) => f.hasChanged).map((f) => `${f.file} : OK`),
- replaceResultXhtml.filter((f) => f.hasChanged).map((f) => `${f.file} : OK`),
- );
+ // Walk the sub folders of `build/addon/locale`
+ const localesPath = path.join(buildDir, "addon/locale"),
+ localeNames = readdirSync(localesPath, { withFileTypes: true })
+ .filter((dirent) => dirent.isDirectory())
+ .map((dirent) => dirent.name);
- if (localeMessageMiss.size !== 0) {
- console.warn(
- `[Build] [Warn] Fluent message [${new Array(
- ...localeMessageMiss,
- )}] do not exsit in addon's locale files.`,
- );
+ for (const localeName of localeNames) {
+ const localePath = path.join(localesPath, localeName);
+ const ftlFiles = readdirSync(localePath, {
+ withFileTypes: true,
+ })
+ .filter((dirent) => dirent.isFile())
+ .map((dirent) => dirent.name);
+
+ // rename *.ftl to addonRef-*.ftl
+ for (const ftlFile of ftlFiles) {
+ if (ftlFile.endsWith(".ftl")) {
+ renameSync(
+ path.join(localePath, ftlFile),
+ path.join(localePath, `${config.addonRef}-${ftlFile}`),
+ );
+ }
+ }
+
+ // Prefix Fluent messages in each ftl
+ const MessageInThisLang = new Set();
+ replaceInFileSync({
+ files: [`${buildDir}/addon/locale/${localeName}/*.ftl`],
+ processor: (fltContent) => {
+ const lines = fltContent.split("\n");
+ const prefixedLines = lines.map((line) => {
+ // https://regex101.com/r/lQ9x5p/1
+ const match = line.match(
+ /^(?[a-zA-Z]\S*)([ ]*=[ ]*)(?.*)$/m,
+ );
+ if (match) {
+ MessageInThisLang.add(match.groups.message);
+ return `${config.addonRef}-${line}`;
+ } else {
+ return line;
+ }
+ });
+ return prefixedLines.join("\n");
+ },
+ });
+
+ // If a message in xhtml but not in ftl of current language, log it
+ MessagesInHTML.forEach((message) => {
+ if (!MessageInThisLang.has(message)) {
+ Logger.error(`[Build] ${message} don't exist in ${localeName}`);
+ }
+ });
}
}
-async function esbuild() {
- await build({
- entryPoints: ["src/index.ts"],
- define: {
- __env__: `"${env.NODE_ENV}"`,
- },
- bundle: true,
- target: "firefox102",
- outfile: path.join(
- buildDir,
- `addon/chrome/content/scripts/${config.addonRef}.js`,
- ),
- // Don't turn minify on
- // minify: true,
- }).catch(() => exit(1));
+function prepareUpdateJson() {
+ // If it is a pre-release, use update-beta.json
+ if (!isPreRelease) {
+ copyFileSync("scripts/update-template.json", "update.json");
+ }
+ if (existsSync("update-beta.json") || isPreRelease) {
+ copyFileSync("scripts/update-template.json", "update-beta.json");
+ }
+
+ const updateLink =
+ config.updateLink ?? isPreRelease
+ ? `${config.releasePage}/download/v${version}/${name}.xpi`
+ : `${config.releasePage}/latest/download/${name}.xpi`;
+
+ const replaceResult = replaceInFileSync({
+ files: [
+ "update-beta.json",
+ isPreRelease ? "pass" : "update.json",
+ `${buildDir}/addon/manifest.json`,
+ ],
+ from: [
+ /__addonID__/g,
+ /__buildVersion__/g,
+ /__updateLink__/g,
+ /__updateURL__/g,
+ ],
+ to: [config.addonID, version, updateLink, config.updateURL],
+ countMatches: true,
+ });
+
+ Logger.debug(
+ `[Build] Prepare Update.json for ${
+ isPreRelease
+ ? "\u001b[31m Prerelease \u001b[0m"
+ : "\u001b[32m Release \u001b[0m"
+ }`,
+ replaceResult
+ .filter((f) => f.hasChanged)
+ .map((f) => `${f.file} : ${f.numReplacements} / ${f.numMatches}`),
+ );
}
-async function main() {
- console.log(
+export const esbuildOptions = {
+ entryPoints: ["src/index.ts"],
+ define: {
+ __env__: `"${env.NODE_ENV}"`,
+ },
+ bundle: true,
+ target: "firefox102",
+ outfile: path.join(
+ buildDir,
+ `addon/chrome/content/scripts/${config.addonRef}.js`,
+ ),
+ // Don't turn minify on
+ minify: env.NODE_ENV === "production",
+};
+
+export async function main() {
+ const t = new Date();
+ const buildTime = dateFormat("YYYY-mm-dd HH:MM:SS", new Date());
+
+ Logger.info(
`[Build] BUILD_DIR=${buildDir}, VERSION=${version}, BUILD_TIME=${buildTime}, ENV=${[
env.NODE_ENV,
]}`,
);
clearFolder(buildDir);
-
copyFolderRecursiveSync("addon", buildDir);
- if (isPreRelease) {
- console.log(
- "[Build] [Warn] Running in pre-release mode. update.json will not be replaced.",
+ Logger.debug("[Build] Replacing");
+ replaceString(buildTime);
+
+ Logger.debug("[Build] Preparing locale files");
+ prepareLocaleFiles();
+
+ Logger.debug("[Build] Running esbuild");
+ await build(esbuildOptions);
+
+ Logger.debug("[Build] Addon prepare OK");
+
+ if (process.env.NODE_ENV === "production") {
+ Logger.debug("[Build] Packing Addon");
+ await zip.compressDir(
+ path.join(buildDir, "addon"),
+ path.join(buildDir, `${name}.xpi`),
+ {
+ ignoreBase: true,
+ },
+ );
+
+ prepareUpdateJson();
+
+ Logger.debug(
+ `[Build] Finished in ${(new Date().getTime() - t.getTime()) / 1000} s.`,
);
- } else {
- copyFileSync("update-template.json", "update.json");
}
-
- await esbuild();
-
- console.log("[Build] Run esbuild OK");
-
- replaceString();
-
- console.log("[Build] Replace OK");
-
- // Walk the builds/addon/locale folder's sub folders and rename *.ftl to addonRef-*.ftl
- renameLocaleFiles();
-
- console.log("[Build] Addon prepare OK");
-
- await zip.compressDir(
- path.join(buildDir, "addon"),
- path.join(buildDir, `${name}.xpi`),
- {
- ignoreBase: true,
- },
- );
-
- console.log("[Build] Addon pack OK");
- console.log(
- `[Build] Finished in ${(new Date().getTime() - t.getTime()) / 1000} s.`,
- );
}
-main().catch((err) => {
- console.log(err);
- exit(1);
-});
+if (process.env.NODE_ENV === "production") {
+ main().catch((err) => {
+ Logger.error(err);
+ exit(1);
+ });
+}
diff --git a/scripts/reload.mjs b/scripts/reload.mjs
deleted file mode 100644
index 5cf84c7..0000000
--- a/scripts/reload.mjs
+++ /dev/null
@@ -1,34 +0,0 @@
-import { exit } from "process";
-import { execSync } from "child_process";
-import details from "../package.json" assert { type: "json" };
-import cmd from "./zotero-cmd.json" assert { type: "json" };
-
-const { addonID, addonName } = details.config;
-const { version } = details;
-const { zoteroBinPath, profilePath } = cmd.exec;
-
-const startZotero = `"${zoteroBinPath}" --debugger --purgecaches -profile "${profilePath}"`;
-
-const script = `
-(async () => {
- Services.obs.notifyObservers(null, "startupcache-invalidate", null);
- const { AddonManager } = ChromeUtils.import("resource://gre/modules/AddonManager.jsm");
- const addon = await AddonManager.getAddonByID("${addonID}");
- await addon.reload();
- const progressWindow = new Zotero.ProgressWindow({ closeOnClick: true });
- progressWindow.changeHeadline("${addonName} Hot Reload");
- progressWindow.progress = new progressWindow.ItemProgress(
- "chrome://zotero/skin/tick.png",
- "VERSION=${version}, BUILD=${new Date().toLocaleString()}. By zotero-plugin-toolkit"
- );
- progressWindow.progress.setProgress(100);
- progressWindow.show();
- progressWindow.startCloseTimer(5000);
-})()`;
-
-const url = `zotero://ztoolkit-debug/?run=${encodeURIComponent(script)}`;
-
-const command = `${startZotero} -url "${url}"`;
-
-execSync(command);
-exit(0);
diff --git a/scripts/scripts.mjs b/scripts/scripts.mjs
new file mode 100644
index 0000000..126d8e6
--- /dev/null
+++ b/scripts/scripts.mjs
@@ -0,0 +1,75 @@
+import details from "../package.json" assert { type: "json" };
+
+const { addonID, addonName } = details.config;
+const { version } = details;
+
+export const reloadScript = `
+(async () => {
+Services.obs.notifyObservers(null, "startupcache-invalidate", null);
+const { AddonManager } = ChromeUtils.import("resource://gre/modules/AddonManager.jsm");
+const addon = await AddonManager.getAddonByID("${addonID}");
+await addon.reload();
+const progressWindow = new Zotero.ProgressWindow({ closeOnClick: true });
+progressWindow.changeHeadline("${addonName} Hot Reload");
+progressWindow.progress = new progressWindow.ItemProgress(
+ "chrome://zotero/skin/tick.png",
+ "VERSION=${version}, BUILD=${new Date().toLocaleString()}. By zotero-plugin-toolkit"
+);
+progressWindow.progress.setProgress(100);
+progressWindow.show();
+progressWindow.startCloseTimer(5000);
+})()`;
+
+export const openDevToolScript = `
+(async () => {
+
+// const { BrowserToolboxLauncher } = ChromeUtils.import(
+// "resource://devtools/client/framework/browser-toolbox/Launcher.jsm",
+// );
+// BrowserToolboxLauncher.init();
+// TODO: Use the above code to open the devtool after https://github.com/zotero/zotero/pull/3387
+
+Zotero.Prefs.set("devtools.debugger.remote-enabled", true, true);
+Zotero.Prefs.set("devtools.debugger.remote-port", 6100, true);
+Zotero.Prefs.set("devtools.debugger.prompt-connection", false, true);
+Zotero.Prefs.set("devtools.debugger.chrome-debugging-websocket", false, true);
+
+env =
+ Services.env ||
+ Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment);
+
+env.set("MOZ_BROWSER_TOOLBOX_PORT", 6100);
+Zotero.openInViewer(
+ "chrome://devtools/content/framework/browser-toolbox/window.html",
+ {
+ onLoad: (doc) => {
+ doc.querySelector("#status-message-container").style.visibility =
+ "collapse";
+ let toolboxBody;
+ waitUntil(
+ () => {
+ toolboxBody = doc
+ .querySelector(".devtools-toolbox-browsertoolbox-iframe")
+ ?.contentDocument?.querySelector(".theme-body");
+ return toolboxBody;
+ },
+ () => {
+ toolboxBody.style = "pointer-events: all !important";
+ }
+ );
+ },
+ }
+);
+
+function waitUntil(condition, callback, interval = 100, timeout = 10000) {
+ const start = Date.now();
+ const intervalId = setInterval(() => {
+ if (condition()) {
+ clearInterval(intervalId);
+ callback();
+ } else if (Date.now() - start > timeout) {
+ clearInterval(intervalId);
+ }
+ }, interval);
+}
+})()`;
diff --git a/scripts/server.mjs b/scripts/server.mjs
new file mode 100644
index 0000000..3f35430
--- /dev/null
+++ b/scripts/server.mjs
@@ -0,0 +1,87 @@
+import { main as build, esbuildOptions } from "./build.mjs";
+import { openDevToolScript, reloadScript } from "./scripts.mjs";
+import { main as startZotero } from "./start.mjs";
+import { Logger } from "./utils.mjs";
+import cmd from "./zotero-cmd.json" assert { type: "json" };
+import { execSync } from "child_process";
+import chokidar from "chokidar";
+import { context } from "esbuild";
+import { exit } from "process";
+
+process.env.NODE_ENV = "development";
+
+const { zoteroBinPath, profilePath } = cmd.exec;
+
+const startZoteroCmd = `"${zoteroBinPath}" --debugger --purgecaches -profile "${profilePath}"`;
+
+async function watch() {
+ const watcher = chokidar.watch(["src/**", "addon/**"], {
+ ignored: /(^|[\/\\])\../, // ignore dotfiles
+ persistent: true,
+ });
+
+ let esbuildCTX = await context(esbuildOptions);
+
+ watcher
+ .on("ready", () => {
+ Logger.info("Server Ready! \n");
+ })
+ .on("change", async (path) => {
+ Logger.info(`${path} changed.`);
+ if (path.startsWith("src")) {
+ await esbuildCTX.rebuild();
+ } else if (path.startsWith("addon")) {
+ await build()
+ // Do not abort the watcher when errors occur in builds triggered by the watcher.
+ .catch((err) => {
+ Logger.error(err);
+ });
+ }
+ // reload
+ reload();
+ })
+ .on("error", (err) => {
+ Logger.error("Server start failed!", err);
+ });
+}
+
+function reload() {
+ Logger.debug("Reloading...");
+ const url = `zotero://ztoolkit-debug/?run=${encodeURIComponent(
+ reloadScript,
+ )}`;
+ const command = `${startZoteroCmd} -url "${url}"`;
+ execSync(command);
+}
+
+function openDevTool() {
+ Logger.debug("Open dev tools...");
+ const url = `zotero://ztoolkit-debug/?run=${encodeURIComponent(
+ openDevToolScript,
+ )}`;
+ const command = `${startZoteroCmd} -url "${url}"`;
+ execSync(command);
+}
+
+async function main() {
+ // build
+ await build();
+
+ // start Zotero
+ startZotero(openDevTool);
+
+ // watch
+ await watch();
+}
+
+main().catch((err) => {
+ Logger.error(err);
+ // execSync("node scripts/stop.mjs");
+ exit(1);
+});
+
+process.on("SIGINT", (code) => {
+ execSync("node scripts/stop.mjs");
+ Logger.info(`Server terminated with signal ${code}.`);
+ exit(0);
+});
diff --git a/scripts/start.mjs b/scripts/start.mjs
index 118b5a0..0229d2b 100644
--- a/scripts/start.mjs
+++ b/scripts/start.mjs
@@ -1,33 +1,39 @@
-import { execSync } from "child_process";
-import { exit } from "process";
-import { existsSync, writeFileSync, readFileSync, mkdirSync } from "fs";
-import path from "path";
import details from "../package.json" assert { type: "json" };
+import { Logger } from "./utils.mjs";
import cmd from "./zotero-cmd.json" assert { type: "json" };
+import { spawn } from "child_process";
+import { existsSync, readFileSync, writeFileSync, rmSync } from "fs";
+import { clearFolder } from "./utils.mjs";
+import path from "path";
+import { exit } from "process";
const { addonID } = details.config;
const { zoteroBinPath, profilePath, dataDir } = cmd.exec;
+// Keep in sync with the addon's onStartup
+const loadDevToolWhen = `Plugin ${addonID} startup`;
+
+const logPath = "logs";
+const logFilePath = path.join(logPath, "zotero.log");
+
if (!existsSync(zoteroBinPath)) {
throw new Error("Zotero binary does not exist.");
}
-if (existsSync(profilePath)) {
+if (!existsSync(profilePath)) {
+ throw new Error("The given Zotero profile does not exist.");
+}
+
+function prepareDevEnv() {
const addonProxyFilePath = path.join(profilePath, `extensions/${addonID}`);
const buildPath = path.resolve("build/addon");
- if (!existsSync(path.join(buildPath, "./manifest.json"))) {
- throw new Error(
- `The built file does not exist, maybe you need to build the addon first.`,
- );
- }
-
function writeAddonProxyFile() {
writeFileSync(addonProxyFilePath, buildPath);
- console.log(
- `[info] Addon proxy file has been updated.
- File path: ${addonProxyFilePath}
- Addon path: ${buildPath} `,
+ Logger.debug(
+ `Addon proxy file has been updated.
+ File path: ${addonProxyFilePath}
+ Addon path: ${buildPath} `,
);
}
@@ -36,15 +42,14 @@ if (existsSync(profilePath)) {
writeAddonProxyFile();
}
} else {
- if (
- existsSync(profilePath) &&
- !existsSync(path.join(profilePath, "extensions"))
- ) {
- mkdirSync(path.join(profilePath, "extensions"));
- }
writeAddonProxyFile();
}
+ const addonXpiFilePath = path.join(profilePath, `extensions/${addonID}.xpi`);
+ if (existsSync(addonXpiFilePath)) {
+ rmSync(addonXpiFilePath);
+ }
+
const prefsPath = path.join(profilePath, "prefs.js");
if (existsSync(prefsPath)) {
const PrefsLines = readFileSync(prefsPath, "utf-8").split("\n");
@@ -56,19 +61,59 @@ if (existsSync(profilePath)) {
return;
}
if (line.includes("extensions.zotero.dataDir") && dataDir !== "") {
- return `user_pref("extensions.zotero.dataDir", "${dataDir}");`;
+ return `user_pref("extensions.zotero.dataDir", "${dataDir.replace(/\\\\?/g, "\\\\")}");`;
}
return line;
});
const updatedPrefs = filteredLines.join("\n");
writeFileSync(prefsPath, updatedPrefs, "utf-8");
- console.log("[info] The /prefs.js has been modified.");
+ Logger.debug("The /prefs.js has been modified.");
}
-} else {
- throw new Error("The given Zotero profile does not exist.");
}
-const startZotero = `"${zoteroBinPath}" --debugger --purgecaches -profile "${profilePath}"`;
+function prepareLog() {
+ clearFolder(logPath);
+ writeFileSync(logFilePath, "");
+}
-execSync(startZotero);
-exit(0);
+export function main(callback) {
+ let isZoteroReady = false;
+
+ prepareDevEnv();
+
+ prepareLog();
+
+ const zoteroProcess = spawn(zoteroBinPath, [
+ "--debugger",
+ "--purgecaches",
+ "-profile",
+ profilePath,
+ ]);
+
+ zoteroProcess.stdout.on("data", (data) => {
+ if (!isZoteroReady && data.toString().includes(loadDevToolWhen)) {
+ isZoteroReady = true;
+ callback();
+ }
+ writeFileSync(logFilePath, data, {
+ flag: "a",
+ });
+ });
+
+ zoteroProcess.stderr.on("data", (data) => {
+ writeFileSync(logFilePath, data, {
+ flag: "a",
+ });
+ });
+
+ zoteroProcess.on("close", (code) => {
+ Logger.info(`Zotero terminated with code ${code}.`);
+ exit(0);
+ });
+
+ process.on("SIGINT", () => {
+ // Handle interrupt signal (Ctrl+C) to gracefully terminate Zotero process
+ zoteroProcess.kill();
+ exit();
+ });
+}
diff --git a/scripts/stop.mjs b/scripts/stop.mjs
index 197ef04..3b44ef2 100644
--- a/scripts/stop.mjs
+++ b/scripts/stop.mjs
@@ -1,14 +1,26 @@
-import process from "process";
-import { execSync } from "child_process";
+import { Logger, isRunning } from "./utils.mjs";
import cmd from "./zotero-cmd.json" assert { type: "json" };
+import { execSync } from "child_process";
+import process from "process";
+
const { killZoteroWindows, killZoteroUnix } = cmd;
-try {
- if (process.platform === "win32") {
- execSync(killZoteroWindows);
+isRunning("zotero", (status) => {
+ if (status) {
+ killZotero();
} else {
- execSync(killZoteroUnix);
+ Logger.warn("No Zotero running.");
+ }
+});
+
+function killZotero() {
+ try {
+ if (process.platform === "win32") {
+ execSync(killZoteroWindows);
+ } else {
+ execSync(killZoteroUnix);
+ }
+ } catch (e) {
+ Logger.error(e);
}
-} catch (e) {
- console.error(e);
}
diff --git a/update-template.json b/scripts/update-template.json
similarity index 85%
rename from update-template.json
rename to scripts/update-template.json
index ad674e8..8f65a4c 100644
--- a/update-template.json
+++ b/scripts/update-template.json
@@ -4,7 +4,7 @@
"updates": [
{
"version": "__buildVersion__",
- "update_link": "__releasepage__",
+ "update_link": "__updateLink__",
"applications": {
"zotero": {
"strict_min_version": "6.999"
diff --git a/scripts/utils.mjs b/scripts/utils.mjs
new file mode 100644
index 0000000..d17288a
--- /dev/null
+++ b/scripts/utils.mjs
@@ -0,0 +1,129 @@
+import { exec } from "child_process";
+import {
+ existsSync,
+ lstatSync,
+ mkdirSync,
+ readFileSync,
+ readdirSync,
+ rmSync,
+ writeFileSync,
+} from "fs";
+import path from "path";
+
+export function copyFileSync(source, target) {
+ var targetFile = target;
+
+ // If target is a directory, a new file with the same name will be created
+ if (existsSync(target)) {
+ if (lstatSync(target).isDirectory()) {
+ targetFile = path.join(target, path.basename(source));
+ }
+ }
+
+ writeFileSync(targetFile, readFileSync(source));
+}
+
+export function copyFolderRecursiveSync(source, target) {
+ var files = [];
+
+ // Check if folder needs to be created or integrated
+ var targetFolder = path.join(target, path.basename(source));
+ if (!existsSync(targetFolder)) {
+ mkdirSync(targetFolder);
+ }
+
+ // Copy
+ if (lstatSync(source).isDirectory()) {
+ files = readdirSync(source);
+ files.forEach(function (file) {
+ var curSource = path.join(source, file);
+ if (lstatSync(curSource).isDirectory()) {
+ copyFolderRecursiveSync(curSource, targetFolder);
+ } else {
+ copyFileSync(curSource, targetFolder);
+ }
+ });
+ }
+}
+
+export function clearFolder(target) {
+ if (existsSync(target)) {
+ rmSync(target, { recursive: true, force: true });
+ }
+
+ mkdirSync(target, { recursive: true });
+}
+
+export function dateFormat(fmt, date) {
+ let ret;
+ const opt = {
+ "Y+": date.getFullYear().toString(),
+ "m+": (date.getMonth() + 1).toString(),
+ "d+": date.getDate().toString(),
+ "H+": date.getHours().toString(),
+ "M+": date.getMinutes().toString(),
+ "S+": date.getSeconds().toString(),
+ };
+ for (let k in opt) {
+ ret = new RegExp("(" + k + ")").exec(fmt);
+ if (ret) {
+ fmt = fmt.replace(
+ ret[1],
+ ret[1].length == 1 ? opt[k] : opt[k].padStart(ret[1].length, "0"),
+ );
+ }
+ }
+ return fmt;
+}
+
+export class Logger {
+ static log(...args) {
+ console.log(...args);
+ }
+
+ // red
+ static error(...args) {
+ console.error("\u001b[31m [ERROR]", ...args, "\u001b[0m");
+ }
+
+ // yellow
+ static warn(...args) {
+ console.warn("\u001b[33m [WARN]", ...args, "\u001b[0m");
+ }
+
+ // blue
+ static debug(...args) {
+ console.log("\u001b[34m [DEBUG]\u001b[0m", ...args);
+ }
+
+ // green
+ static info(...args) {
+ console.log("\u001b[32m [INFO]", ...args, "\u001b[0m");
+ }
+
+ // cyan
+ static trace(...args) {
+ console.log("\u001b[36m [TRACE]\u001b[0m", ...args);
+ }
+}
+
+export function isRunning(query, cb) {
+ let platform = process.platform;
+ let cmd = "";
+ switch (platform) {
+ case "win32":
+ cmd = `tasklist`;
+ break;
+ case "darwin":
+ cmd = `ps -ax | grep ${query}`;
+ break;
+ case "linux":
+ cmd = `ps -A`;
+ break;
+ default:
+ break;
+ }
+ exec(cmd, (err, stdout, stderr) => {
+ cb(stdout.toLowerCase().indexOf(query.toLowerCase()) > -1);
+ });
+}
diff --git a/scripts/zotero-cmd-default.json b/scripts/zotero-cmd-template.json
similarity index 93%
rename from scripts/zotero-cmd-default.json
rename to scripts/zotero-cmd-template.json
index c02a99f..27143f6 100644
--- a/scripts/zotero-cmd-default.json
+++ b/scripts/zotero-cmd-template.json
@@ -1,7 +1,7 @@
{
"usage": "Copy and rename this file to zotero-cmd.json. Edit the cmd.",
"killZoteroWindows": "taskkill /f /im zotero.exe",
- "killZoteroUnix": "kill -9 $(ps -x | grep zotero)",
+ "killZoteroUnix": "kill -9 $(ps -x | grep '[z]otero' | awk '{print $1}')",
"exec": {
"@comment-zoteroBinPath": "Please input the path of the Zotero binary file in `zoteroBinPath`.",
"@comment-zoteroBinPath-tip": "The path delimiter should be escaped as `\\` for win32. The path is `*/Zotero.app/Contents/MacOS/zotero` for MacOS.",
diff --git a/src/hooks.ts b/src/hooks.ts
index 0f6d741..2b3dfbc 100644
--- a/src/hooks.ts
+++ b/src/hooks.ts
@@ -12,6 +12,14 @@ async function onStartup() {
Zotero.unlockPromise,
Zotero.uiReadyPromise,
]);
+
+ // TODO: Remove this after zotero#3387 is merged
+ if (__env__ === "development") {
+ // Keep in sync with the scripts/startup.mjs
+ const loadDevToolWhen = `Plugin ${config.addonID} startup`;
+ ztoolkit.log(loadDevToolWhen);
+ }
+
initLocale();
await tldrs.getAsync();
@@ -25,8 +33,6 @@ async function onMainWindowLoad(win: Window): Promise {
// Create ztoolkit for every window
addon.data.ztoolkit = createZToolkit();
- await Zotero.Promise.delay(1000);
-
UIFactory.registerRightClickMenuItem();
UIFactory.registerRightClickCollectionMenuItem();
@@ -158,11 +164,9 @@ function onUpdateItems(items: Zotero.Item[], forceFetch: boolean = false) {
ztoolkit.ItemBox.refresh();
popupWin.changeLine({
progress: (index * 100) / count,
- text: `${getString("popWindow-waiting")}: ${
- count - index - 1
- }; ${getString("popWindow-succeed")}: ${
- succeedItems.length
- }; ${getString("popWindow-failed")}: ${failedItems.length}`,
+ text: `${getString("popWindow-waiting")}: ${count - index - 1
+ }; ${getString("popWindow-succeed")}: ${succeedItems.length
+ }; ${getString("popWindow-failed")}: ${failedItems.length}`,
});
}
})();
@@ -171,9 +175,8 @@ function onUpdateItems(items: Zotero.Item[], forceFetch: boolean = false) {
popupWin.changeLine({
type: "success",
progress: 100,
- text: `${getString("popWindow-succeed")}: ${
- succeedItems.length
- }; ${getString("popWindow-failed")}: ${failedItems.length}`,
+ text: `${getString("popWindow-succeed")}: ${succeedItems.length
+ }; ${getString("popWindow-failed")}: ${failedItems.length}`,
});
popupWin.startCloseTimer(3000);
})();
@@ -182,7 +185,7 @@ function onUpdateItems(items: Zotero.Item[], forceFetch: boolean = false) {
// Add your hooks here. For element click, etc.
// Keep in mind hooks only do dispatch. Don't add code that does real jobs in hooks.
-// Otherwise the code would be hard to read and maintian.
+// Otherwise the code would be hard to read and maintain.
export default {
onStartup,
diff --git a/src/index.ts b/src/index.ts
index 86c6a19..f413754 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -5,8 +5,6 @@ import { config } from "../package.json";
const basicTool = new BasicTool();
if (!basicTool.getGlobal("Zotero")[config.addonInstance]) {
- // Set global variables
- _globalThis.Zotero = basicTool.getGlobal("Zotero");
defineGlobal("window");
defineGlobal("document");
defineGlobal("ZoteroPane");
@@ -16,8 +14,6 @@ if (!basicTool.getGlobal("Zotero")[config.addonInstance]) {
return _globalThis.addon.data.ztoolkit;
});
Zotero[config.addonInstance] = addon;
- // Trigger addon hook for initialization
- addon.hooks.onStartup();
}
function defineGlobal(name: Parameters[0]): void;