UNPKG

zotero-plugin-toolkit

Version:
1,697 lines (1,687 loc) 131 kB
import { __export } from "./chunk-Cl8Af3a2.js"; //#region package.json var version = "5.1.0-beta.9"; //#endregion //#region src/utils/debugBridge.ts /** * Debug bridge. * * @deprecated Since this is a temporary solution for debugging, it is not recommended to use. * The zotero-plugin-scaffold no longer need this. * * @remarks * Global variables: Zotero, window(main window). * * @example * Run script directly. The `run` is URIencoded script. * * `zotero://ztoolkit-debug/?run=Zotero.getMainWindow().alert(%22HelloWorld!%22)&app=developer` * @example * Run script from file. The `file` is URIencoded path to js file starts with `file:///` * * `zotero://ztoolkit-debug/?file=file%3A%2F%2F%2FC%3A%2FUsers%2Fw_xia%2FDesktop%2Frun.js&app=developer` */ var DebugBridge = class DebugBridge { static version = 2; static passwordPref = "extensions.zotero.debug-bridge.password"; get version() { return DebugBridge.version; } _disableDebugBridgePassword; get disableDebugBridgePassword() { return this._disableDebugBridgePassword; } set disableDebugBridgePassword(value) { this._disableDebugBridgePassword = value; } get password() { return BasicTool.getZotero().Prefs.get(DebugBridge.passwordPref, true); } set password(v) { BasicTool.getZotero().Prefs.set(DebugBridge.passwordPref, v, true); } constructor() { this._disableDebugBridgePassword = false; this.initializeDebugBridge(); } static setModule(instance) { if (!instance.debugBridge?.version || instance.debugBridge.version < DebugBridge.version) instance.debugBridge = new DebugBridge(); } initializeDebugBridge() { const debugBridgeExtension = { noContent: true, doAction: async (uri) => { const Zotero$1 = BasicTool.getZotero(); const window$1 = Zotero$1.getMainWindow(); const uriString = uri.spec.split("//").pop(); if (!uriString) return; const params = {}; uriString.split("?").pop()?.split("&").forEach((p) => { params[p.split("=")[0]] = decodeURIComponent(p.split("=")[1]); }); const skipPasswordCheck = toolkitGlobal_default.getInstance()?.debugBridge.disableDebugBridgePassword; let allowed = false; if (skipPasswordCheck) allowed = true; else if (typeof params.password === "undefined" && typeof this.password === "undefined") allowed = window$1.confirm(`External App ${params.app} wants to execute command without password.\nCommand:\n${(params.run || params.file || "").slice(0, 100)}\nIf you do not know what it is, please click Cancel to deny.`); else allowed = this.password === params.password; if (allowed) { if (params.run) try { const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor; const f = new AsyncFunction("Zotero,window", params.run); await f(Zotero$1, window$1); } catch (e) { Zotero$1.debug(e); window$1.console.log(e); } if (params.file) try { Services.scriptloader.loadSubScript(params.file, { Zotero: Zotero$1, window: window$1 }); } catch (e) { Zotero$1.debug(e); window$1.console.log(e); } } }, newChannel(uri) { this.doAction(uri); } }; Services.io.getProtocolHandler("zotero").wrappedJSObject._extensions["zotero://ztoolkit-debug"] = debugBridgeExtension; } }; //#endregion //#region src/utils/pluginBridge.ts /** * Plugin bridge. Install plugin from zotero://plugin * * @deprecated Since this is a temporary solution for debugging, it is not recommended to use. * The zotero-plugin-scaffold no longer need this. * * @example * Install plugin from url, with minimal Zotero version requirement. * ```text * zotero://plugin/?action=install&url=https%3A%2F%2Fgithub.com%2FMuiseDestiny%2Fzotero-style%2Freleases%2Fdownload%2F3.0.5%2Fzotero-style.xpi&minVersion=6.999 * ``` */ var PluginBridge = class PluginBridge { static version = 1; get version() { return PluginBridge.version; } constructor() { this.initializePluginBridge(); } static setModule(instance) { if (!instance.pluginBridge?.version || instance.pluginBridge.version < PluginBridge.version) instance.pluginBridge = new PluginBridge(); } initializePluginBridge() { const { AddonManager } = _importESModule("resource://gre/modules/AddonManager.sys.mjs"); const Zotero$1 = BasicTool.getZotero(); const pluginBridgeExtension = { noContent: true, doAction: async (uri) => { try { const uriString = uri.spec.split("//").pop(); if (!uriString) return; const params = {}; uriString.split("?").pop()?.split("&").forEach((p) => { params[p.split("=")[0]] = decodeURIComponent(p.split("=")[1]); }); if (params.action === "install" && params.url) { if (params.minVersion && Services.vc.compare(Zotero$1.version, params.minVersion) < 0 || params.maxVersion && Services.vc.compare(Zotero$1.version, params.maxVersion) > 0) throw new Error(`Plugin is not compatible with Zotero version ${Zotero$1.version}.The plugin requires Zotero version between ${params.minVersion} and ${params.maxVersion}.`); const addon = await AddonManager.getInstallForURL(params.url); if (addon && addon.state === AddonManager.STATE_AVAILABLE) { addon.install(); hint("Plugin installed successfully.", true); } else throw new Error(`Plugin ${params.url} is not available.`); } } catch (e) { Zotero$1.logError(e); hint(e.message, false); } }, newChannel(uri) { this.doAction(uri); } }; Services.io.getProtocolHandler("zotero").wrappedJSObject._extensions["zotero://plugin"] = pluginBridgeExtension; } }; function hint(content, success) { const progressWindow = new Zotero.ProgressWindow({ closeOnClick: true }); progressWindow.changeHeadline("Plugin Toolkit"); progressWindow.progress = new progressWindow.ItemProgress(success ? "chrome://zotero/skin/tick.png" : "chrome://zotero/skin/cross.png", content); progressWindow.progress.setProgress(100); progressWindow.show(); progressWindow.startCloseTimer(5e3); } //#endregion //#region src/managers/toolkitGlobal.ts /** * The Singleton class of global parameters used by managers. * @example `ToolkitGlobal.getInstance().itemTree.state` */ var ToolkitGlobal = class ToolkitGlobal { debugBridge; pluginBridge; prompt; currentWindow; constructor() { initializeModules(this); this.currentWindow = BasicTool.getZotero().getMainWindow(); } /** * Get the global unique instance of `class ToolkitGlobal`. * @returns An instance of `ToolkitGlobal`. */ static getInstance() { let _Zotero; try { if (typeof Zotero !== "undefined") _Zotero = Zotero; else _Zotero = BasicTool.getZotero(); } catch {} if (!_Zotero) return void 0; let requireInit = false; if (!("_toolkitGlobal" in _Zotero)) { _Zotero._toolkitGlobal = new ToolkitGlobal(); requireInit = true; } const currentGlobal = _Zotero._toolkitGlobal; if (currentGlobal.currentWindow !== _Zotero.getMainWindow()) { checkWindowDependentModules(currentGlobal); requireInit = true; } if (requireInit) initializeModules(currentGlobal); return currentGlobal; } }; /** * Initialize global modules using the data of this toolkit build. * Modules and their properties that do not exist will be updated. * @param instance ToolkitGlobal instance */ function initializeModules(instance) { new BasicTool().log("Initializing ToolkitGlobal modules"); setModule(instance, "prompt", { _ready: false, instance: void 0 }); DebugBridge.setModule(instance); PluginBridge.setModule(instance); } function setModule(instance, key, module) { if (!module) return; if (!instance[key]) instance[key] = module; for (const moduleKey in module) instance[key][moduleKey] ??= module[moduleKey]; } function checkWindowDependentModules(instance) { instance.currentWindow = BasicTool.getZotero().getMainWindow(); instance.prompt = void 0; } var toolkitGlobal_default = ToolkitGlobal; //#endregion //#region src/basic.ts /** * Basic APIs with Zotero 6 & newer (7) compatibility. * See also https://www.zotero.org/support/dev/zotero_7_for_developers */ var BasicTool = class BasicTool { /** * configurations. */ _basicOptions; _console; /** * @deprecated Use `patcherManager` instead. */ patchSign = "zotero-plugin-toolkit@3.0.0"; static _version = version; /** * Get version - checks subclass first, then falls back to parent */ get _version() { return version; } get basicOptions() { return this._basicOptions; } /** * * @param data Pass an BasicTool instance to copy its options. */ constructor(data) { this._basicOptions = { log: { _type: "toolkitlog", disableConsole: false, disableZLog: false, prefix: "" }, get debug() { if (this._debug) return this._debug; this._debug = toolkitGlobal_default.getInstance()?.debugBridge || { disableDebugBridgePassword: false, password: "" }; return this._debug; }, api: { pluginID: "zotero-plugin-toolkit@windingwind.com" }, listeners: { callbacks: { onMainWindowLoad: /* @__PURE__ */ new Set(), onMainWindowUnload: /* @__PURE__ */ new Set(), onPluginUnload: /* @__PURE__ */ new Set() }, _mainWindow: void 0, _plugin: void 0 } }; try { if (typeof globalThis.ChromeUtils?.importESModule !== "undefined" || typeof globalThis.ChromeUtils?.import !== "undefined") { const { ConsoleAPI } = _importESModule("resource://gre/modules/Console.sys.mjs"); this._console = new ConsoleAPI({ consoleID: `${this._basicOptions.api.pluginID}-${Date.now()}` }); } } catch {} this.updateOptions(data); } getGlobal(k) { if (typeof globalThis[k] !== "undefined") return globalThis[k]; const _Zotero = BasicTool.getZotero(); try { const window$1 = _Zotero.getMainWindow(); switch (k) { case "Zotero": case "zotero": return _Zotero; case "window": return window$1; case "windows": return _Zotero.getMainWindows(); case "document": return window$1.document; case "ZoteroPane": case "ZoteroPane_Local": return _Zotero.getActiveZoteroPane(); default: return window$1[k]; } } catch (e) { Zotero.logError(e); } } /** * If it's an XUL element * @param elem */ isXULElement(elem) { return elem.namespaceURI === "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; } /** * Create an XUL element * * For Zotero 6, use `createElementNS`; * * For Zotero 7+, use `createXULElement`. * @param doc * @param type * @example * Create a `<menuitem>`: * ```ts * const compat = new ZoteroCompat(); * const doc = compat.getWindow().document; * const elem = compat.createXULElement(doc, "menuitem"); * ``` */ createXULElement(doc, type) { return doc.createXULElement(type); } /** * Output to both Zotero.debug and console.log * @param data e.g. string, number, object, ... */ log(...data) { if (data.length === 0) return; let _Zotero; try { if (typeof Zotero !== "undefined") _Zotero = Zotero; else _Zotero = BasicTool.getZotero(); } catch {} let options; if (data[data.length - 1]?._type === "toolkitlog") options = data.pop(); else options = this._basicOptions.log; try { if (options.prefix) data.splice(0, 0, options.prefix); if (!options.disableConsole) { let _console; if (typeof console !== "undefined") _console = console; else if (_Zotero) _console = _Zotero.getMainWindow()?.console; if (!_console) { if (!this._console) return; _console = this._console; } if (_console.groupCollapsed) _console.groupCollapsed(...data); else _console.group(...data); _console.trace(); _console.groupEnd(); } if (!options.disableZLog) { if (typeof _Zotero === "undefined") return; _Zotero.debug(data.map((d) => { try { return typeof d === "object" ? JSON.stringify(d) : String(d); } catch { _Zotero.debug(d); return ""; } }).join("\n")); } } catch (e) { if (_Zotero) Zotero.logError(e); else console.error(e); } } /** * Patch a function * @deprecated Use {@link PatchHelper} instead. * @param object The owner of the function * @param funcSign The signature of the function(function name) * @param ownerSign The signature of patch owner to avoid patching again * @param patcher The new wrapper of the patched function */ patch(object, funcSign, ownerSign, patcher) { if (object[funcSign][ownerSign]) throw new Error(`${String(funcSign)} re-patched`); this.log("patching", funcSign, `by ${ownerSign}`); object[funcSign] = patcher(object[funcSign]); object[funcSign][ownerSign] = true; } /** * Add a Zotero event listener callback * @param type Event type * @param callback Event callback */ addListenerCallback(type, callback) { if (["onMainWindowLoad", "onMainWindowUnload"].includes(type)) this._ensureMainWindowListener(); if (type === "onPluginUnload") this._ensurePluginListener(); this._basicOptions.listeners.callbacks[type].add(callback); } /** * Remove a Zotero event listener callback * @param type Event type * @param callback Event callback */ removeListenerCallback(type, callback) { this._basicOptions.listeners.callbacks[type].delete(callback); this._ensureRemoveListener(); } /** * Remove all Zotero event listener callbacks when the last callback is removed. */ _ensureRemoveListener() { const { listeners } = this._basicOptions; if (listeners._mainWindow && listeners.callbacks.onMainWindowLoad.size === 0 && listeners.callbacks.onMainWindowUnload.size === 0) { Services.wm.removeListener(listeners._mainWindow); delete listeners._mainWindow; } if (listeners._plugin && listeners.callbacks.onPluginUnload.size === 0) { Zotero.Plugins.removeObserver(listeners._plugin); delete listeners._plugin; } } /** * Ensure the main window listener is registered. */ _ensureMainWindowListener() { if (this._basicOptions.listeners._mainWindow) return; const mainWindowListener = { onOpenWindow: (xulWindow) => { const domWindow = xulWindow.docShell.domWindow; const onload = async () => { domWindow.removeEventListener("load", onload, false); if (domWindow.location.href !== "chrome://zotero/content/zoteroPane.xhtml") return; for (const cbk of this._basicOptions.listeners.callbacks.onMainWindowLoad) try { cbk(domWindow); } catch (e) { this.log(e); } }; domWindow.addEventListener("load", () => onload(), false); }, onCloseWindow: async (xulWindow) => { const domWindow = xulWindow.docShell.domWindow; if (domWindow.location.href !== "chrome://zotero/content/zoteroPane.xhtml") return; for (const cbk of this._basicOptions.listeners.callbacks.onMainWindowUnload) try { cbk(domWindow); } catch (e) { this.log(e); } } }; this._basicOptions.listeners._mainWindow = mainWindowListener; Services.wm.addListener(mainWindowListener); } /** * Ensure the plugin listener is registered. */ _ensurePluginListener() { if (this._basicOptions.listeners._plugin) return; const pluginListener = { shutdown: (...args) => { for (const cbk of this._basicOptions.listeners.callbacks.onPluginUnload) try { cbk(...args); } catch (e) { this.log(e); } } }; this._basicOptions.listeners._plugin = pluginListener; Zotero.Plugins.addObserver(pluginListener); } updateOptions(source) { if (!source) return this; if (source instanceof BasicTool) this._basicOptions = source._basicOptions; else this._basicOptions = source; return this; } static getZotero() { if (typeof Zotero !== "undefined") return Zotero; const { Zotero: _Zotero } = ChromeUtils.importESModule("chrome://zotero/content/zotero.mjs"); return _Zotero; } }; var ManagerTool = class extends BasicTool { _ensureAutoUnregisterAll() { this.addListenerCallback("onPluginUnload", (params, _reason) => { if (params.id !== this.basicOptions.api.pluginID) return; this.unregisterAll(); }); } }; function unregister(tools) { Object.values(tools).forEach((tool) => { if (tool instanceof ManagerTool || typeof tool?.unregisterAll === "function") tool.unregisterAll(); }); } function makeHelperTool(cls, options) { return new Proxy(cls, { construct(target, args) { const _origin = new cls(...args); if (_origin instanceof BasicTool) _origin.updateOptions(options); else _origin._version = BasicTool._version; return _origin; } }); } function _importESModule(path) { if (typeof ChromeUtils.import === "undefined") return ChromeUtils.importESModule(path, { global: "contextual" }); if (path.endsWith(".sys.mjs")) path = path.replace(/\.sys\.mjs$/, ".jsm"); return ChromeUtils.import(path); } //#endregion //#region src/helpers/clipboard.ts /** * Copy helper for text/richtext/image. * * @example * Copy plain text * ```ts * new ClipboardHelper().addText("plain", "text/unicode").copy(); * ``` * @example * Copy plain text & rich text * ```ts * new ClipboardHelper().addText("plain", "text/unicode") * .addText("<h1>rich text</h1>", "text/html") * .copy(); * ``` * @example * Copy plain text, rich text & image * ```ts * new ClipboardHelper().addText("plain", "text/unicode") * .addText("<h1>rich text</h1>", "text/html") * .addImage("data:image/png;base64,...") * .copy(); * ``` */ var ClipboardHelper = class extends BasicTool { transferable; clipboardService; filePath = ""; constructor() { super(); this.transferable = Components.classes["@mozilla.org/widget/transferable;1"].createInstance(Components.interfaces.nsITransferable); this.clipboardService = Components.classes["@mozilla.org/widget/clipboard;1"].getService(Components.interfaces.nsIClipboard); this.transferable.init(null); } addText(source, type = "text/plain") { const str = Components.classes["@mozilla.org/supports-string;1"].createInstance(Components.interfaces.nsISupportsString); str.data = source; if (type === "text/unicode") type = "text/plain"; this.transferable.addDataFlavor(type); this.transferable.setTransferData(type, str, source.length * 2); return this; } addImage(source) { const parts = source.split(","); if (!parts[0].includes("base64")) return this; const mime = parts[0].match(/:(.*?);/)[1]; const bstr = this.getGlobal("window").atob(parts[1]); let n = bstr.length; const u8arr = new Uint8Array(n); while (n--) u8arr[n] = bstr.charCodeAt(n); const imgTools = Components.classes["@mozilla.org/image/tools;1"].getService(Components.interfaces.imgITools); let mimeType; let img; if (this.getGlobal("Zotero").platformMajorVersion >= 102) { img = imgTools.decodeImageFromArrayBuffer(u8arr.buffer, mime); mimeType = "application/x-moz-nativeimage"; } else { mimeType = `image/png`; img = Components.classes["@mozilla.org/supports-interface-pointer;1"].createInstance(Components.interfaces.nsISupportsInterfacePointer); img.data = imgTools.decodeImageFromArrayBuffer(u8arr.buffer, mimeType); } this.transferable.addDataFlavor(mimeType); this.transferable.setTransferData(mimeType, img, 0); return this; } addFile(path) { const file = Components.classes["@mozilla.org/file/local;1"].createInstance(Components.interfaces.nsIFile); file.initWithPath(path); this.transferable.addDataFlavor("application/x-moz-file"); this.transferable.setTransferData("application/x-moz-file", file); this.filePath = path; return this; } copy() { try { this.clipboardService.setData(this.transferable, null, Components.interfaces.nsIClipboard.kGlobalClipboard); } catch (e) { if (this.filePath && Zotero.isMac) Zotero.Utilities.Internal.exec(`/usr/bin/osascript`, [`-e`, `set the clipboard to POSIX file "${this.filePath}"`]); else throw e; } return this; } }; //#endregion //#region src/tools/ui.ts /** * UI APIs. Create elements and manage them. */ var UITool = class extends BasicTool { get basicOptions() { return this._basicOptions; } /** * Store elements created with this instance * * @remarks * > What is this for? * * In bootstrap plugins, elements must be manually maintained and removed on exiting. * * This API does this for you. */ elementCache; constructor(base) { super(base); this.elementCache = []; if (!this._basicOptions.ui) this._basicOptions.ui = { enableElementRecord: true, enableElementJSONLog: false, enableElementDOMLog: true }; } /** * Remove all elements created by `createElement`. * * @remarks * > What is this for? * * In bootstrap plugins, elements must be manually maintained and removed on exiting. * * This API does this for you. */ unregisterAll() { this.elementCache.forEach((e) => { try { e?.deref()?.remove(); } catch (e$1) { this.log(e$1); } }); } createElement(...args) { const doc = args[0]; const tagName = args[1].toLowerCase(); let props = args[2] || {}; if (!tagName) return; if (typeof args[2] === "string") props = { namespace: args[2], enableElementRecord: args[3] }; if (typeof props.enableElementJSONLog !== "undefined" && props.enableElementJSONLog || this.basicOptions.ui.enableElementJSONLog) this.log(props); props.properties = props.properties || props.directAttributes; props.children = props.children || props.subElementOptions; let elem; if (tagName === "fragment") { const fragElem = doc.createDocumentFragment(); elem = fragElem; } else { let realElem = props.id && (props.checkExistenceParent ? props.checkExistenceParent : doc).querySelector(`#${props.id}`); if (realElem && props.ignoreIfExists) return realElem; if (realElem && props.removeIfExists) { realElem.remove(); realElem = void 0; } if (props.customCheck && !props.customCheck(doc, props)) return void 0; if (!realElem || !props.skipIfExists) { let namespace = props.namespace; if (!namespace) { const mightHTML = HTMLElementTagNames.includes(tagName); const mightXUL = XULElementTagNames.includes(tagName); const mightSVG = SVGElementTagNames.includes(tagName); if (Number(mightHTML) + Number(mightXUL) + Number(mightSVG) > 1) this.log(`[Warning] Creating element ${tagName} with no namespace specified. Found multiply namespace matches.`); if (mightHTML) namespace = "html"; else if (mightXUL) namespace = "xul"; else if (mightSVG) namespace = "svg"; else namespace = "html"; } if (namespace === "xul") realElem = this.createXULElement(doc, tagName); else realElem = doc.createElementNS({ html: "http://www.w3.org/1999/xhtml", svg: "http://www.w3.org/2000/svg" }[namespace], tagName); if (typeof props.enableElementRecord !== "undefined" ? props.enableElementRecord : this.basicOptions.ui.enableElementRecord) this.elementCache.push(new WeakRef(realElem)); } if (props.id) realElem.id = props.id; if (props.styles && Object.keys(props.styles).length) Object.keys(props.styles).forEach((k) => { const v = props.styles[k]; typeof v !== "undefined" && (realElem.style[k] = v); }); if (props.properties && Object.keys(props.properties).length) Object.keys(props.properties).forEach((k) => { const v = props.properties[k]; typeof v !== "undefined" && (realElem[k] = v); }); if (props.attributes && Object.keys(props.attributes).length) Object.keys(props.attributes).forEach((k) => { const v = props.attributes[k]; typeof v !== "undefined" && realElem.setAttribute(k, String(v)); }); if (props.classList?.length) realElem.classList.add(...props.classList); if (props.listeners?.length) props.listeners.forEach(({ type, listener, options }) => { listener && realElem.addEventListener(type, listener, options); }); elem = realElem; } if (props.children?.length) { const subElements = props.children.map((childProps) => { childProps.namespace = childProps.namespace || props.namespace; return this.createElement(doc, childProps.tag, childProps); }).filter((e) => e); elem.append(...subElements); } if (typeof props.enableElementDOMLog !== "undefined" ? props.enableElementDOMLog : this.basicOptions.ui.enableElementDOMLog) this.log(elem); return elem; } /** * Append element(s) to a node. * @param properties See {@link ElementProps} * @param container The parent node to append to. * @returns A Node that is the appended child (aChild), * except when aChild is a DocumentFragment, * in which case the empty DocumentFragment is returned. */ appendElement(properties, container) { return container.appendChild(this.createElement(container.ownerDocument, properties.tag, properties)); } /** * Inserts a node before a reference node as a child of its parent node. * @param properties See {@link ElementProps} * @param referenceNode The node before which newNode is inserted. * @returns Node */ insertElementBefore(properties, referenceNode) { if (referenceNode.parentNode) return referenceNode.parentNode.insertBefore(this.createElement(referenceNode.ownerDocument, properties.tag, properties), referenceNode); else this.log(`${referenceNode.tagName} has no parent, cannot insert ${properties.tag}`); } /** * Replace oldNode with a new one. * @param properties See {@link ElementProps} * @param oldNode The child to be replaced. * @returns The replaced Node. This is the same node as oldChild. */ replaceElement(properties, oldNode) { if (oldNode.parentNode) return oldNode.parentNode.replaceChild(this.createElement(oldNode.ownerDocument, properties.tag, properties), oldNode); else this.log(`${oldNode.tagName} has no parent, cannot replace it with ${properties.tag}`); } /** * Parse XHTML to XUL fragment. For Zotero 6. * * To load preferences from a Zotero 7's `.xhtml`, use this method to parse it. * @param str xhtml raw text * @param entities dtd file list ("chrome://xxx.dtd") * @param defaultXUL true for default XUL namespace */ parseXHTMLToFragment(str, entities = [], defaultXUL = true) { const parser = new DOMParser(); const xulns = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; const htmlns = "http://www.w3.org/1999/xhtml"; const wrappedStr = `${entities.length ? `<!DOCTYPE bindings [ ${entities.reduce((preamble, url, index) => { return `${preamble}<!ENTITY % _dtd-${index} SYSTEM "${url}"> %_dtd-${index}; `; }, "")}]>` : ""} <html:div xmlns="${defaultXUL ? xulns : htmlns}" xmlns:xul="${xulns}" xmlns:html="${htmlns}"> ${str} </html:div>`; this.log(wrappedStr, parser); const doc = parser.parseFromString(wrappedStr, "text/xml"); this.log(doc); if (doc.documentElement.localName === "parsererror") throw new Error("not well-formed XHTML"); const range = doc.createRange(); range.selectNodeContents(doc.querySelector("div")); return range.extractContents(); } }; const HTMLElementTagNames = [ "a", "abbr", "address", "area", "article", "aside", "audio", "b", "base", "bdi", "bdo", "blockquote", "body", "br", "button", "canvas", "caption", "cite", "code", "col", "colgroup", "data", "datalist", "dd", "del", "details", "dfn", "dialog", "div", "dl", "dt", "em", "embed", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "head", "header", "hgroup", "hr", "html", "i", "iframe", "img", "input", "ins", "kbd", "label", "legend", "li", "link", "main", "map", "mark", "menu", "meta", "meter", "nav", "noscript", "object", "ol", "optgroup", "option", "output", "p", "picture", "pre", "progress", "q", "rp", "rt", "ruby", "s", "samp", "script", "section", "select", "slot", "small", "source", "span", "strong", "style", "sub", "summary", "sup", "table", "tbody", "td", "template", "textarea", "tfoot", "th", "thead", "time", "title", "tr", "track", "u", "ul", "var", "video", "wbr" ]; const XULElementTagNames = [ "action", "arrowscrollbox", "bbox", "binding", "bindings", "box", "broadcaster", "broadcasterset", "button", "browser", "checkbox", "caption", "colorpicker", "column", "columns", "commandset", "command", "conditions", "content", "deck", "description", "dialog", "dialogheader", "editor", "grid", "grippy", "groupbox", "hbox", "iframe", "image", "key", "keyset", "label", "listbox", "listcell", "listcol", "listcols", "listhead", "listheader", "listitem", "member", "menu", "menubar", "menuitem", "menulist", "menupopup", "menuseparator", "observes", "overlay", "page", "popup", "popupset", "preference", "preferences", "prefpane", "prefwindow", "progressmeter", "radio", "radiogroup", "resizer", "richlistbox", "richlistitem", "row", "rows", "rule", "script", "scrollbar", "scrollbox", "scrollcorner", "separator", "spacer", "splitter", "stack", "statusbar", "statusbarpanel", "stringbundle", "stringbundleset", "tab", "tabbrowser", "tabbox", "tabpanel", "tabpanels", "tabs", "template", "textnode", "textbox", "titlebar", "toolbar", "toolbarbutton", "toolbargrippy", "toolbaritem", "toolbarpalette", "toolbarseparator", "toolbarset", "toolbarspacer", "toolbarspring", "toolbox", "tooltip", "tree", "treecell", "treechildren", "treecol", "treecols", "treeitem", "treerow", "treeseparator", "triple", "vbox", "window", "wizard", "wizardpage" ]; const SVGElementTagNames = [ "a", "animate", "animateMotion", "animateTransform", "circle", "clipPath", "defs", "desc", "ellipse", "feBlend", "feColorMatrix", "feComponentTransfer", "feComposite", "feConvolveMatrix", "feDiffuseLighting", "feDisplacementMap", "feDistantLight", "feDropShadow", "feFlood", "feFuncA", "feFuncB", "feFuncG", "feFuncR", "feGaussianBlur", "feImage", "feMerge", "feMergeNode", "feMorphology", "feOffset", "fePointLight", "feSpecularLighting", "feSpotLight", "feTile", "feTurbulence", "filter", "foreignObject", "g", "image", "line", "linearGradient", "marker", "mask", "metadata", "mpath", "path", "pattern", "polygon", "polyline", "radialGradient", "rect", "script", "set", "stop", "style", "svg", "switch", "symbol", "text", "textPath", "title", "tspan", "use", "view" ]; //#endregion //#region src/helpers/dialog.ts /** * Dialog window helper. A superset of XUL dialog. */ var DialogHelper = class extends UITool { /** * Passed to dialog window for data-binding and lifecycle controls. See {@link DialogHelper.setDialogData} */ dialogData; /** * Dialog window instance */ window; elementProps; /** * Create a dialog helper with row \* column grids. * @param row * @param column */ constructor(row, column) { super(); if (row <= 0 || column <= 0) throw new Error(`row and column must be positive integers.`); this.elementProps = { tag: "vbox", attributes: { flex: 1 }, styles: { width: "100%", height: "100%" }, children: [] }; for (let i = 0; i < Math.max(row, 1); i++) { this.elementProps.children.push({ tag: "hbox", attributes: { flex: 1 }, children: [] }); for (let j = 0; j < Math.max(column, 1); j++) this.elementProps.children[i].children.push({ tag: "vbox", attributes: { flex: 1 }, children: [] }); } this.elementProps.children.push({ tag: "hbox", attributes: { flex: 0, pack: "end" }, children: [] }); this.dialogData = {}; } /** * Add a cell at (row, column). Index starts from 0. * @param row * @param column * @param elementProps Cell element props. See {@link ElementProps} * @param cellFlex If the cell is flex. Default true. */ addCell(row, column, elementProps, cellFlex = true) { if (row >= this.elementProps.children.length || column >= this.elementProps.children[row].children.length) throw new Error(`Cell index (${row}, ${column}) is invalid, maximum (${this.elementProps.children.length}, ${this.elementProps.children[0].children.length})`); this.elementProps.children[row].children[column].children = [elementProps]; this.elementProps.children[row].children[column].attributes.flex = cellFlex ? 1 : 0; return this; } /** * Add a control button to the bottom of the dialog. * @param label Button label * @param id Button id. * The corresponding id of the last button user clicks before window exit will be set to `dialogData._lastButtonId`. * @param options Options * @param [options.noClose] Don't close window when clicking this button. * @param [options.callback] Callback of button click event. */ addButton(label, id, options = {}) { id = id || `btn-${Zotero.Utilities.randomString()}-${(/* @__PURE__ */ new Date()).getTime()}`; this.elementProps.children[this.elementProps.children.length - 1].children.push({ tag: "vbox", styles: { margin: "10px" }, children: [{ tag: "button", namespace: "html", id, attributes: { type: "button", "data-l10n-id": label }, properties: { innerHTML: label }, listeners: [{ type: "click", listener: (e) => { this.dialogData._lastButtonId = id; if (options.callback) options.callback(e); if (!options.noClose) this.window.close(); } }] }] }); return this; } /** * Dialog data. * @remarks * This object is passed to the dialog window. * * The control button id is in `dialogData._lastButtonId`; * * The data-binding values are in `dialogData`. * ```ts * interface DialogData { * [key: string | number | symbol]: any; * loadLock?: { promise: Promise<void>; resolve: () => void; isResolved: () => boolean }; // resolve after window load (auto-generated) * loadCallback?: Function; // called after window load * unloadLock?: { promise: Promise<void>; resolve: () => void }; // resolve after window unload (auto-generated) * unloadCallback?: Function; // called after window unload * beforeUnloadCallback?: Function; // called before window unload when elements are accessable. * } * ``` * @param dialogData */ setDialogData(dialogData) { this.dialogData = dialogData; return this; } /** * Open the dialog * @param title Window title * @param windowFeatures * @param windowFeatures.width Ignored if fitContent is `true`. * @param windowFeatures.height Ignored if fitContent is `true`. * @param windowFeatures.left * @param windowFeatures.top * @param windowFeatures.centerscreen Open window at the center of screen. * @param windowFeatures.resizable If window is resizable. * @param windowFeatures.fitContent Resize the window to content size after elements are loaded. * @param windowFeatures.noDialogMode Dialog mode window only has a close button. Set `true` to make maximize and minimize button visible. * @param windowFeatures.alwaysRaised Is the window always at the top. */ open(title, windowFeatures = { centerscreen: true, resizable: true, fitContent: true }) { this.window = openDialog(this, `dialog-${Zotero.Utilities.randomString()}-${(/* @__PURE__ */ new Date()).getTime()}`, title, this.elementProps, this.dialogData, windowFeatures); return this; } }; function openDialog(dialogHelper, targetId, title, elementProps, dialogData, windowFeatures = { centerscreen: true, resizable: true, fitContent: true }) { dialogData = dialogData || {}; if (!dialogData.loadLock) { let loadResolve; let isLoadResolved = false; const loadPromise = new Promise((resolve) => { loadResolve = resolve; }); loadPromise.then(() => { isLoadResolved = true; }); dialogData.loadLock = { promise: loadPromise, resolve: loadResolve, isResolved: () => isLoadResolved }; } if (!dialogData.unloadLock) { let unloadResolve; const unloadPromise = new Promise((resolve) => { unloadResolve = resolve; }); dialogData.unloadLock = { promise: unloadPromise, resolve: unloadResolve }; } let featureString = `resizable=${windowFeatures.resizable ? "yes" : "no"},`; if (windowFeatures.width || windowFeatures.height) featureString += `width=${windowFeatures.width || 100},height=${windowFeatures.height || 100},`; if (windowFeatures.left) featureString += `left=${windowFeatures.left},`; if (windowFeatures.top) featureString += `top=${windowFeatures.top},`; if (windowFeatures.centerscreen) featureString += "centerscreen,"; if (windowFeatures.noDialogMode) featureString += "dialog=no,"; if (windowFeatures.alwaysRaised) featureString += "alwaysRaised=yes,"; const win = dialogHelper.getGlobal("openDialog")("about:blank", targetId || "_blank", featureString, dialogData); dialogData.loadLock?.promise.then(() => { win.document.head.appendChild(dialogHelper.createElement(win.document, "title", { properties: { innerText: title }, attributes: { "data-l10n-id": title } })); let l10nFiles = dialogData.l10nFiles || []; if (typeof l10nFiles === "string") l10nFiles = [l10nFiles]; l10nFiles.forEach((file) => { win.document.head.appendChild(dialogHelper.createElement(win.document, "link", { properties: { rel: "localization", href: file } })); }); dialogHelper.appendElement({ tag: "fragment", children: [ { tag: "style", properties: { innerHTML: style } }, { tag: "link", properties: { rel: "stylesheet", href: "chrome://global/skin/global.css" } }, { tag: "link", properties: { rel: "stylesheet", href: "chrome://zotero-platform/content/zotero.css" } } ] }, win.document.head); replaceElement(elementProps, dialogHelper); win.document.body.appendChild(dialogHelper.createElement(win.document, "fragment", { children: [elementProps] })); Array.from(win.document.querySelectorAll("*[data-bind]")).forEach((elem) => { const bindKey = elem.getAttribute("data-bind"); const bindAttr = elem.getAttribute("data-attr"); const bindProp = elem.getAttribute("data-prop"); if (bindKey && dialogData && dialogData[bindKey]) if (bindProp) elem[bindProp] = dialogData[bindKey]; else elem.setAttribute(bindAttr || "value", dialogData[bindKey]); }); if (windowFeatures.fitContent) setTimeout(() => { win.sizeToContent(); }, 300); win.focus(); }).then(() => { dialogData?.loadCallback && dialogData.loadCallback(); }); dialogData.unloadLock?.promise.then(() => { dialogData?.unloadCallback && dialogData.unloadCallback(); }); win.addEventListener("DOMContentLoaded", function onWindowLoad(_ev) { win.arguments[0]?.loadLock?.resolve(); win.removeEventListener("DOMContentLoaded", onWindowLoad, false); }, false); win.addEventListener("beforeunload", function onWindowBeforeUnload(_ev) { Array.from(win.document.querySelectorAll("*[data-bind]")).forEach((elem) => { const dialogData$1 = this.window.arguments[0]; const bindKey = elem.getAttribute("data-bind"); const bindAttr = elem.getAttribute("data-attr"); const bindProp = elem.getAttribute("data-prop"); if (bindKey && dialogData$1) if (bindProp) dialogData$1[bindKey] = elem[bindProp]; else dialogData$1[bindKey] = elem.getAttribute(bindAttr || "value"); }); this.window.removeEventListener("beforeunload", onWindowBeforeUnload, false); dialogData?.beforeUnloadCallback && dialogData.beforeUnloadCallback(); }); win.addEventListener("unload", function onWindowUnload(_ev) { if (!this.window.arguments[0]?.loadLock?.isResolved()) return; this.window.arguments[0]?.unloadLock?.resolve(); this.window.removeEventListener("unload", onWindowUnload, false); }); if (win.document.readyState === "complete") win.arguments[0]?.loadLock?.resolve(); return win; } function replaceElement(elementProps, uiTool) { let checkChildren = true; if (elementProps.tag === "select") { let is140 = false; try { is140 = Number.parseInt(Services.appinfo.platformVersion.match(/^\d+/)[0]) >= 140; } catch { is140 = false; } if (!is140) { checkChildren = false; const customSelectProps = { tag: "div", classList: ["dropdown"], listeners: [{ type: "mouseleave", listener: (ev) => { const select = ev.target.querySelector("select"); select?.blur(); } }], children: [Object.assign({}, elementProps, { tag: "select", listeners: [{ type: "focus", listener: (ev) => { const select = ev.target; const dropdown = select.parentElement?.querySelector(".dropdown-content"); dropdown && (dropdown.style.display = "block"); select.setAttribute("focus", "true"); } }, { type: "blur", listener: (ev) => { const select = ev.target; const dropdown = select.parentElement?.querySelector(".dropdown-content"); dropdown && (dropdown.style.display = "none"); select.removeAttribute("focus"); } }] }), { tag: "div", classList: ["dropdown-content"], children: elementProps.children?.map((option) => ({ tag: "p", attributes: { value: option.properties?.value }, properties: { innerHTML: option.properties?.innerHTML || option.properties?.textContent }, classList: ["dropdown-item"], listeners: [{ type: "click", listener: (ev) => { const select = ev.target.parentElement?.previousElementSibling; select && (select.value = ev.target.getAttribute("value") || ""); select?.blur(); } }] })) }] }; for (const key in elementProps) delete elementProps[key]; Object.assign(elementProps, customSelectProps); } else { const children = elementProps.children || []; const randomString = CSS.escape(`${Zotero.Utilities.randomString()}-${(/* @__PURE__ */ new Date()).getTime()}`); if (!elementProps.id) elementProps.id = `select-${randomString}`; const selectId = elementProps.id; const popupId = `popup-${randomString}`; const popup = uiTool.appendElement({ tag: "menupopup", namespace: "xul", id: popupId, children: children.map((option) => ({ tag: "menuitem", attributes: { value: option.properties?.value, label: option.properties?.innerHTML || option.properties?.textContent } })), listeners: [{ type: "command", listener: (ev) => { if (ev.target?.tagName !== "menuitem") return; const select = uiTool.window.document.getElementById(selectId); const menuitem = ev.target; if (select) { select.value = menuitem.getAttribute("value") || ""; select.blur(); } popup.hidePopup(); } }] }, uiTool.window.document.body); if (!elementProps.listeners) elementProps.listeners = []; elementProps.listeners.push(...[{ type: "click", listener: (ev) => { const select = ev.target; const rect = select.getBoundingClientRect(); let left = rect.left + uiTool.window.scrollX; let top = rect.bottom + uiTool.window.scrollY; if (uiTool.getGlobal("Zotero").isMac) { left += uiTool.window.screenLeft; top += uiTool.window.screenTop + rect.height; } fixMenuPopup(popup, uiTool); popup.openPopup(null, "", left, top, false, false); select.setAttribute("focus", "true"); } }]); } } else if (elementProps.tag === "a") { const href = elementProps?.properties?.href || ""; elementProps.properties ??= {}; elementProps.properties.href = "javascript:void(0);"; elementProps.attributes ??= {}; elementProps.attributes["zotero-href"] = href; elementProps.listeners ??= []; elementProps.listeners.push({ type: "click", listener: (ev) => { const href$1 = ev.target?.getAttribute("zotero-href"); href$1 && uiTool.getGlobal("Zotero").launchURL(href$1); } }); elementProps.classList ??= []; elementProps.classList.push("zotero-text-link"); } if (checkChildren) elementProps.children?.forEach((child) => replaceElement(child, uiTool)); } const style = ` html { color-scheme: light dark; } .zotero-text-link { -moz-user-focus: normal; color: -moz-nativehyperlinktext; text-decoration: underline; border: 1px solid transparent; cursor: pointer; } .dropdown { position: relative; display: inline-block; } .dropdown-content { display: none; position: absolute; background-color: var(--material-toolbar); min-width: 160px; box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.5); border-radius: 5px; padding: 5px 0 5px 0; z-index: 999; } .dropdown-item { margin: 0px; padding: 5px 10px 5px 10px; } .dropdown-item:hover { background-color: var(--fill-quinary); } `; function fixMenuPopup(popup, uiTool) { for (const item of popup.querySelectorAll("menuitem")) if (!item.innerHTML) uiTool.appendElement({ tag: "fragment", children: [ { tag: "image", namespace: "xul", classList: ["menu-icon"], attributes: { "aria-hidden": "true" } }, { tag: "label", namespace: "xul", classList: ["menu-text"], properties: { value: item.getAttribute("label") || "" }, attributes: { crop: "end", "aria-hidden": "true" } }, { tag: "label", namespace: "xul", classList: ["menu-highlightable-text"], properties: { textContent: item.getAttribute("label") || "" }, attributes: { crop: "end", "aria-hidden": "true" } }, { tag: "label", namespace: "xul", classList: ["menu-accel"], attributes: { "aria-hidden": "true" } } ] }, item); } //#endregion //#region src/helpers/filePicker.ts /** * File picker helper. * @param title window title * @param mode * @param filters Array<[hint string, filter string]> * @param suggestion default file/folder * @param window the parent window. By default it is the main window * @param filterMask built-in filters * @param directory directory in which to open the file picker * @example * ```ts * await new FilePickerHelper( * `${Zotero.getString("fileInterface.import")} MarkDown Document`, * "open", * [["MarkDown File(*.md)", "*.md"]] * ).open(); * ``` */ var FilePickerHelper = class extends BasicTool { title; mode; filters; suggestion; directory; window; filterMask; constructor(title, mode, filters, suggestion, window$1, filterMask, directory) { super(); this.title = title; this.mode = mode; this.filters = filters; this.suggestion = suggestion; this.directory = directory; this.window = window$1; this.filterMask = filterMask; } async open() { const Backend = ChromeUtils.importESModule("chrome://zotero/content/modules/filePicker.mjs").FilePicker; const fp = new Backend(); fp.init(this.window || this.getGlobal("window"), this.title, this.getMode(fp)); for (const [label, ext] of this.filters || []) fp.appendFilter(label, ext); if (this.filterMask) fp.appendFilters(this.getFilterMask(fp)); if (this.suggestion) fp.defaultString = this.suggestion; if (this.directory) fp.displayDirectory = this.directory; const userChoice = await fp.show(); switch (userChoice) { case fp.returnOK: case fp.returnReplace: return this.mode === "multiple" ? fp.files : fp.file; default: return false; } } getMode(fp) { switch (this.mode) { case "open": return fp.modeOpen; case "save": return fp.modeSave; case "folder": return fp.modeGetFolder; case "multiple": return fp.modeOpenMultiple; default: return 0; } } getFilterMask(fp) { switch (this.filterMask) { case "all": return fp.filterAll; case "html": return fp.filterHTML; case "text": return fp.filterText; case "images": return fp.filterImages; case "xml": return fp.filterXML; case "apps": return fp.filterApps; case "urls": return fp.filterAllowURLs; case "audio": return fp.filterAudio; case "video": return fp.filterVideo; default: return 1; } } }; //#endregion //#region src/helpers/guide.ts /** * Helper for creating a guide. * Designed for creating a step-by-step guide for users. * @alpha */ var GuideHelper = class extends BasicTool { _steps = []; constructor() { super(); } addStep(step) { this._steps.push(step); return this; } addSteps(steps) { this._steps.push(...steps); return this; } async show(doc) { if (!doc?.ownerGlobal) throw new Error("Document is required."); const guide = new Guide(doc.ownerGlobal); await guide.show(this._steps); const promise = new Promise((resolve) => { guide._panel.addEventListener("guide-finished", () => resolve(guide)); }); await promise; return guide; } async highlight(doc, step) { if (!doc?.ownerGlobal) throw new Error("Document is required."); const guide = new Guide(doc.ownerGlobal); await guide.show([step]); const promise = new Promise((resolve) => { guide._panel.addEventListener("guide-finished", () => resolve(guide)); }); await promise; return guide; } }; var Guide = class { _window; _id = `guide-${Zotero.Utilities.randomString()}`; _panel; _header; _body; _footer; _progress; _closeButton; _prevButton; _nextButton; _steps; _noClose; _closed; _autoNext; _currentIndex; initialized; _cachedMasks = []; get content() { return this._window.MozXULElement.parseXULToFragment(` <panel id="${this._id}" class="guide-panel" type="arrow" align="top" noautohide="true"> <html:div class="guide-panel-content"> <html:div class="guide-panel-header"></html:div> <html:div class="guide-panel-body"></html:div> <html:div class="guide-panel-footer"> <html:div class="guide-panel-progress"></html:div> <html:div class="guide-panel-buttons"> <button id="prev-button" class="guide-panel-button" hidden="true"></button> <button id="next-button" class="guide-panel-button" hidden="true"></button> <button id="close-button" class="guide-panel-button" hidden="true"></button> </html:div> </html:div> </html:div> <html:style> .guide-panel { backgr