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 + +[![zotero target version](https://img.shields.io/badge/Zotero-7-green?style=flat-square&logo=zotero&logoColor=CC2936)](https://www.zotero.org) +[![Using Zotero Plugin Template](https://img.shields.io/badge/Using-Zotero%20Plugin%20Template-blue?style=flat-square&logo=github)](https://github.com/windingwind/zotero-plugin-template) + +这是 [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 本仓库,以及时收到修复或更新的通知. + +## 使用此模板构建的插件 + +[![GitHub Repo stars](https://img.shields.io/github/stars/windingwind/zotero-better-notes?label=zotero-better-notes&style=flat-square)](https://github.com/windingwind/zotero-better-notes) +[![GitHub Repo stars](https://img.shields.io/github/stars/windingwind/zotero-pdf-preview?label=zotero-pdf-preview&style=flat-square)](https://github.com/windingwind/zotero-pdf-preview) +[![GitHub Repo stars](https://img.shields.io/github/stars/windingwind/zotero-pdf-translate?label=zotero-pdf-translate&style=flat-square)](https://github.com/windingwind/zotero-pdf-translate) +[![GitHub Repo stars](https://img.shields.io/github/stars/windingwind/zotero-tag?label=zotero-tag&style=flat-square)](https://github.com/windingwind/zotero-tag) +[![GitHub Repo stars](https://img.shields.io/github/stars/iShareStuff/ZoteroTheme?label=zotero-theme&style=flat-square)](https://github.com/iShareStuff/ZoteroTheme) +[![GitHub Repo stars](https://img.shields.io/github/stars/MuiseDestiny/zotero-reference?label=zotero-reference&style=flat-square)](https://github.com/MuiseDestiny/zotero-reference) +[![GitHub Repo stars](https://img.shields.io/github/stars/MuiseDestiny/zotero-citation?label=zotero-citation&style=flat-square)](https://github.com/MuiseDestiny/zotero-citation) +[![GitHub Repo stars](https://img.shields.io/github/stars/MuiseDestiny/ZoteroStyle?label=zotero-style&style=flat-square)](https://github.com/MuiseDestiny/ZoteroStyle) +[![GitHub Repo stars](https://img.shields.io/github/stars/volatile-static/Chartero?label=Chartero&style=flat-square)](https://github.com/volatile-static/Chartero) +[![GitHub Repo stars](https://img.shields.io/github/stars/l0o0/tara?label=tara&style=flat-square)](https://github.com/l0o0/tara) +[![GitHub Repo stars](https://img.shields.io/github/stars/redleafnew/delitemwithatt?label=delitemwithatt&style=flat-square)](https://github.com/redleafnew/delitemwithatt) +[![GitHub Repo stars](https://img.shields.io/github/stars/redleafnew/zotero-updateifsE?label=zotero-updateifsE&style=flat-square)](https://github.com/redleafnew/zotero-updateifsE) +[![GitHub Repo stars](https://img.shields.io/github/stars/northword/zotero-format-metadata?label=zotero-format-metadata&style=flat-square)](https://github.com/northword/zotero-format-metadata) +[![GitHub Repo stars](https://img.shields.io/github/stars/inciteful-xyz/inciteful-zotero-plugin?label=inciteful-zotero-plugin&style=flat-square)](https://github.com/inciteful-xyz/inciteful-zotero-plugin) +[![GitHub Repo stars](https://img.shields.io/github/stars/MuiseDestiny/zotero-gpt?label=zotero-gpt&style=flat-square)](https://github.com/MuiseDestiny/zotero-gpt) +[![GitHub Repo stars](https://img.shields.io/github/stars/zoushucai/zotero-journalabbr?label=zotero-journalabbr&style=flat-square)](https://github.com/zoushucai/zotero-journalabbr) +[![GitHub Repo stars](https://img.shields.io/github/stars/MuiseDestiny/zotero-figure?label=zotero-figure&style=flat-square)](https://github.com/MuiseDestiny/zotero-figure) +[![GitHub Repo stars](https://img.shields.io/github/stars/l0o0/jasminum?label=jasminum&style=flat-square)](https://github.com/l0o0/jasminum) +[![GitHub Repo stars](https://img.shields.io/github/stars/lifan0127/ai-research-assistant?label=ai-research-assistant&style=flat-square)](https://github.com/lifan0127/ai-research-assistant) + +[![GitHub Repo stars](https://img.shields.io/github/stars/daeh/zotero-markdb-connect?label=zotero-markdb-connect&style=flat-square)](https://github.com/daeh/zotero-markdb-connect) + +如果你正在使用此库,我建议你将这个标志 ([![Using Zotero Plugin Template](https://img.shields.io/badge/Using-Zotero%20Plugin%20Template-blue?style=flat-square&logo=github)](https://github.com/windingwind/zotero-plugin-template)) 放在 README 文件中: + +```md +[![Using Zotero Plugin Template](https://img.shields.io/badge/Using-Zotero%20Plugin%20Template-blue?style=flat-square&logo=github)](https://github.com/windingwind/zotero-plugin-template) +``` + +## 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) + +![image](https://user-images.githubusercontent.com/33902321/211739774-cc5c2df8-5fd9-42f0-9cdf-0f2e5946d427.png) + +- registerStyleSheet(the official make-it-red example) +- registerRightClickMenuItem +- registerRightClickMenuPopup +- registerWindowMenuWithSeprator +- registerExtraColumn +- registerExtraColumnWithCustomCell +- registerCustomItemBoxRow +- registerLibraryTabPanel +- registerReaderTabPanel + +### 首选项面板示例(Preference Pane Examples) + +![image](https://user-images.githubusercontent.com/33902321/211737987-cd7c5c87-9177-4159-b975-dc67690d0490.png) + +- Preferences bindings +- UI Events +- Table +- Locale + +详情参见 [`src/modules/preferenceScript.ts`](./src/modules/preferenceScript.ts) + +### 帮助示例(HelperExamples) + +![image](https://user-images.githubusercontent.com/33902321/215119473-e7d0d0ef-6d96-437e-b989-4805ffcde6cf.png) + +- dialogExample +- clipboardExample +- filePickerExample +- progressWindowExample +- vtableExample(See Preference Pane Examples) + +### 指令行示例(PromptExamples) + +Obsidian风格的指令输入模块,它通过接受文本来运行插件,并在弹出窗口中显示可选项. + +使用 `Shift+P` 激活. + +![image](https://user-images.githubusercontent.com/33902321/215120009-e7c7ed27-33a0-44fe-b021-06c272481a92.png) + +- 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;