├── 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 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /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 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /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 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | --------------------------------------------------------------------------------