diff --git a/chrome/content/zotero/elements/attachmentPreview.js b/chrome/content/zotero/elements/attachmentPreview.js index c932b26838..ed0df9d7ee 100644 --- a/chrome/content/zotero/elements/attachmentPreview.js +++ b/chrome/content/zotero/elements/attachmentPreview.js @@ -27,9 +27,6 @@ // eslint-disable-next-line no-undef class AttachmentPreview extends XULElementBase { static fileTypeMap = { - 'application/pdf': 'pdf', - 'application/epub+zip': 'epub', - 'text/html': 'snapshot', // TODO: support video and audio // 'video/mp4': 'video', // 'video/webm': 'video', @@ -111,6 +108,9 @@ } get previewType() { + if (this._item?.attachmentReaderType) { + return this._item.attachmentReaderType; + } let contentType = this._item?.attachmentContentType; if (!contentType) { return "file"; diff --git a/chrome/content/zotero/locateMenu.js b/chrome/content/zotero/locateMenu.js index 54e93cfab2..d02b0a82cd 100644 --- a/chrome/content/zotero/locateMenu.js +++ b/chrome/content/zotero/locateMenu.js @@ -370,11 +370,6 @@ var Zotero_LocateMenu = new function() { snapshot: "zotero-menuitem-attachments-snapshot", multiple: "zotero-menuitem-new-tab", }; - const attachmentTypes = { - "application/pdf": "pdf", - "application/epub+zip": "epub", - "text/html": "snapshot", - }; this._attachmentType = "multiple"; Object.defineProperty(this, "className", { get: () => (alternateWindowBehavior ? "zotero-menuitem-new-window" : classNames[this._attachmentType]), @@ -404,7 +399,7 @@ var Zotero_LocateMenu = new function() { this._attachmentType = "multiple"; } else if (attachment) { - this._attachmentType = attachmentTypes[attachment.attachmentContentType]; + this._attachmentType = attachment.attachmentReaderType; } return !!attachment; }; diff --git a/chrome/content/zotero/xpcom/data/item.js b/chrome/content/zotero/xpcom/data/item.js index fef04456da..40b414949a 100644 --- a/chrome/content/zotero/xpcom/data/item.js +++ b/chrome/content/zotero/xpcom/data/item.js @@ -1914,7 +1914,7 @@ Zotero.Item.prototype._saveData = Zotero.Promise.coroutine(function* (env) { if (!parentItem.isFileAttachment()) { throw new Error("Annotation parent must be a file attachment"); } - if (!['application/pdf', 'application/epub+zip', 'text/html'].includes(parentItem.attachmentContentType)) { + if (!parentItem.attachmentReaderType) { throw new Error("Annotation parent must be a PDF, EPUB, or HTML snapshot"); } let type = this._getLatestField('annotationType'); @@ -3103,6 +3103,25 @@ Zotero.defineProperty(Zotero.Item.prototype, 'attachmentContentType', { }); +Zotero.defineProperty(Zotero.Item.prototype, 'attachmentReaderType', { + get() { + if (!this.isFileAttachment()) { + return undefined; + } + switch (this.attachmentContentType) { + case 'application/pdf': + return 'pdf'; + case 'application/epub+zip': + return 'epub'; + case 'text/html': + return 'snapshot'; + default: + return undefined; + } + } +}); + + Zotero.Item.prototype.getAttachmentCharset = function() { Zotero.debug("getAttachmentCharset() deprecated -- use .attachmentCharset"); return this.attachmentCharset; diff --git a/chrome/content/zotero/xpcom/fileHandlers.js b/chrome/content/zotero/xpcom/fileHandlers.js new file mode 100644 index 0000000000..1acdfa2035 --- /dev/null +++ b/chrome/content/zotero/xpcom/fileHandlers.js @@ -0,0 +1,501 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2018 Center for History and New Media + George Mason University, Fairfax, Virginia, USA + https://zotero.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + +/* eslint-disable array-element-newline */ + +Zotero.FileHandlers = { + async open(item, params) { + let { location, openInWindow = false } = params || {}; + + let path = await item.getFilePathAsync(); + if (!path) { + Zotero.warn(`File not found: ${item.attachmentPath}`); + return false; + } + + Zotero.debug('Opening ' + path); + + let readerType = item.attachmentReaderType; + + // Not a file that we/external readers handle with page number support - + // just open it with the system handler + if (!readerType) { + Zotero.debug('No associated reader type -- launching default application'); + Zotero.launchFile(path); + return true; + } + + let handler = Zotero.Prefs.get(`fileHandler.${readerType}`); + if (!handler) { + Zotero.debug('No external handler for ' + readerType + ' -- opening in Zotero'); + await Zotero.Reader.open(item.id, location, { + openInWindow, + allowDuplicate: openInWindow + }); + return true; + } + + let systemHandler = this._getSystemHandler(item.attachmentContentType); + + if (handler === 'system') { + handler = systemHandler; + Zotero.debug(`System handler is ${handler}`); + } + else { + Zotero.debug(`Custom handler is ${handler}`); + } + + let handlers; + if (this._mockHandlers) { + handlers = this._mockHandlers[readerType]; + } + else if (Zotero.isMac) { + handlers = this._handlersMac[readerType]; + } + else if (Zotero.isWin) { + handlers = this._handlersWin[readerType]; + } + else if (Zotero.isLinux) { + handlers = this._handlersLinux[readerType]; + } + + let page = location?.position?.pageIndex ?? undefined; + // Add 1 to page index for external readers + if (page !== undefined && parseInt(page) == page) { + page = parseInt(page) + 1; + } + + // If there are handlers for this platform and this reader type... + if (handlers) { + // First try to open with the custom handler + try { + for (let [i, { name, open }] of handlers.entries()) { + if (name.test(handler)) { + Zotero.debug('Opening with handler ' + i); + await open(handler, { filePath: path, location, page }); + return true; + } + } + } + catch (e) { + Zotero.logError(e); + } + + // If we get here, we don't have special handling for the custom + // handler that the user has set. If we have a location, we really + // want to open with something we know how to pass a page number to, + // so we'll see if we know how to do that for the system handler. + if (location) { + try { + if (systemHandler && handler !== systemHandler) { + Zotero.debug(`Custom handler did not match -- falling back to system handler ${systemHandler}`); + handler = systemHandler; + for (let [i, { name, open }] of handlers.entries()) { + if (name.test(handler)) { + Zotero.debug('Opening with handler ' + i); + await open(handler, { filePath: path, location, page }); + return true; + } + } + } + } + catch (e) { + Zotero.logError(e); + } + + // And lastly, the fallback handler for this platform/reader type, + // if we have one + let fallback = handlers.find(h => h.fallback); + if (fallback) { + try { + Zotero.debug('Opening with fallback'); + await fallback.open(null, { filePath: path, location, page }); + return true; + } + catch (e) { + // Don't log error if fallback fails + // Just move on and try system handler + } + } + } + } + + Zotero.debug("Opening handler without page number"); + + handler = handler || systemHandler; + if (handler) { + if (Zotero.isMac) { + try { + await Zotero.Utilities.Internal.exec('/usr/bin/open', ['-a', handler, path]); + return true; + } + catch (e) { + Zotero.logError(e); + } + } + + try { + if (await OS.File.exists(handler)) { + Zotero.debug(`Opening with handler ${handler}`); + Zotero.launchFileWithApplication(path, handler); + return true; + } + } + catch (e) { + Zotero.logError(e); + } + Zotero.logError(`${handler} not found`); + } + + Zotero.debug('Launching file normally'); + Zotero.launchFile(path); + return true; + }, + + _handlersMac: { + pdf: [ + { + name: /Preview/, + fallback: true, + async open(appPath, { filePath, page }) { + await Zotero.Utilities.Internal.exec('/usr/bin/open', ['-a', "Preview", filePath]); + if (page !== undefined) { + // Go to page using AppleScript + let args = [ + '-e', 'tell app "Preview" to activate', + '-e', 'tell app "System Events" to keystroke "g" using {option down, command down}', + '-e', `tell app "System Events" to keystroke "${page}"`, + '-e', 'tell app "System Events" to keystroke return' + ]; + await Zotero.Utilities.Internal.exec('/usr/bin/osascript', args); + } + }, + }, + { + name: /Adobe Acrobat/, + async open(appPath, { page }) { + if (page !== undefined) { + // Go to page using AppleScript + let args = [ + '-e', `tell app "${appPath}" to activate`, + '-e', 'tell app "System Events" to keystroke "n" using {command down, shift down}', + '-e', `tell app "System Events" to keystroke "${page}"`, + '-e', 'tell app "System Events" to keystroke return' + ]; + await Zotero.Utilities.Internal.exec('/usr/bin/osascript', args); + } + } + }, + { + name: /Skim/, + async open(appPath, { filePath, page }) { + // Escape double-quotes in path + var quoteRE = /"/g; + filePath = filePath.replace(quoteRE, '\\"'); + let args = [ + '-e', `tell app "${appPath}" to activate`, + '-e', `tell app "${appPath}" to open "${filePath}"` + ]; + if (page !== undefined) { + let filename = OS.Path.basename(filePath) + .replace(quoteRE, '\\"'); + args.push('-e', `tell document "${filename}" of application "${appPath}" to go to page ${page}`); + } + await Zotero.Utilities.Internal.exec('/usr/bin/osascript', args); + } + }, + { + name: /PDF Expert/, + async open(appPath, { page }) { + // Go to page using AppleScript (same as Preview) + let args = [ + '-e', `tell app "${appPath}" to activate` + ]; + if (page !== undefined) { + args.push( + '-e', 'tell app "System Events" to keystroke "g" using {option down, command down}', + '-e', `tell app "System Events" to keystroke "${page}"`, + '-e', 'tell app "System Events" to keystroke return' + ); + } + await Zotero.Utilities.Internal.exec('/usr/bin/osascript', args); + } + }, + ], + epub: [ + { + name: /Calibre/i, + async open(appPath, { filePath, location }) { + if (!appPath.endsWith('ebook-viewer.app')) { + appPath += '/Contents/ebook-viewer.app'; + } + let args = ['-a', appPath, filePath]; + if (location?.position?.value) { + args.push('--args', '--open-at=' + location.position.value); + } + await Zotero.Utilities.Internal.exec('/usr/bin/open', args); + } + }, + ] + }, + + _handlersWin: { + pdf: [ + { + name: new RegExp(''), // Match any handler + async open(appPath, { filePath, page }) { + let args = [filePath]; + if (page !== undefined) { + // Include flags to open the PDF on a given page in various apps + // + // Adobe Acrobat: http://partners.adobe.com/public/developer/en/acrobat/PDFOpenParameters.pdf + // PDF-XChange: http://help.tracker-software.com/eu/default.aspx?pageid=PDFXView25:command_line_options + args.unshift('/A', 'page=' + page); + } + await Zotero.Utilities.Internal.exec(appPath, args); + } + } + ], + epub: [ + { + name: /Calibre/i, + async open(appPath, { filePath, location }) { + if (appPath.toLowerCase().endsWith('calibre.exe')) { + appPath = appPath.slice(0, -11) + 'ebook-viewer.exe'; + } + let args = [filePath]; + if (location?.position?.value) { + args.push('--open-at=' + location.position.value); + } + await Zotero.Utilities.Internal.exec(appPath, args); + } + } + ] + }, + + _handlersLinux: { + pdf: [ + { + name: /evince|okular/i, + fallback: true, + async open(appPath, { filePath, page }) { + if (appPath) { + switch (appPath.toLowerCase()) { + case 'okular': + appPath = '/usr/bin/okular'; + + // It's "Document Viewer" on stock Ubuntu + case 'document viewer': + case 'evince': + appPath = '/usr/bin/evince'; + } + } + else if (await OS.File.exists('/usr/bin/okular')) { + appPath = '/usr/bin/okular'; + } + else if (await OS.File.exists('/usr/bin/evince')) { + appPath = '/usr/bin/evince'; + } + else { + throw new Error('No PDF reader found'); + } + + // TODO: Try to get default from mimeapps.list, etc., in case system default is okular + // or evince somewhere other than /usr/bin + + let args = [filePath]; + if (page !== undefined) { + args.unshift('-p', page); + } + await Zotero.Utilities.Internal.exec(appPath, args); + } + } + ], + epub: [ + { + name: /calibre/i, + async open(appPath, { filePath, location }) { + if (appPath.toLowerCase().endsWith('calibre')) { + appPath = appPath.slice(0, -7) + 'ebook-viewer'; + } + let args = [filePath]; + if (location?.position?.value) { + args.push('--open-at=' + location.position.value); + } + await Zotero.Utilities.Internal.exec(appPath, args); + } + } + ] + }, + + _getSystemHandler(mimeType) { + if (Zotero.isWin) { + return this._getSystemHandlerWin(mimeType); + } + else { + return this._getSystemHandlerPOSIX(mimeType); + } + }, + + _getSystemHandlerWin(mimeType) { + // Based on getPDFReader() in ZotFile (GPL) + // https://github.com/jlegewie/zotfile/blob/a6c9e02e17b60cbc1f9bb4062486548d9ef583e3/chrome/content/zotfile/utils.js + + var wrk = Components.classes["@mozilla.org/windows-registry-key;1"] + .createInstance(Components.interfaces.nsIWindowsRegKey); + // Get handler + var extension = Zotero.MIME.getPrimaryExtension(mimeType); + var tryKeys = [ + { + root: wrk.ROOT_KEY_CURRENT_USER, + path: `Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\.${extension}\\UserChoice`, + value: 'Progid' + }, + { + root: wrk.ROOT_KEY_CLASSES_ROOT, + path: `.${extension}`, + value: '' + } + ]; + var progId; + for (let key of tryKeys) { + try { + wrk.open(key.root, key.path, wrk.ACCESS_READ); + progId = wrk.readStringValue(key.value); + if (progId) { + break; + } + } + catch (e) {} + } + + if (!progId) { + wrk.close(); + return false; + } + + // Get version specific handler, if it exists + try { + wrk.open( + wrk.ROOT_KEY_CLASSES_ROOT, + progId + '\\CurVer', + wrk.ACCESS_READ + ); + progId = wrk.readStringValue('') || progId; + } + catch (e) {} + + // Get command + var success = false; + tryKeys = [ + progId + '\\shell\\Read\\command', + progId + '\\shell\\Open\\command' + ]; + for (let key of tryKeys) { + try { + wrk.open( + wrk.ROOT_KEY_CLASSES_ROOT, + key, + wrk.ACCESS_READ + ); + success = true; + break; + } + catch (e) {} + } + + if (!success) { + wrk.close(); + return false; + } + + try { + var command = wrk.readStringValue('').match(/^(?:".+?"|[^"]\S+)/); + } + catch (e) {} + + wrk.close(); + + if (!command) return false; + return command[0].replace(/"/g, ''); + }, + + _getSystemHandlerPOSIX(mimeType) { + var handlerService = Cc["@mozilla.org/uriloader/handler-service;1"] + .getService(Ci.nsIHandlerService); + var handlers = handlerService.enumerate(); + var handler; + while (handlers.hasMoreElements()) { + let handlerInfo = handlers.getNext().QueryInterface(Ci.nsIHandlerInfo); + if (handlerInfo.type == mimeType) { + handler = handlerInfo; + break; + } + } + if (!handler) { + // We can't get the name of the system default handler unless we add an entry + Zotero.debug("Default handler not found -- adding default entry"); + let mimeService = Components.classes["@mozilla.org/mime;1"] + .getService(Components.interfaces.nsIMIMEService); + let mimeInfo = mimeService.getFromTypeAndExtension(mimeType, ""); + mimeInfo.preferredAction = 4; + mimeInfo.alwaysAskBeforeHandling = false; + handlerService.store(mimeInfo); + + // And once we do that, we can get the name (but not the path, unfortunately) + let handlers = handlerService.enumerate(); + while (handlers.hasMoreElements()) { + let handlerInfo = handlers.getNext().QueryInterface(Ci.nsIHandlerInfo); + if (handlerInfo.type == mimeType) { + handler = handlerInfo; + break; + } + } + } + if (handler) { + Zotero.debug(`Default handler is ${handler.defaultDescription}`); + return handler.defaultDescription; + } + return false; + } +}; + +Zotero.OpenPDF = { + openToPage: async function (pathOrItem, page, annotationKey) { + Zotero.warn('Zotero.OpenPDF.openToPage() is deprecated -- use Zotero.FileHandlers.open()'); + if (typeof pathOrItem === 'string') { + throw new Error('Zotero.OpenPDF.openToPage() requires an item -- update your code!'); + } + + await Zotero.FileHandlers.open(pathOrItem, { + location: { + annotationID: annotationKey, + position: { + pageIndex: page, + } + } + }); + } +}; diff --git a/chrome/content/zotero/xpcom/openPDF.js b/chrome/content/zotero/xpcom/openPDF.js deleted file mode 100644 index 1cdd419cfc..0000000000 --- a/chrome/content/zotero/xpcom/openPDF.js +++ /dev/null @@ -1,353 +0,0 @@ -/* - ***** BEGIN LICENSE BLOCK ***** - - Copyright © 2018 Center for History and New Media - George Mason University, Fairfax, Virginia, USA - https://zotero.org - - This file is part of Zotero. - - Zotero is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Zotero is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with Zotero. If not, see . - - ***** END LICENSE BLOCK ***** -*/ - -/* eslint-disable array-element-newline */ - -Zotero.OpenPDF = { - openToPage: async function (pathOrItem, page, annotationKey) { - var handler = Zotero.Prefs.get("fileHandler.pdf"); - - var path; - if (pathOrItem == 'string') { - Zotero.logError("Zotero.OpenPDF.openToPage() now takes a Zotero.Item rather than a path " - + "-- please update your code"); - path = pathOrItem; - } - else { - let item = pathOrItem; - let library = Zotero.Libraries.get(item.libraryID); - // Zotero PDF reader - if (!handler) { - let location = { - annotationID: annotationKey, - pageIndex: page && page - 1 - }; - await Zotero.Reader.open(item.id, location); - return true; - } - - path = await item.getFilePathAsync(); - if (!path) { - Zotero.warn(`${path} not found`); - return false; - } - } - - var opened = false; - - if (handler != 'system') { - Zotero.debug(`Custom handler is ${handler}`); - } - - if (Zotero.isMac) { - if (!this._openWithHandlerMac(handler, path, page)) { - // Try to detect default app - handler = this._getPDFHandlerName(); - if (!this._openWithHandlerMac(handler, path, page)) { - // Fall back to Preview - this._openWithPreview(path, page); - } - } - opened = true; - } - else if (Zotero.isWin) { - if (handler == 'system') { - handler = this._getPDFHandlerWindows(); - if (handler) { - Zotero.debug(`Default handler is ${handler}`); - } - } - if (handler) { - // Include flags to open the PDF on a given page in various apps - // - // Adobe Acrobat: http://partners.adobe.com/public/developer/en/acrobat/PDFOpenParameters.pdf - // PDF-XChange: http://help.tracker-software.com/eu/default.aspx?pageid=PDFXView25:command_line_options - let args = ['/A', 'page=' + page, path]; - Zotero.Utilities.Internal.exec(handler, args); - opened = true; - } - else { - Zotero.debug("No handler found"); - } - } - else if (Zotero.isLinux) { - if (handler == 'system') { - handler = await this._getPDFHandlerLinux(); - if (handler) { - Zotero.debug(`Resolved handler is ${handler}`); - } - } - if (handler && (handler.includes('evince') || handler.includes('okular'))) { - this._openWithEvinceOrOkular(handler, path, page); - opened = true; - } - // Fall back to okular and then evince if unknown handler - else if (await OS.File.exists('/usr/bin/okular')) { - this._openWithEvinceOrOkular('/usr/bin/okular', path, page); - opened = true; - } - else if (await OS.File.exists('/usr/bin/evince')) { - this._openWithEvinceOrOkular('/usr/bin/evince', path, page); - opened = true; - } - else { - Zotero.debug("No handler found"); - } - } - return opened; - }, - - _getPDFHandlerName: function () { - var handlerService = Cc["@mozilla.org/uriloader/handler-service;1"] - .getService(Ci.nsIHandlerService); - var handlers = handlerService.enumerate(); - var handler; - while (handlers.hasMoreElements()) { - let handlerInfo = handlers.getNext().QueryInterface(Ci.nsIHandlerInfo); - if (handlerInfo.type == 'application/pdf') { - handler = handlerInfo; - break; - } - } - if (!handler) { - // We can't get the name of the system default handler unless we add an entry - Zotero.debug("Default handler not found -- adding default entry"); - let mimeService = Components.classes["@mozilla.org/mime;1"] - .getService(Components.interfaces.nsIMIMEService); - let mimeInfo = mimeService.getFromTypeAndExtension("application/pdf", ""); - mimeInfo.preferredAction = 4; - mimeInfo.alwaysAskBeforeHandling = false; - handlerService.store(mimeInfo); - - // And once we do that, we can get the name (but not the path, unfortunately) - let handlers = handlerService.enumerate(); - while (handlers.hasMoreElements()) { - let handlerInfo = handlers.getNext().QueryInterface(Ci.nsIHandlerInfo); - if (handlerInfo.type == 'application/pdf') { - handler = handlerInfo; - break; - } - } - } - if (handler) { - Zotero.debug(`Default handler is ${handler.defaultDescription}`); - return handler.defaultDescription; - } - return false; - }, - - // - // Mac - // - _openWithHandlerMac: function (handler, path, page) { - if (!handler) { - return false; - } - if (handler.includes('Preview')) { - this._openWithPreview(path, page); - return true; - } - if (handler.includes('Adobe Acrobat')) { - this._openWithAcrobat(handler, path, page); - return true; - } - if (handler.includes('Skim')) { - this._openWithSkim(handler, path, page); - return true; - } - if (handler.includes('PDF Expert')) { - this._openWithPDFExpert(handler, path, page); - return true; - } - return false; - }, - - _openWithPreview: async function (filePath, page) { - await Zotero.Utilities.Internal.exec('/usr/bin/open', ['-a', "Preview", filePath]); - // Go to page using AppleScript - let args = [ - '-e', 'tell app "Preview" to activate', - '-e', 'tell app "System Events" to keystroke "g" using {option down, command down}', - '-e', `tell app "System Events" to keystroke "${page}"`, - '-e', 'tell app "System Events" to keystroke return' - ]; - await Zotero.Utilities.Internal.exec('/usr/bin/osascript', args); - }, - - _openWithAcrobat: async function (appPath, filePath, page) { - await Zotero.Utilities.Internal.exec('/usr/bin/open', ['-a', appPath, filePath]); - // Go to page using AppleScript - let args = [ - '-e', `tell app "${appPath}" to activate`, - '-e', 'tell app "System Events" to keystroke "n" using {command down, shift down}', - '-e', `tell app "System Events" to keystroke "${page}"`, - '-e', 'tell app "System Events" to keystroke return' - ]; - await Zotero.Utilities.Internal.exec('/usr/bin/osascript', args); - }, - - _openWithSkim: async function (appPath, filePath, page) { - // Escape double-quotes in path - var quoteRE = /"/g; - filePath = filePath.replace(quoteRE, '\\"'); - let filename = OS.Path.basename(filePath).replace(quoteRE, '\\"'); - let args = [ - '-e', `tell app "${appPath}" to activate`, - '-e', `tell app "${appPath}" to open "${filePath}"` - ]; - args.push('-e', `tell document "${filename}" of application "${appPath}" to go to page ${page}`); - await Zotero.Utilities.Internal.exec('/usr/bin/osascript', args); - }, - - _openWithPDFExpert: async function (appPath, filePath, page) { - await Zotero.Utilities.Internal.exec('/usr/bin/open', ['-a', appPath, filePath]); - // Go to page using AppleScript (same as Preview) - let args = [ - '-e', `tell app "${appPath}" to activate`, - '-e', 'tell app "System Events" to keystroke "g" using {option down, command down}', - '-e', `tell app "System Events" to keystroke "${page}"`, - '-e', 'tell app "System Events" to keystroke return' - ]; - await Zotero.Utilities.Internal.exec('/usr/bin/osascript', args); - }, - - // - // Windows - // - /** - * Get path to default pdf reader application on windows - * - * From getPDFReader() in ZotFile (GPL) - * https://github.com/jlegewie/zotfile/blob/master/chrome/content/zotfile/utils.js - * - * @return {String|false} - Path to default pdf reader application, or false if none - */ - _getPDFHandlerWindows: function () { - var wrk = Components.classes["@mozilla.org/windows-registry-key;1"] - .createInstance(Components.interfaces.nsIWindowsRegKey); - // Get handler for PDFs - var tryKeys = [ - { - root: wrk.ROOT_KEY_CURRENT_USER, - path: 'Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\.pdf\\UserChoice', - value: 'Progid' - }, - { - root: wrk.ROOT_KEY_CLASSES_ROOT, - path: '.pdf', - value: '' - } - ]; - var progId; - for (let i = 0; !progId && i < tryKeys.length; i++) { - try { - wrk.open( - tryKeys[i].root, - tryKeys[i].path, - wrk.ACCESS_READ - ); - progId = wrk.readStringValue(tryKeys[i].value); - } - catch (e) {} - } - - if (!progId) { - wrk.close(); - return false; - } - - // Get version specific handler, if it exists - try { - wrk.open( - wrk.ROOT_KEY_CLASSES_ROOT, - progId + '\\CurVer', - wrk.ACCESS_READ - ); - progId = wrk.readStringValue('') || progId; - } - catch (e) {} - - // Get command - var success = false; - tryKeys = [ - progId + '\\shell\\Read\\command', - progId + '\\shell\\Open\\command' - ]; - for (let i = 0; !success && i < tryKeys.length; i++) { - try { - wrk.open( - wrk.ROOT_KEY_CLASSES_ROOT, - tryKeys[i], - wrk.ACCESS_READ - ); - success = true; - } - catch (e) {} - } - - if (!success) { - wrk.close(); - return false; - } - - try { - var command = wrk.readStringValue('').match(/^(?:".+?"|[^"]\S+)/); - } - catch (e) {} - - wrk.close(); - - if (!command) return false; - return command[0].replace(/"/g, ''); - }, - - // - // Linux - // - _getPDFHandlerLinux: async function () { - var name = this._getPDFHandlerName(); - switch (name.toLowerCase()) { - case 'okular': - return `/usr/bin/${name}`; - - // It's "Document Viewer" on stock Ubuntu - case 'document viewer': - case 'evince': - return `/usr/bin/evince`; - } - - // TODO: Try to get default from mimeapps.list, etc., in case system default is okular - // or evince somewhere other than /usr/bin - var homeDir = OS.Constants.Path.homeDir; - - return false; - - }, - - _openWithEvinceOrOkular: function (appPath, filePath, page) { - var args = ['-p', page, filePath]; - Zotero.Utilities.Internal.exec(appPath, args); - } -} diff --git a/chrome/content/zotero/xpcom/reader.js b/chrome/content/zotero/xpcom/reader.js index acdecf7297..1f25dda8a3 100644 --- a/chrome/content/zotero/xpcom/reader.js +++ b/chrome/content/zotero/xpcom/reader.js @@ -48,11 +48,9 @@ class ReaderInstance { this._pendingWriteStateTimeout = null; this._pendingWriteStateFunction = null; - switch (this._item.attachmentContentType) { - case 'application/pdf': this._type = 'pdf'; break; - case 'application/epub+zip': this._type = 'epub'; break; - case 'text/html': this._type = 'snapshot'; break; - default: throw new Error('Unsupported attachment type'); + this._type = this._item.attachmentReaderType; + if (!this._type) { + throw new Error('Unsupported attachment type'); } return new Proxy(this, { @@ -93,7 +91,7 @@ class ReaderInstance { } getSecondViewState() { - let state = this._iframeWindow.wrappedJSObject.getSecondViewState(); + let state = this._iframeWindow?.wrappedJSObject?.getSecondViewState?.(); return state ? JSON.parse(JSON.stringify(state)) : undefined; } diff --git a/chrome/content/zotero/xpcom/zotero.js b/chrome/content/zotero/xpcom/zotero.js index 153cdd737a..da507aec77 100644 --- a/chrome/content/zotero/xpcom/zotero.js +++ b/chrome/content/zotero/xpcom/zotero.js @@ -998,6 +998,13 @@ Services.scriptloader.loadSubScript("resource://zotero/polyfill.js"); file.launch(); } catch (e) { + // macOS only: if there's no associated application, launch() will throw, but + // the OS will show a dialog asking the user to choose an application. We don't + // want to show the Firefox dialog in that case. + if (Zotero.isMac && file.exists()) { + return; + } + Zotero.debug(e, 2); Zotero.debug("launch() not supported -- trying fallback executable", 2); @@ -1013,18 +1020,19 @@ Services.scriptloader.loadSubScript("resource://zotero/polyfill.js"); } catch (e) { Zotero.debug(e); - Zotero.debug("Launching via executable failed -- passing to loadUrl()"); + Zotero.debug("Launching via executable failed -- passing to loadURI()"); // If nsIFile.launch() isn't available and the fallback // executable doesn't exist, we just let the Firefox external // helper app window handle it - var nsIFPH = Components.classes["@mozilla.org/network/protocol;1?name=file"] - .getService(Components.interfaces.nsIFileProtocolHandler); - var uri = nsIFPH.newFileURI(file); + var uri = Services.io.newFileURI(file); var nsIEPS = Components.classes["@mozilla.org/uriloader/external-protocol-service;1"]. getService(Components.interfaces.nsIExternalProtocolService); - nsIEPS.loadUrl(uri); + nsIEPS.loadURI( + uri, + Services.scriptSecurityManager.getSystemPrincipal(), + ); } } }; diff --git a/chrome/content/zotero/zoteroPane.js b/chrome/content/zotero/zoteroPane.js index 517c198860..b76c63515b 100644 --- a/chrome/content/zotero/zoteroPane.js +++ b/chrome/content/zotero/zoteroPane.js @@ -4908,64 +4908,15 @@ var ZoteroPane = new function() await item.saveTx(); } - if (['application/pdf', 'application/epub+zip', 'text/html'].includes(contentType)) { - let type; - if (contentType === 'application/pdf') { - type = 'pdf'; - } - else if (contentType === 'application/epub+zip') { - type = 'epub'; - } - else { - type = 'snapshot'; - } - let handler = Zotero.Prefs.get('fileHandler.' + type); - - // Zotero PDF reader - if (!handler) { - let openInWindow = Zotero.Prefs.get('openReaderInNewWindow'); - let useAlternateWindowBehavior = event?.shiftKey || extraData?.forceAlternateWindowBehavior; - if (useAlternateWindowBehavior) { - openInWindow = !openInWindow; - } - await Zotero.Reader.open( - item.id, - extraData && extraData.location, - { - openInWindow, - allowDuplicate: openInWindow - } - ); - return; - } - // Try to open external PDF reader to page number if specified - // TODO: Implement for EPUBs if readers support it - else if (type == 'pdf') { - let pageIndex = extraData?.location?.position?.pageIndex; - if (pageIndex !== undefined) { - await Zotero.OpenPDF.openToPage( - item, - parseInt(pageIndex) + 1 - ); - return; - } - } - // Custom PDF handler - // TODO: Remove this and unify with Zotero.OpenPDF - if (handler != 'system') { - try { - if (await OS.File.exists(handler)) { - Zotero.launchFileWithApplication(path, handler); - return; - } - } - catch (e) { - Zotero.logError(e); - } - Zotero.logError(`${handler} not found -- launching file normally`); - } + let openInWindow = Zotero.Prefs.get('openReaderInNewWindow'); + let useAlternateWindowBehavior = event?.shiftKey || extraData?.forceAlternateWindowBehavior; + if (useAlternateWindowBehavior) { + openInWindow = !openInWindow; } - Zotero.launchFile(path); + await Zotero.FileHandlers.open(item, { + location: extraData?.location, + openInWindow, + }); }; for (let i = 0; i < itemIDs.length; i++) { diff --git a/components/zotero-protocol-handler.js b/components/zotero-protocol-handler.js index 8b61c337a1..fa4cf717d4 100644 --- a/components/zotero-protocol-handler.js +++ b/components/zotero-protocol-handler.js @@ -1135,7 +1135,7 @@ function ZoteroProtocolHandler() { * Also supports ZotFile format: * zotero://open-pdf/[libraryID]_[key]/[page] */ - var OpenPDFExtension = { + var OpenExtension = { noContent: true, doAction: async function (uri) { @@ -1145,8 +1145,7 @@ function ZoteroProtocolHandler() { if (!uriPath) { return 'Invalid URL'; } - uriPath = uriPath.substr('//open-pdf/'.length); - var mimeType, content = ''; + uriPath = uriPath.replace(/^\/\/open(-pdf)?\//, ''); var params = { objectType: 'item' @@ -1174,11 +1173,7 @@ function ZoteroProtocolHandler() { Zotero.API.parseParams(params); var results = await Zotero.API.getResultsFromParams(params); - var page = params.page; - if (parseInt(page) != page) { - page = null; - } - var annotation = params.annotation; + var { annotation, page, cfi, sel } = params; if (!results.length) { Zotero.warn(`No item found for ${uriPath}`); @@ -1198,32 +1193,43 @@ function ZoteroProtocolHandler() { return; } - if (!path.toLowerCase().endsWith('.pdf') - && Zotero.MIME.sniffForMIMEType(await Zotero.File.getSample(path)) != 'application/pdf') { - Zotero.warn(`${path} is not a PDF`); - return; + try { + if (page) { + await Zotero.FileHandlers.open(item, { + location: { + position: { + pageIndex: page + }, + annotationID: annotation + } + }); + } + else if (cfi) { + await Zotero.FileHandlers.open(item, { + location: { + position: { + type: 'FragmentSelector', + conformsTo: 'http://www.idpf.org/epub/linking/cfi/epub-cfi.html', + value: cfi + } + } + }); + } + else if (sel) { + await Zotero.FileHandlers.open(item, { + location: { + position: { + type: 'CssSelector', + value: sel + } + } + }); + } + } + catch (e) { + Zotero.logError(e); } - var opened = false; - if (page || annotation) { - try { - opened = await Zotero.OpenPDF.openToPage(item, page, annotation); - } - catch (e) { - Zotero.logError(e); - } - } - - // If something went wrong, just open PDF without page - if (!opened) { - Zotero.debug("Launching PDF without page number"); - let zp = Zotero.getActiveZoteroPane(); - // TODO: Open pane if closed (macOS) - if (zp) { - zp.viewAttachment([item.id]); - } - return; - } Zotero.Notifier.trigger('open', 'file', item.id); }, @@ -1241,7 +1247,8 @@ function ZoteroProtocolHandler() { this._extensions[ZOTERO_SCHEME + "://debug"] = DebugExtension; this._extensions[ZOTERO_SCHEME + "://connector"] = ConnectorExtension; this._extensions[ZOTERO_SCHEME + "://pdf.js"] = PDFJSExtension; - this._extensions[ZOTERO_SCHEME + "://open-pdf"] = OpenPDFExtension; + this._extensions[ZOTERO_SCHEME + "://open"] = OpenExtension; + this._extensions[ZOTERO_SCHEME + "://open-pdf"] = OpenExtension; } diff --git a/components/zotero-service.js b/components/zotero-service.js index cf24c15591..f7b1f25aa2 100644 --- a/components/zotero-service.js +++ b/components/zotero-service.js @@ -113,7 +113,7 @@ const xpcomFilesLocal = [ 'locateManager', 'mime', 'notifier', - 'openPDF', + 'fileHandlers', 'plugins', 'reader', 'progressQueue', diff --git a/test/tests/fileHandlersTest.js b/test/tests/fileHandlersTest.js new file mode 100644 index 0000000000..0db8b8d056 --- /dev/null +++ b/test/tests/fileHandlersTest.js @@ -0,0 +1,105 @@ +describe("Zotero.FileHandlers", () => { + describe("open()", () => { + var win; + + function clearPrefs() { + Zotero.Prefs.clear('fileHandler.pdf'); + Zotero.Prefs.clear('fileHandler.epub'); + Zotero.Prefs.clear('fileHandler.snapshot'); + Zotero.Prefs.clear('openReaderInNewWindow'); + } + + before(async function () { + clearPrefs(); + win = await loadZoteroPane(); + }); + + afterEach(function () { + clearPrefs(); + delete Zotero.FileHandlers._mockHandlers; + for (let reader of Zotero.Reader._readers) { + reader.close(); + } + }); + + after(async function () { + win.close(); + }); + + it("should open a PDF internally when no handler is set", async function () { + let pdf = await importFileAttachment('wonderland_short.pdf'); + await Zotero.FileHandlers.open(pdf, { + location: { position: { pageIndex: 2 } } + }); + assert.ok(Zotero.Reader.getByTabID(win.Zotero_Tabs.selectedID)); + }); + + it("should open a PDF in a new window when no handler is set and openInWindow is passed", async function () { + let pdf = await importFileAttachment('wonderland_short.pdf'); + await Zotero.FileHandlers.open(pdf, { + location: { position: { pageIndex: 2 } }, + openInWindow: true + }); + assert.notOk(Zotero.Reader.getByTabID(win.Zotero_Tabs.selectedID)); + assert.isNotEmpty(Zotero.Reader.getWindowStates()); + }); + + it("should use matching handler", async function () { + let pdf = await importFileAttachment('wonderland_short.pdf'); + let wasRun = false; + let readerOpenSpy = sinon.spy(Zotero.Reader, 'open'); + Zotero.FileHandlers._mockHandlers = { + pdf: [ + { + name: /mock/, + async open() { + wasRun = true; + } + } + ] + }; + Zotero.Prefs.set('fileHandler.pdf', 'mock'); + + await Zotero.FileHandlers.open(pdf); + assert.isTrue(wasRun); + assert.isFalse(readerOpenSpy.called); + assert.notOk(Zotero.Reader.getByTabID(win.Zotero_Tabs.selectedID)); + assert.isEmpty(Zotero.Reader.getWindowStates()); + + readerOpenSpy.restore(); + }); + + it("should fall back to fallback handler when location is passed", async function () { + let pdf = await importFileAttachment('wonderland_short.pdf'); + let wasRun = false; + let readerOpenSpy = sinon.spy(Zotero.Reader, 'open'); + Zotero.FileHandlers._mockHandlers = { + pdf: [ + { + name: /mock/, + fallback: true, + async open(appPath) { + assert.notOk(appPath); // appPath won't be set when called as fallback + wasRun = true; + } + } + ] + }; + + // Set our custom handler to something nonexistent, + // and stub the system handler to something nonexistent as well + Zotero.Prefs.set('fileHandler.pdf', 'some nonexistent tool'); + let getSystemHandlerStub = sinon.stub(Zotero.FileHandlers, '_getSystemHandler'); + getSystemHandlerStub.returns('some other nonexistent tool'); + + await Zotero.FileHandlers.open(pdf, { location: {} }); + assert.isTrue(wasRun); + assert.isFalse(readerOpenSpy.called); + assert.notOk(Zotero.Reader.getByTabID(win.Zotero_Tabs.selectedID)); + assert.isEmpty(Zotero.Reader.getWindowStates()); + + readerOpenSpy.restore(); + getSystemHandlerStub.restore(); + }); + }); +});