├── typings
└── global.d.ts
├── lib
├── zotero.d.ts
└── log.ts
├── .gitignore
├── zotero-plugin.ini
├── public.pem
├── .github
└── workflows
│ ├── release.yml
│ └── label-gun.yml
├── tsconfig.json
├── dprint.json
├── monkey-patch.ts
├── icons
├── pdf.svg
├── epub.svg
└── snapshot.svg
├── icons.mjs
├── bootstrap.ts
├── package.json
├── private.pem.json
├── esbuild.js
├── README.md
└── lib.ts
/typings/global.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace Zotero {
2 | let AltOpenPDF: import('../lib').ZoteroAltOpenPDF
3 | }
4 |
--------------------------------------------------------------------------------
/lib/zotero.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace Zotero {
2 | version: string
3 | let AltOpenPDF: import('../lib').ZoteroAltOpenPDF
4 | }
5 |
--------------------------------------------------------------------------------
/lib/log.ts:
--------------------------------------------------------------------------------
1 | export function log(msg) {
2 | Zotero.debug(`AltOpen PDF: ${msg}`)
3 | }
4 |
5 | export function bootstrapLog(msg) {
6 | Zotero.debug(`AltOpen PDF: (bootstrap) ${msg}`)
7 | }
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | logs
2 | .eslintcache
3 | .DS_Store
4 | wiki
5 | gen
6 | build
7 | *~
8 | *.swp
9 | *.debug
10 | *.cache
11 | *.status
12 | *.js.map
13 | *.tmp
14 | *.xpi
15 | node_modules
16 | .env
17 |
--------------------------------------------------------------------------------
/zotero-plugin.ini:
--------------------------------------------------------------------------------
1 | [profile]
2 | name = BBTTEST
3 | path = ~/.BBTTEST
4 |
5 | [zotero]
6 | log = ~/.BBTTEST.log
7 | #path = /Applications/Zotero-beta.app/Contents/MacOS/zotero
8 | path = /Applications/Zotero.app/Contents/MacOS/zotero
9 | # db = zotero.sqlite
10 |
11 | [preferences]
12 | extensions.zotero.open-pdf.with.Preview = /usr/bin/open -a Preview @pdf
13 | extensions.zotero.open-pdf.with.Labeled = [Open with Skim v1]/usr/bin/open -a Skim @pdf
14 |
--------------------------------------------------------------------------------
/public.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN PUBLIC KEY-----
2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4MhpeqlaxBx3UnJ8Y631
3 | 6s9KZjI6o8PRirumWlCF5ZBdYjlj+i+drPX3sxeMl9kUrZHTo4/jZRIYtFh6y+0e
4 | S+5F+51oAyhjZnfGl6xdmvjdKHulwmLilzB26Fp2gCcHNUENyoyK3I7cM3Tb2LK/
5 | 09CqTe/FxYr07BW7x2gWyeAjucLheIet/GuFyrcjbuCuf9aXM/rZPdXc4+TzdFnc
6 | yqj2ExCRRzE7SoYH3XUt11cKWakqx60AasZDcg7CPxWRcpD6AB+bynA1d5zRF0cM
7 | yvqr8Tk3yHT8YZIsUygLXgo6ex4t1VV95oAEBVTfpLNV6ZFAJFIu7rs36zQiDYOg
8 | NwIDAQAB
9 | -----END PUBLIC KEY-----
10 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: release
2 |
3 | on:
4 | push:
5 |
6 | jobs:
7 | release:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v5
11 | - name: install node
12 | uses: actions/setup-node@v4
13 | with:
14 | node-version: 24.x
15 | - name: install node dependencies
16 | run: npm install
17 | - name: build
18 | run: npm run build
19 | - name: release
20 | run: npm run release
21 | env:
22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
23 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "importHelpers": true,
4 | "target": "es2017",
5 | "disableSizeLimit": true,
6 | "module": "commonjs",
7 | "noImplicitAny": false,
8 | "esModuleInterop": true,
9 | "removeComments": false,
10 | "preserveConstEnums": false,
11 | "sourceMap": false,
12 | "downlevelIteration": true,
13 | "skipLibCheck": true,
14 | "lib": [ "es2017", "dom", "dom.iterable", "webworker", "ES2021.WeakRef" ],
15 | "typeRoots": [ "node_modules", "./typings", "./node_modules/@types" ],
16 | "types": ["zotero-types"]
17 | },
18 | "include": [ "typings/*", "*.ts" ]
19 | }
20 |
--------------------------------------------------------------------------------
/dprint.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript": {
3 | "arrowFunction.useParentheses": "preferNone",
4 | "trailingCommas": "onlyMultiLine",
5 | "bracePosition": "sameLine",
6 | "nextControlFlowPosition": "nextLine",
7 | "useBraces": "maintain",
8 | "quoteProps": "asNeeded",
9 | "useTabs": false,
10 | "quoteStyle": "preferSingle",
11 | "semiColons": "asi",
12 | "lineWidth": 1024
13 | },
14 | "json": {
15 | },
16 | "markdown": {
17 | },
18 | "excludes": [
19 | "**/node_modules",
20 | "**/*-lock.json"
21 | ],
22 | "plugins": [
23 | "https://plugins.dprint.dev/typescript-0.93.0.wasm",
24 | "https://plugins.dprint.dev/json-0.19.3.wasm",
25 | "https://plugins.dprint.dev/markdown-0.17.8.wasm"
26 | ]
27 | }
28 |
--------------------------------------------------------------------------------
/monkey-patch.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-argument, @typescript-eslint/ban-types, prefer-rest-params, @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-unsafe-return */
2 |
3 | export type Trampoline = Function & { disabled?: boolean }
4 | const trampolines: Trampoline[] = []
5 |
6 | export function patch(object: any, method: string, patcher: (f: Function) => Function, mem?: Trampoline[]): void {
7 | if (typeof object[method] !== 'function') throw new Error(`monkey-patch: ${method} is not a function`)
8 |
9 | const orig = object[method]
10 | const patched = patcher(orig)
11 | object[method] = function trampoline() {
12 | return (trampoline as Trampoline).disabled ? orig.apply(this, arguments) : patched.apply(this, arguments)
13 | }
14 | trampolines.push(object[method])
15 | if (mem) mem.push(object[method])
16 | }
17 |
18 | export function unpatch(functions?: Trampoline[]) {
19 | for (const trampoline of (functions || trampolines)) {
20 | trampoline.disabled = true
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/icons/pdf.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/icons.mjs:
--------------------------------------------------------------------------------
1 | import { JSDOM } from 'jsdom'
2 | import fs from 'fs'
3 |
4 | for (const kind of ['pdf', 'snapshot', 'epub']) {
5 | const icons = {}
6 | for (const mode of ['light', 'dark']) {
7 | const svg = fs.readFileSync(`../better-bibtex/submodules/zotero/chrome/skin/default/zotero/item-type/28/${mode}/attachment-${kind}.svg`, 'utf-8')
8 |
9 | icons[mode] = {
10 | dom: new JSDOM(svg, { contentType: 'image/svg+xml' })
11 | }
12 | icons[mode].doc = icons[mode].dom.window.document
13 | icons[mode].root = icons[mode].doc.querySelector('svg')
14 |
15 | for (const e of [...icons[mode].root.children]) {
16 | if (e.nodeName !== 'defs') e.classList.add(`${mode}-theme`)
17 | }
18 | }
19 |
20 | const style = icons.light.doc.createElement('style')
21 | style.textContent = `
22 | .light-theme {
23 | display: block;
24 | }
25 |
26 | .dark-theme {
27 | display: none;
28 | }
29 |
30 | @media (prefers-color-scheme: dark) {
31 | .light-theme {
32 | display: none;
33 | }
34 |
35 | .dark-theme {
36 | display: block;
37 | }
38 | }
39 | `
40 |
41 | icons.light.root.prepend(style)
42 |
43 | const clipPath = {
44 | light: icons.light.doc.querySelector('clipPath'),
45 | dark: icons.dark.doc.querySelectorAll('[clip-path]'),
46 | }
47 |
48 | if (clipPath.light) {
49 | for (const e of [...clipPath.dark]) {
50 | e.setAttribute('clip-path', clipPath.light.getAttribute('id'))
51 | }
52 | }
53 |
54 | let pred = [...icons.light.root.children].filter(e => e.nodeName !== 'defs').reverse()[0]
55 | for (const e of [...icons.dark.root.children]) {
56 | if (e.nodeName !== 'defs') {
57 | pred.after(e)
58 | pred = e
59 | }
60 | }
61 |
62 | fs.writeFileSync(`icons/${kind}.svg`, icons.light.dom.serialize().replace(' xmlns=""', ''))
63 | }
64 |
65 |
--------------------------------------------------------------------------------
/bootstrap.ts:
--------------------------------------------------------------------------------
1 | declare const Zotero: any
2 | declare const Services: any
3 | declare const ChromeUtils: any
4 | declare const Components: any
5 | declare const dump: (msg: string) => void
6 | const {
7 | interfaces: Ci,
8 | results: Cr,
9 | utils: Cu,
10 | Constructor: CC,
11 | } = Components
12 |
13 | var stylesheetID = 'zotero-alt-open-pdf-stylesheet'
14 | var ftlID = 'zotero-alt-open-pdf-ftl'
15 | var menuitemID = 'make-it-green-instead'
16 | var addedElementIDs = [stylesheetID, ftlID, menuitemID]
17 |
18 | import { bootstrapLog as log } from './lib/log'
19 |
20 | export async function install(): Promise {
21 | }
22 |
23 | let chromeHandle
24 | export async function startup({ id, version, resourceURI, rootURI = resourceURI.spec }): Promise {
25 | log('startup')
26 |
27 | const aomStartup = Cc['@mozilla.org/addons/addon-manager-startup;1'].getService(Ci.amIAddonManagerStartup) as amIAddonManagerStartup
28 | const manifestURI = Services.io.newURI(`${rootURI}manifest.json`)
29 | log(manifestURI.spec)
30 | chromeHandle = aomStartup.registerChrome(manifestURI, [
31 | ['content', 'zotero-open-pdf', 'icons/'],
32 | ])
33 |
34 | // Add DOM elements to the main Zotero pane
35 | try {
36 | Services.scriptloader.loadSubScript(`${rootURI}lib.js`, { Zotero })
37 | await Zotero.AltOpenPDF.startup()
38 | log('started')
39 | }
40 | catch (err) {
41 | log(`startup error: ${err}`)
42 | }
43 | }
44 |
45 | export async function onMainWindowLoad({ window }) {
46 | await Zotero.AltOpenPDF.onMainWindowLoad({ window })
47 | }
48 |
49 | export async function onMainWindowUnload({ window }) {
50 | await Zotero.AltOpenPDF.onMainWindowUnload({ window })
51 | }
52 |
53 | export async function shutdown() {
54 | log('Shutting down')
55 |
56 | if (Zotero.AltOpenPDF) {
57 | try {
58 | await Zotero.AltOpenPDF.shutdown()
59 | }
60 | catch (err) {
61 | log(`shutdown error: ${err}`)
62 | }
63 | delete Zotero.AltOpenPDF
64 | }
65 | }
66 |
67 | export function uninstall() {
68 | }
69 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "zotero-open-pdf",
3 | "version": "1.0.5",
4 | "description": "Open PDF",
5 | "scripts": {
6 | "lint": "dprint fmt bootstrap.ts content/*.ts && dprint check bootstrap.ts content/*.ts",
7 | "prebuild": "rm -rf build && mkdir build && npm run lint",
8 | "build": "tsc --noEmit && node esbuild.js",
9 | "postbuild": "mkdir -p build/icons && cp icons/* build/icons && zp-zipup build zotero-open-pdf",
10 | "release": "zp-release",
11 | "postversion": "git add package.json package-lock.json && git commit -m version && git push --follow-tags",
12 | "postinstall": "patch-package",
13 | "prestart": "npm run build",
14 | "start": "zotero-start",
15 | "prebeta": "npm run build",
16 | "beta": "zotero-start --beta"
17 | },
18 | "repository": {
19 | "type": "git",
20 | "url": "https://github.com/retorquere/zotero-open-pdf.git"
21 | },
22 | "author": {
23 | "name": "Emiliano Heyns",
24 | "email": "emiliano.heyns@iris-advies.com"
25 | },
26 | "bugs": {
27 | "url": "https://github.com/retorquere/zotero-open-pdf/issues"
28 | },
29 | "homepage": "https://github.com/retorquere/zotero-open-pdf",
30 | "dependencies": {
31 | "dprint": "^0.50.1",
32 | "esbuild": "^0.25.9",
33 | "mkdirp": "^3.0.1",
34 | "npm-run-all": "^4.1.5",
35 | "rimraf": "^6.0.1",
36 | "shell-quote": "^1.8.3",
37 | "ts-node": "^10.9.2",
38 | "typescript": "^5.9.2",
39 | "zotero-plugin": "^6.3.3",
40 | "zotero-plugin-toolkit": "^5.1.0-beta.9",
41 | "zotero-types": "^4.0.5"
42 | },
43 | "xpi": {
44 | "name": "Open PDF for Zotero",
45 | "updateLink": "https://github.com/retorquere/zotero-open-pdf/releases/download/v{version}/zotero-open-pdf-{version}.xpi",
46 | "releaseURL": "https://github.com/retorquere/zotero-open-pdf/releases/download/release/",
47 | "bootstrapped": true,
48 | "minVersion": "7.0"
49 | },
50 | "devDependencies": {
51 | "jsdom": "^27.0.0",
52 | "patch-package": "^8.0.0",
53 | "typescript-eslint": "^8.43.0"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/.github/workflows/label-gun.yml:
--------------------------------------------------------------------------------
1 | jobs:
2 | nag:
3 | runs-on: ubuntu-latest
4 | steps:
5 | - id: labelgun
6 | uses: retorquere/label-gun@main
7 | with:
8 | assign: true
9 | close.message: Thanks for the feedback! Just so you know, GitHub doesn't let
10 | me control who can close issues, and @retorquere likes to leave bug reports
11 | and enhancements open as a nudge to merge them into the next release.
12 | label.awaiting: awaiting-user-feedback
13 | label.canclose: question
14 | label.reopened: reopened
15 | token: ${{ github.token }}
16 | verbose: true
17 | - if: ${{ steps.labelgun.outputs.status != '' }}
18 | uses: actions/add-to-project@v1.0.2
19 | with:
20 | github-token: ${{ secrets.PROJECT_TOKEN }}
21 | project-url: https://github.com/users/retorquere/projects/5
22 | - if: ${{ steps.labelgun.outputs.status != '' }}
23 | name: Set status
24 | uses: nipe0324/update-project-v2-item-field@v2.0.2
25 | with:
26 | field-name: Status
27 | field-value: ${{ steps.labelgun.outputs.status }}
28 | github-token: ${{ secrets.PROJECT_TOKEN }}
29 | project-url: https://github.com/users/retorquere/projects/5
30 | - if: ${{ steps.labelgun.outputs.status != '' && steps.labelgun.outputs.lastactive
31 | != '' }}
32 | name: Set end date
33 | uses: nipe0324/update-project-v2-item-field@v2.0.2
34 | with:
35 | field-name: End date
36 | field-value: ${{ steps.labelgun.outputs.lastactive }}
37 | github-token: ${{ secrets.PROJECT_TOKEN }}
38 | project-url: https://github.com/users/retorquere/projects/5
39 | - if: ${{ steps.labelgun.outputs.status != '' && steps.actor.outputs.users !=
40 | '' }}
41 | name: Set users
42 | uses: nipe0324/update-project-v2-item-field@v2.0.2
43 | with:
44 | field-name: Users
45 | field-value: ${{ steps.actor.outputs.users }}
46 | github-token: ${{ secrets.PROJECT_TOKEN }}
47 | project-url: https://github.com/users/retorquere/projects/5
48 | name: Manage issues and nag about debug logs
49 | on:
50 | issue_comment:
51 | types:
52 | - created
53 | - edited
54 | - closed
55 | issues:
56 | types:
57 | - opened
58 | - edited
59 | - closed
60 | - reopened
61 | workflow_dispatch: null
62 |
--------------------------------------------------------------------------------
/private.pem.json:
--------------------------------------------------------------------------------
1 | {
2 | "ciphertext": "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",
3 | "iv": "rZiNvU5BmgoONcGA",
4 | "tag": "HmrHDif2yni6TUBVq/4d0Q==",
5 | "salt": "CrJ7vas1YUEb4BNYDXuW6w==",
6 | "iterations": 100000,
7 | "keyLength": 32,
8 | "algorithm": "aes-256-gcm"
9 | }
--------------------------------------------------------------------------------
/esbuild.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const fs = require('fs')
3 | const esbuild = require('esbuild')
4 | const rmrf = require('rimraf')
5 | rmrf.sync('gen')
6 |
7 | require('zotero-plugin/copy-assets')
8 | require('zotero-plugin/manifest')
9 | require('zotero-plugin/version')
10 | const { pem } = require('zotero-plugin/esbuild')
11 |
12 | function js(src) {
13 | return src.replace(/[.]ts$/, '.js')
14 | }
15 |
16 | async function bundle(config) {
17 | config = {
18 | bundle: true,
19 | format: 'iife',
20 | target: ['firefox60'],
21 | inject: [],
22 | plugins: [pem],
23 | treeShaking: true,
24 | keepNames: true,
25 | loader: {
26 | '.png': 'dataurl',
27 | },
28 | ...config,
29 | }
30 |
31 | let target
32 | if (config.outfile) {
33 | target = config.outfile
34 | }
35 | else if (config.entryPoints.length === 1 && config.outdir) {
36 | target = path.join(config.outdir, js(path.basename(config.entryPoints[0])))
37 | }
38 | else {
39 | target = `${config.outdir} [${config.entryPoints.map(js).join(', ')}]`
40 | }
41 |
42 | const exportGlobals = config.exportGlobals
43 | delete config.exportGlobals
44 | if (exportGlobals) {
45 | const esm = await esbuild.build({ ...config, logLevel: 'silent', format: 'esm', metafile: true, write: false })
46 | if (Object.values(esm.metafile.outputs).length !== 1) throw new Error('exportGlobals not supported for multiple outputs')
47 |
48 | for (const output of Object.values(esm.metafile.outputs)) {
49 | if (output.entryPoint) {
50 | config.globalName = escape(`{ ${output.exports.sort().join(', ')} }`).replace(/%/g, '$')
51 | // make these var, not const, so they get hoisted and are available in the global scope.
52 | }
53 | }
54 | }
55 |
56 | console.log('* bundling', target)
57 | await esbuild.build(config)
58 | if (exportGlobals) {
59 | await fs.promises.writeFile(
60 | target,
61 | (await fs.promises.readFile(target, 'utf-8')).replace(config.globalName, unescape(config.globalName.replace(/[$]/g, '%')))
62 | )
63 | }
64 | }
65 |
66 | async function build() {
67 | await bundle({
68 | exportGlobals: true,
69 | entryPoints: [ 'bootstrap.ts' ],
70 | outdir: 'build',
71 | banner: { js: 'var Zotero;\n' },
72 | })
73 |
74 | await bundle({
75 | entryPoints: [ 'lib.ts' ],
76 | outdir: 'build',
77 | })
78 | }
79 |
80 | build().catch(err => {
81 | console.log(err)
82 | process.exit(1)
83 | })
84 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Open PDF
2 | =================
3 |
4 | Install by downloading the [latest version](https://github.com/retorquere/zotero-open-pdf/releases/latest). Compatible with Zotero 6 and 7.
5 |
6 | Zotero allows you to set a default for opening PDFs from Zotero:
7 |
8 | * with the system PDF viewer
9 | * with the internal PDF editor
10 |
11 | This plugin adds two things:
12 |
13 | * adds an option to the right-click menu of items to open PDFs with opposite of what you have configured in Zotero (so open with system PDF viewer if you have configured Zotero to open with the internal editor, and vice versa
14 | * allows you to add extra entries for your own PDF viewers/editor of choice
15 |
16 | To add your own, go into the Zotero preferences, tab Advanced, and open the config editor (You will be warned that you can break things. Don't break things). Then right-click and add a new String entry. The key must start with `extensions.zotero.alt-open.{pdf|snapshot|epub}.with.`, add any name you want after it, eg `extensions.zotero.alt-open.pdf.with.skim`, and as the value enter the command line needed to start the app, giving `@pdf` as a parameter where the filename must go. this could eg be
17 |
18 | `extensions.zotero.alt-open.pdf.with.preview` = `/usr/bin/open -a Preview @pdf`
19 |
20 | which would add an option `Open with preview` to the menu. You can set your own menu label by adding it before the path in brackets:
21 |
22 | `extensions.zotero.alt-open.pdf.with.skim` = `[Open with Skim]/usr/bin/open -a Skim @pdf`
23 |
24 | If the path to the executable contains spaces, you need to enclose it in quotes, eg to use edge on windows as a PDF viewer:
25 |
26 | `extensions.zotero.alt-open.pdf.with.edge` = `"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" @pdf`
27 |
28 | A more complex example is that you can also open PDF files through extensions in Chrome, such as the Acrobat extension:
29 |
30 | `extensions.zotero.alt-open.pdf.with.chrome-acrobat` = `"C:\Program Files\Google\Chrome\Application\chrome.exe" "chrome-extension://efaidnbmnnnibpcajpcglclefindmkaj/file:///@pdf"`
31 |
32 | "efaidnbmnnnibpcajpcglclefindmkaj" is the ID of the Acrobat extension. To enable the extension to read the file:/// protocol, you need to turn on "Allow access to file URLs" on the "Manage Extensions" page.
33 |
34 | **Adding new entries to the menu requires a restart of the plugin.**
35 |
36 | In some situations, some programs (such as sioyek) may behave like command-line programs, becoming subprocesses of zotero instead of independent processes when started, causing the program to behave unexpectedly.
37 |
38 | On Windows, you can use `start` command to launch it as an independent process.
39 |
40 | `extensions.zotero.alt-open.pdf.with.sioyek` = `"C:\Windows\System32\cmd.exe" /c start "" "C:\Users\user\scoop\apps\sioyek\2.0.0\sioyek.exe" --new-window @pdf`
41 |
42 | **Pay attention:**
43 |
44 | **The plugin does not search the system PATH, you need to enter the full path to the executable. When the path contains spaces or backslashes `\`, you need to wrap the path in quotes " ".**
45 |
46 |
47 | # Warning
48 |
49 | Modifying PDFs outside of Zotero (e.g., deleting, moving, or rotating pages) may result in inconsistencies with Zotero-created annotations!
50 |
51 | Rotating and deleting individual pages can be done safely from the thumbnails tab of the Zotero PDF reader sidebar. Please consult Zotero's support pages about [using an external PDF reader](https://www.zotero.org/support/kb/annotations_in_database).
52 |
53 | # Support - read carefully
54 |
55 | My time is extremely limited for a number of very great reasons (you shall have to trust me on this). Because of this, I
56 | cannot accept bug reports
57 | or support requests on anything but the latest version. If you submit an issue report,
58 | please include the version that you are on. By the time I get to your issue, the latest version might have bumped up
59 | already, and you
60 | will have to upgrade (you might have auto-upgraded already however) and re-verify that your issue still exists.
61 | Apologies for the inconvenience, but such
62 | are the breaks.
63 |
64 |
--------------------------------------------------------------------------------
/icons/epub.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lib.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-var, prefer-arrow/prefer-arrow-functions, @typescript-eslint/require-await */
2 |
3 | // declare const dump: (msg: string) => void
4 |
5 | import { MenuitemOptions, MenuManager } from 'zotero-plugin-toolkit'
6 | const Menu = new MenuManager()
7 |
8 | import { DebugLog } from 'zotero-plugin/debug-log'
9 | const pubkey: string = require('./public.pem')
10 | DebugLog.register('Open PDF', ['alt-open.', 'fileHandler.'], pubkey)
11 |
12 | import unshell from 'shell-quote/parse'
13 |
14 | import { log } from './lib/log'
15 |
16 | log('lib loading')
17 |
18 | const Kinds = ['PDF', 'Snapshot', 'ePub']
19 |
20 | type Opener = { label: string; cmdline: string }
21 | function getOpener(opener: string): Opener {
22 | if (!opener) return { label: '', cmdline: '' }
23 | const cmdline = Zotero.Prefs.get(opener, true) as string
24 | if (!cmdline) return { label: '', cmdline: '' }
25 | const m = cmdline.match(/^\[(.+?)\](.+)/)
26 | if (m) return { label: m[1], cmdline: m[2] }
27 | return { label: `Open with ${opener.replace(/^extensions\.zotero\.alt-open\.[a-z]+\.with\./, '')}`, cmdline }
28 | }
29 |
30 | function exec(exe: string, args: string[] = []): void {
31 | log(`running ${JSON.stringify([exe].concat(args))}`)
32 |
33 | const cmd = Zotero.File.pathToFile(exe)
34 | if (!cmd.exists()) {
35 | flash('opening PDF failed', `${exe} not found`)
36 | return
37 | }
38 | if (!cmd.isExecutable()) {
39 | flash('opening PDF failed', `${exe} is not runnable`)
40 | return
41 | }
42 |
43 | const proc = Components.classes['@mozilla.org/process/util;1'].createInstance(Components.interfaces.nsIProcess)
44 | proc.init(cmd)
45 | proc.startHidden = true
46 | proc.runw(false, args, args.length)
47 | }
48 |
49 | function flash(title: string, body?: string, timeout = 8): void {
50 | try {
51 | log(`flash: ${JSON.stringify({ title, body })}`)
52 | const pw = new Zotero.ProgressWindow()
53 | pw.changeHeadline(`open-pdf ${title}`)
54 | if (!body) body = title
55 | if (Array.isArray(body)) body = body.join('\n')
56 | pw.addDescription(body)
57 | pw.show()
58 | pw.startCloseTimer(timeout * 1000)
59 | }
60 | catch (err) {
61 | const msg = `${err}`
62 | log(`flash: ${JSON.stringify({ title, body, err: msg })}`)
63 | }
64 | }
65 |
66 | async function selectedAttachment(kind: string) {
67 | const items = Zotero.getActiveZoteroPane().getSelectedItems()
68 | if (items.length !== 1) return null
69 | const attachment = items[0].isAttachment() ? items[0] : await items[0].getBestAttachment()
70 | if (!attachment) return null
71 | if (!attachment.getFilePath()) return null
72 | switch (kind) {
73 | case 'pdf':
74 | if (!attachment.isPDFAttachment()) return null
75 | break
76 | case 'snapshot':
77 | if (!attachment.isSnapshotAttachment()) return null
78 | break
79 | case 'epub':
80 | if (!attachment.isEPUBAttachment()) return null
81 | break
82 | default:
83 | return null
84 | }
85 | return attachment
86 | }
87 |
88 | export class ZoteroAltOpenPDF {
89 | shutdown() {
90 | log('shutdown')
91 | Menu.unregisterAll()
92 | log('shutdown done')
93 | }
94 |
95 | public async startup() {
96 | for (const kind of Kinds.map(k => k.toLowerCase())) {
97 | const prefix = `extensions.zotero.open-${kind}.with.`
98 | for (const old of Zotero.Prefs.rootBranch.getChildList(prefix) as string[]) {
99 | const migrated = old.replace(prefix, `alt-open.${kind}.with.`)
100 | Zotero.Prefs.set(old.replace(prefix, `alt-open.${kind}.with.`), Zotero.Prefs.get(old, true))
101 | Zotero.Prefs.clear(old, true)
102 | }
103 | }
104 |
105 | log('startup')
106 | await this.onMainWindowLoad({ window: Zotero.getMainWindow() })
107 | log('startup done')
108 | }
109 |
110 | public async onMainWindowLoad({ window }) {
111 | log('onMainWindowLoad')
112 |
113 | for (const Kind of Kinds) {
114 | const kind = Kind.toLowerCase()
115 | if (window.document.getElementById(`alt-open-${kind}-internal`)) continue
116 |
117 | // filehandler: '' == internal, 'system' = system, other = custom
118 | const system: MenuitemOptions[] = [
119 | {
120 | tag: 'menuitem',
121 | id: `alt-open-${kind}-internal`,
122 | label: Zotero.getString('locate.internalViewer.label') as string,
123 | isHidden: async (elem, ev) => {
124 | return !(
125 | Zotero.Prefs.get(`fileHandler.${kind}`) // internal is not the default
126 | && await selectedAttachment(kind)
127 | )
128 | },
129 | commandListener: async () => {
130 | Zotero.Reader.open((await selectedAttachment(kind))!.id, undefined, { openInWindow: false })
131 | },
132 | },
133 | {
134 | tag: 'menuitem',
135 | id: `alt-open-${kind}-system`,
136 | label: Zotero.getString('locate.externalViewer.label') as string,
137 | isHidden: async (elem, ev) => {
138 | return !(
139 | Zotero.Prefs.get(`fileHandler.${kind}`) !== 'system' // system is not the default
140 | && await selectedAttachment(kind)
141 | )
142 | },
143 | commandListener: async () => {
144 | Zotero.launchFile((await selectedAttachment(kind))!.getFilePath() as string)
145 | },
146 | },
147 | ]
148 |
149 | const placeholder = new RegExp(`@${kind}`, 'i')
150 | const custom: MenuitemOptions[] = (Zotero.Prefs.rootBranch.getChildList(`extensions.zotero.alt-open.${kind}.with.`) as string[])
151 | .map(cmdline => getOpener(cmdline))
152 | .filter(opener => opener.label && opener.cmdline)
153 | .map(opener => ({
154 | tag: 'menuitem',
155 | label: opener.label,
156 | isHidden: async (elem, ev) => !(await selectedAttachment(kind)),
157 | commandListener: async ev => {
158 | const target = ev.target as HTMLSelectElement
159 | let args: string[] = unshell(opener.cmdline)
160 | const cmd = args.shift()
161 | const pdf = await selectedAttachment(kind)
162 | exec(cmd, args.map((arg: string) => arg.replace(placeholder, pdf.getFilePath() as string)))
163 | },
164 | }))
165 | log(`${kind} customs: ${JSON.stringify(custom.map(mi => mi.label))}`)
166 |
167 | if (custom.length) {
168 | const openers = [...system, ...custom]
169 | log(`chrome://zotero-open-pdf/content/${kind}.svg`)
170 | Menu.register('item', {
171 | tag: 'menu',
172 | label: `Open ${Kind}`,
173 | icon: `chrome://zotero-open-pdf/content/${kind}.svg`,
174 | isHidden: async (elem, ev) => {
175 | log(`menu activated: selected ${kind} = ${await selectedAttachment(kind)}`)
176 | if (!(await selectedAttachment(kind))) return true
177 | for (const opener of openers) {
178 | log(`${kind} submenu ${opener.label}: hidden = ${await opener.isHidden(null, null)}`)
179 | if (!(await opener.isHidden(null, null))) return false
180 | }
181 | return true
182 | },
183 | children: openers,
184 | })
185 | }
186 | else {
187 | for (const mi of system) {
188 | log(`chrome://zotero-open-pdf/content/${kind}.svg`)
189 | Menu.register('item', { ...mi, icon: `chrome://zotero-open-pdf/content/${kind}.svg` })
190 | }
191 | }
192 | }
193 | log('onMainWindowLoad done')
194 | }
195 |
196 | public async onMainWindowUnLoad() {
197 | log('onMainWindowUnload')
198 | Menu.unregisterAll()
199 | log('onMainWindowUnload done')
200 | }
201 | }
202 | Zotero.AltOpenPDF = Zotero.AltOpenPDF || new ZoteroAltOpenPDF()
203 | log('lib loaded')
204 |
--------------------------------------------------------------------------------
/icons/snapshot.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------