├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── README.md ├── chrome.manifest ├── content ├── debug.ts ├── patch-annot.ts ├── types.d.ts ├── zotero-obsidian-note.ts └── zotero-obsidian-note.xul ├── esbuild.js ├── locale └── en-US │ ├── zotero-obsidian-note.dtd │ └── zotero-obsidian-note.properties ├── package.json ├── skin └── default │ └── overlay.css ├── start.ini ├── start.py └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.d.ts 3 | generator-temp 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@aidenlx/eslint-config", 3 | "env": { 4 | "browser": true, 5 | "node": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | wiki 2 | gen 3 | build 4 | *~ 5 | *.swp 6 | *.debug 7 | *.cache 8 | *.status 9 | *.js.map 10 | *.tmp 11 | *.xpi 12 | node_modules 13 | .env 14 | .eslintcache 15 | 16 | package-lock.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Zotero Obsidian Note 2 | ================= 3 | 4 | Merged into [obsidian-zotero monorepo](https://github.com/aidenlx/obsidian-zotero/tree/master/app/zotero) 5 | -------------------------------------------------------------------------------- /chrome.manifest: -------------------------------------------------------------------------------- 1 | content zotero-obsidian-note content/ 2 | locale zotero-obsidian-note en-US locale/en-US/ 3 | skin zotero-obsidian-note default skin/ 4 | 5 | overlay chrome://zotero/content/zoteroPane.xul chrome://zotero-obsidian-note/content/zotero-obsidian-note.xul 6 | -------------------------------------------------------------------------------- /content/debug.ts: -------------------------------------------------------------------------------- 1 | const to_s = (obj: any): string => { 2 | if (typeof obj === "string") return obj; 3 | const s = `${obj}`; 4 | switch (s) { 5 | case "[object Object]": 6 | return JSON.stringify(obj, null, 2); 7 | case "[object Set]": 8 | return JSON.stringify(Array.from(obj, null, 2)); 9 | default: 10 | return s; 11 | } 12 | }; 13 | 14 | export const format = (...msg) => { 15 | return `ObsidianNote: ${msg.map(to_s).join(" ")}`; 16 | }; 17 | 18 | export const debug = (...msg): void => { 19 | Zotero.log(format(msg)); 20 | }; 21 | -------------------------------------------------------------------------------- /content/patch-annot.ts: -------------------------------------------------------------------------------- 1 | import { around } from "monkey-around"; 2 | 3 | type ReaderData = { 4 | [key: string]: any; 5 | enableEditHighlightedText: 6 | | false 7 | | { 8 | [key: string]: any; 9 | comment: string; 10 | libraryID: number; 11 | /** itemKEY not itemID */ 12 | id: string; 13 | tags: { name: string; [key: string]: any }[]; 14 | }; 15 | /** itemKEY not itemID */ 16 | currentID: string; 17 | /** itemKEY of all selected items */ 18 | ids: string[]; 19 | }; 20 | 21 | type MenuItemProps = { 22 | label: string; 23 | condition: (data: ReaderData, getAnnotations: () => any[]) => boolean; 24 | action: (data: ReaderData, getAnnotations: () => any[]) => any; 25 | }; 26 | 27 | /** 28 | * @returns patch unloader function 29 | */ 30 | const PatchReaderInstance = (...items: MenuItemProps[]) => { 31 | const addMenuItem = ( 32 | reader: any, 33 | data: ReaderData, 34 | getAnnotations: () => any[] 35 | ) => { 36 | for (const props of items) { 37 | const { label, condition, action } = props; 38 | if (condition(data, getAnnotations)) { 39 | Zotero.debug("Try to add menu item for annotation popup: " + label); 40 | const poppers = reader._popupset.childNodes; 41 | let popup = poppers[poppers.length - 1]; 42 | if (!popup) { 43 | Zotero.log( 44 | "No popup found while trying to add annotation popup:" + label 45 | ); 46 | return; 47 | } 48 | let menuitem = reader._window.document.createElement("menuitem"); 49 | menuitem.setAttribute("label", label); 50 | menuitem.setAttribute("disabled", false); 51 | menuitem.addEventListener("command", () => 52 | action(data, getAnnotations) 53 | ); 54 | popup.prepend(menuitem); 55 | } 56 | } 57 | }; 58 | 59 | let notifierID = null, 60 | revertPatch = null, 61 | notifier = { 62 | // eslint-disable-next-line prefer-arrow/prefer-arrow-functions 63 | notify(action, type, ids, extraData) { 64 | const readers = Zotero.Reader._readers; 65 | if (!(action === "add" && type === "tab" && readers?.length > 0)) 66 | return; 67 | revertPatch = around( 68 | readers[readers.length - 1].constructor.prototype, 69 | { 70 | _openAnnotationPopup: (next) => 71 | function (data: ReaderData) { 72 | const result = next.call(this, data); 73 | const attachment = Zotero.Items.get(this._itemID); 74 | let annots; 75 | addMenuItem(this, data, () => { 76 | if (!annots) 77 | annots = data.ids.map((key) => 78 | Zotero.Items.getByLibraryAndKey(attachment.libraryID, key) 79 | ); 80 | return annots; 81 | }); 82 | return result; 83 | }, 84 | } 85 | ); 86 | if (notifierID !== null) { 87 | Zotero.Notifier.unregisterObserver(notifierID); 88 | Zotero.log("annotation popup patched"); 89 | } 90 | }, 91 | }; 92 | notifierID = Zotero.Notifier.registerObserver( 93 | notifier, 94 | ["tab"], 95 | "annot-popup-patch" 96 | ); 97 | 98 | return () => { 99 | revertPatch && revertPatch(); 100 | notifierID !== null && Zotero.Notifier.unregisterObserver(notifierID); 101 | }; 102 | }; 103 | 104 | export default PatchReaderInstance; 105 | -------------------------------------------------------------------------------- /content/types.d.ts: -------------------------------------------------------------------------------- 1 | declare const Zotero: { 2 | [key: string]: any; 3 | Items: any; 4 | }; 5 | declare const ZoteroPane: any; 6 | declare const Components: any; 7 | -------------------------------------------------------------------------------- /content/zotero-obsidian-note.ts: -------------------------------------------------------------------------------- 1 | import assertNever from "assert-never"; 2 | import { encodeURI } from "js-base64"; 3 | import { uniqBy } from "lodash-es"; 4 | 5 | import { debug } from "./debug"; 6 | import PatchReaderInstance from "./patch-annot"; 7 | 8 | class ObsidianNote { 9 | // tslint:disable-line:variable-name 10 | private initialized = false; 11 | private globals: Record; 12 | private strings: { getString: (key: string) => string }; 13 | private _notifierID: any; 14 | private _unloadAnnotPatch: () => void; 15 | 16 | public getString(key: string) { 17 | try { 18 | return this.strings.getString(key); 19 | } catch (error) { 20 | return null; 21 | } 22 | } 23 | 24 | public async load(globals: Record) { 25 | Zotero.log("Loading ObsidianNote"); 26 | 27 | this.globals = globals; 28 | 29 | if (this.initialized) return; 30 | this.initialized = true; 31 | 32 | this.strings = globals.document.getElementById( 33 | "zotero-obsidian-note-strings" 34 | ); 35 | 36 | this._notifierID = Zotero.Notifier.registerObserver( 37 | this, 38 | ["item"], 39 | "obsidian" 40 | ); 41 | 42 | this._unloadAnnotPatch = PatchReaderInstance( 43 | { 44 | label: this.getString("pdfReader.openInObsidian"), 45 | condition: (data, getAnnotations) => { 46 | if (data.ids.length !== 1) return false; 47 | return getAnnotations()[0].hasTag("OB_NOTE"); 48 | }, 49 | /** 50 | * open notes of annotation exported to obsidian before 51 | */ 52 | action: (_data, getAnnotations) => { 53 | this.sendToObsidian("annotation", "open", getAnnotations()); 54 | }, 55 | }, 56 | { 57 | // only work if there is more than one annotation selected 58 | // not implemented yet 59 | label: this.getString("pdfReader.exportToObsidian"), 60 | condition: (_data, getAnnotations) => 61 | getAnnotations().some((annot) => !annot.hasTag("OB_NOTE")), 62 | /** 63 | * export annotation to obsidian 64 | */ 65 | action: (_data, getAnnotations) => { 66 | const items = getAnnotations().filter( 67 | (annot) => !annot.hasTag("OB_NOTE") 68 | ); 69 | if (this.sendToObsidian("annotation", "export", items)) 70 | setObNoteFlag(items); 71 | }, 72 | } 73 | ); 74 | } 75 | unload() { 76 | Zotero.Notifier.unregisterObserver(this._notifierID); 77 | this._unloadAnnotPatch(); 78 | } 79 | 80 | /** Event handler for Zotero.Notifier */ 81 | notify(action: any, type: any, ids: any, extraData: any) { 82 | // if (action === "add" && type === "item") { 83 | // for (const annotation of ids 84 | // .map((_id) => Zotero.Items.get(_id)) 85 | // .filter((item) => item.itemType === "annotation")) { 86 | // annotation.annotationComment = "Done"; 87 | // annotation.saveTx(); 88 | // } 89 | // } 90 | } 91 | 92 | handleSelectedItems() { 93 | const isVaild = (item) => 94 | item.isRegularItem() || // !note && !annotation && !attachment 95 | (item.isAttachment() && !!item?.parentItem?.isRegularItem()); 96 | const getInfoItem = (item) => 97 | item.isAttachment() ? item.parentItem : item; 98 | 99 | let items = ZoteroPane.getSelectedItems(); 100 | if (items.length < 1) return; 101 | let infoItem; 102 | if ( 103 | items.length === 1 && 104 | isVaild(items[0]) && 105 | (infoItem = getInfoItem(items[0])).hasTag("OB_NOTE") 106 | ) { 107 | this.sendToObsidian("info", "open", [infoItem]); 108 | } else { 109 | items = items.reduce((arr, item) => { 110 | if (isVaild(item)) { 111 | let infoItem = getInfoItem(item); 112 | if (!infoItem.hasTag("OB_NOTE")) arr.push(infoItem); 113 | } 114 | return arr; 115 | }, []); 116 | if (items.length === 0) return; 117 | items = uniqBy(items, "id"); 118 | if (this.sendToObsidian("info", "export", items)) setObNoteFlag(items); 119 | } 120 | } 121 | 122 | /** 123 | * @param items should all be Regular Items / Annotation Items 124 | * @returns if url is sent to obsidian successfully 125 | */ 126 | sendToObsidian( 127 | type: "info" | "annotation", 128 | action: "open" | "export", 129 | items: any[] 130 | ): boolean { 131 | if (items.length === 0) return false; 132 | if (action === "open" && items.length > 1) { 133 | Zotero.logError("passed multiple items with action `open`"); 134 | return false; 135 | } 136 | 137 | // js in zotero don't parse other protocols properly 138 | let url = new URL(`http://zotero`); 139 | url.pathname = action; 140 | url.searchParams.append("type", type); 141 | 142 | const infoItem = 143 | type === "annotation" ? items[0].parentItem?.parentItem : items[0]; 144 | if (action === "open") { 145 | if (!infoItem?.isRegularItem()) { 146 | Zotero.logError( 147 | "No item for article info found for annotation: " + 148 | JSON.stringify(infoItem, null, 2) 149 | ); 150 | return false; 151 | } 152 | 153 | const doi = infoItem.getField("DOI"); 154 | if (doi) { 155 | url.searchParams.append("doi", doi); 156 | } 157 | 158 | url.searchParams.append("info-key", infoItem.key); 159 | url.searchParams.append("library-id", infoItem.libraryID); 160 | if (type === "annotation") 161 | url.searchParams.append("annot-key", items[0].key); 162 | } else if (action === "export") { 163 | let data: SendData_AnnotExport | SendData_InfoExport; 164 | if (type === "annotation") { 165 | data = { 166 | info: infoItem, 167 | annotations: items.map((og) => { 168 | let copy = JSON.parse(JSON.stringify(og)); 169 | if (["image", "ink"].includes(og.annotationType)) 170 | copy.imageUrl = Zotero.Annotations.getCacheImagePath(og); 171 | return copy; 172 | }), 173 | }; 174 | } else if (type === "info") { 175 | data = { info: items }; 176 | } else { 177 | assertNever(type); 178 | } 179 | url.searchParams.append("data", encodeURI(JSON.stringify(data))); 180 | } else { 181 | assertNever(action); 182 | } 183 | 184 | // use this as a patch to fix the url 185 | url.protocol = "obsidian"; 186 | Zotero.launchURL(url.toString()); 187 | return true; 188 | } 189 | } 190 | 191 | type SendData_AnnotExport = { 192 | info: any; 193 | annotations: { 194 | [key: string]: any; 195 | /** check if file exists in obsidian */ 196 | imageUrl?: string; 197 | }; 198 | }; 199 | 200 | type SendData_InfoExport = { 201 | info: any[]; 202 | }; 203 | 204 | if (Zotero.ObsidianNote) Zotero.ObsidianNote.unload(); 205 | Zotero.ObsidianNote = new ObsidianNote(); 206 | 207 | // if (!Zotero.ObsidianNote) Zotero.ObsidianNote = new ObsidianNote(); 208 | 209 | const setObNoteFlag = (items: any[]) => 210 | items.forEach((item) => item.addTag("OB_NOTE")); 211 | -------------------------------------------------------------------------------- /content/zotero-obsidian-note.xul: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /esbuild.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | const esbuild = require('esbuild') 4 | 5 | require('zotero-plugin/copy-assets') 6 | require('zotero-plugin/rdf') 7 | require('zotero-plugin/version') 8 | 9 | async function build() { 10 | await esbuild.build({ 11 | bundle: true, 12 | format: 'iife', 13 | target: ['firefox60'], 14 | entryPoints: [ 'content/zotero-obsidian-note.ts' ], 15 | outdir: 'build/content', 16 | }) 17 | } 18 | 19 | build().catch(err => { 20 | console.log(err) 21 | process.exit(1) 22 | }) 23 | -------------------------------------------------------------------------------- /locale/en-US/zotero-obsidian-note.dtd: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /locale/en-US/zotero-obsidian-note.properties: -------------------------------------------------------------------------------- 1 | pdfReader.openInObsidian=Open In Obsidian 2 | pdfReader.exportToObsidian=Export Selected to Obsidian 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zotero-obsidian-note", 3 | "version": "0.0.1", 4 | "description": "Zotero plugin to integrate with Obsidian.md", 5 | "scripts": { 6 | "lint": "eslint . --ext .ts --cache --cache-location .eslintcache/", 7 | "prebuild": "npm run lint", 8 | "build": "tsc --noEmit && node esbuild.js", 9 | "postbuild": "zotero-plugin-zipup build zotero-obsidian-note", 10 | "release": "zotero-plugin-release", 11 | "postversion": "git push --follow-tags" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/aidenlx/zotero-obsidian-note.git" 16 | }, 17 | "author": { 18 | "name": "aidenlx", 19 | "email": "aiden.lx@outlook.com" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/aidenlx/zotero-obsidian-note/issues" 23 | }, 24 | "homepage": "https://github.com/aidenlx/zotero-obsidian-note", 25 | "dependencies": { 26 | "@types/mocha": "^9.0.0", 27 | "@typescript-eslint/eslint-plugin": "^5.9.0", 28 | "@typescript-eslint/parser": "^5.9.0", 29 | "esbuild": "^0.14.10", 30 | "eslint": "^8.6.0", 31 | "eslint-plugin-import": "^2.25.4", 32 | "eslint-plugin-jsdoc": "^37.5.1", 33 | "eslint-plugin-prefer-arrow": "^1.2.3", 34 | "mkdirp": "^1.0.4", 35 | "rimraf": "^3.0.2", 36 | "ts-node": "^10.4.0", 37 | "typescript": "^4.5.4", 38 | "zotero-plugin": "^1.0.62" 39 | }, 40 | "xpi": { 41 | "name": "Obsidian Note for Zotero", 42 | "updateLink": "https://github.com/aidenlx/zotero-obsidian-note/releases/download/v{version}/zotero-obsidian-note-{version}.xpi", 43 | "releaseURL": "https://github.com/aidenlx/zotero-obsidian-note/releases/download/release/" 44 | }, 45 | "devDependencies": { 46 | "@aidenlx/eslint-config": "^0.1.0", 47 | "@aidenlx/prettier-config": "^0.1.0", 48 | "@aidenlx/ts-config": "^0.1.1", 49 | "@types/lodash-es": "^4.17.6", 50 | "assert-never": "^1.2.1", 51 | "cz-conventional-changelog": "^3.3.0", 52 | "js-base64": "^3.7.2", 53 | "lodash-es": "^4.17.21", 54 | "monkey-around": "^2.3.0" 55 | }, 56 | "config": { 57 | "commitizen": { 58 | "path": "./node_modules/cz-conventional-changelog" 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /skin/default/overlay.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aidenlx/zotero-obsidian-note/18c40fd95b46f32be5ec021731388d8f969d843d/skin/default/overlay.css -------------------------------------------------------------------------------- /start.ini: -------------------------------------------------------------------------------- 1 | [profile] 2 | name = ZoteroDEBUG 3 | path = ~/Library/Application Support/Zotero/Profiles/0mfu0e9q.ZoteroDEBUG 4 | 5 | [log] 6 | path = ~/.ZoteroDEBUG.log 7 | 8 | [plugins] 9 | path = 10 | build 11 | -------------------------------------------------------------------------------- /start.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os, sys 4 | import shutil 5 | import json 6 | import configparser 7 | import argparse 8 | import shlex 9 | import xml.etree.ElementTree as ET 10 | import subprocess 11 | import collections 12 | 13 | config = configparser.ConfigParser(strict = False) 14 | if os.path.isfile('start.ini'): 15 | config.read('start.ini') 16 | if 'plugins' in config and 'path' in config['plugins']: 17 | plugins = config['plugins']['path'].strip().split('\n') 18 | else: 19 | plugins = ['build'] 20 | 21 | argp = argparse.ArgumentParser() 22 | argp.add_argument('--profile-path', dest='profile_path', default=config.get('profile', 'path', fallback=None)) 23 | argp.add_argument('--profile-name', dest='profile_name', default=config.get('profile', 'name', fallback=None)) 24 | argp.add_argument('--plugin', dest='plugin', nargs='+', default=plugins) 25 | argp.add_argument('--log', default=config.get('log', 'path', fallback=None)) 26 | args = argp.parse_args() 27 | 28 | if not args.profile_path: 29 | print(args.usage()) 30 | sys.exit(1) 31 | 32 | args.profile_path = os.path.expanduser(args.profile_path) 33 | if args.log: 34 | args.log = os.path.expanduser(args.log) 35 | 36 | def system(cmd): 37 | print('$', cmd) 38 | subprocess.run(cmd, shell=True, check=True) 39 | 40 | settings = { 41 | 'extensions.autoDisableScopes': 0, 42 | 'extensions.enableScopes': 15, 43 | 'extensions.startupScanScopes': 15, 44 | 'extensions.lastAppBuildId': None, 45 | 'extensions.lastAppVersion': None, 46 | 'extensions.zotero.debug.log': True, 47 | } 48 | for prefs in ['user', 'prefs']: 49 | prefs = os.path.join(args.profile_path, f'{prefs}.js') 50 | if not os.path.exists(prefs): continue 51 | 52 | user_prefs = [] 53 | with open(prefs) as f: 54 | for line in f.readlines(): 55 | #print(line, [pref for pref in settings.keys() if pref in line]) 56 | if len([True for pref in settings.keys() if pref in line]) == 0: 57 | user_prefs.append(line) 58 | for key, value in settings.items(): 59 | if value is not None: 60 | user_prefs.append(f'user_pref({json.dumps(key)}, {json.dumps(value)});\n') 61 | 62 | with open(prefs, 'w') as f: 63 | f.write(''.join(user_prefs)) 64 | 65 | system('npm run build') 66 | 67 | #system(f'rm -rf {profile}extensions.json') 68 | 69 | for plugin in args.plugin: 70 | rdf = ET.parse(os.path.join(plugin, 'install.rdf')).getroot() 71 | for plugin_id in rdf.findall('{http://www.w3.org/1999/02/22-rdf-syntax-ns#}Description/{http://www.mozilla.org/2004/em-rdf#}id'): 72 | plugin_path = os.path.join(args.profile_path, 'extensions', plugin_id.text) 73 | 74 | system(f"rm -rf {shlex.quote(os.path.join(plugin, '*'))}") 75 | 76 | with open(plugin_path, 'w') as f: 77 | path = os.path.join(os.getcwd(), plugin) 78 | if path[-1] != '/': path += '/' 79 | print(path, file=f) 80 | 81 | cmd = '/Applications/Zotero.app/Contents/MacOS/zotero -purgecaches -P' 82 | if args.profile_name: cmd += ' ' + shlex.quote(args.profile_name) 83 | cmd += ' -jsconsole -ZoteroDebugText -datadir profile' 84 | if args.log: cmd += ' > ' + shlex.quote(args.log) 85 | cmd += ' &' 86 | 87 | system(cmd) 88 | -------------------------------------------------------------------------------- /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 | "lib": ["es2017", "dom"], 14 | "typeRoots": ["./typings", "./node_modules/@types"] 15 | }, 16 | "include": ["content/**/*", "resource/**/*", "*.ts"], 17 | "exclude": ["node_modules", "**/*.spec.ts", "typings"] 18 | } 19 | --------------------------------------------------------------------------------