zotero-better-notes/src/zotero-protocol-handler.js

1688 lines
51 KiB
JavaScript

/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2009 Center for History and New Media
George Mason University, Fairfax, Virginia, USA
http://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 <http://www.gnu.org/licenses/>.
Based on nsChromeExtensionHandler example code by Ed Anuff at
http://kb.mozillazine.org/Dev_:_Extending_the_Chrome_Protocol
***** END LICENSE BLOCK *****
*/
/*
This file is modified based on the Zotero zotero-protocol-handler.js.
Keep in sync with official file.
*/
const ZOTERO_SCHEME = "zotero";
const ZOTERO_PROTOCOL_CID = Components.ID(
"{9BC3D762-9038-486A-9D70-C997AF848A7D}"
);
const ZOTERO_PROTOCOL_CONTRACTID =
"@mozilla.org/network/protocol;1?name=" + ZOTERO_SCHEME;
const ZOTERO_PROTOCOL_NAME = "Zotero Chrome Extension Protocol";
Components.utils.import("resource://gre/modules/Services.jsm");
Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
Components.utils.import("resource://gre/modules/NetUtil.jsm");
Components.utils.import("resource://gre/modules/osfile.jsm");
const Cc = Components.classes;
const Ci = Components.interfaces;
const Cr = Components.results;
const ios = Services.io;
// Dummy chrome URL used to obtain a valid chrome channel
const DUMMY_CHROME_URL = "chrome://zotero/content/zoteroPane.xul";
var Zotero = Components.classes["@zotero.org/Zotero;1"].getService(
Components.interfaces.nsISupports
).wrappedJSObject;
function ZoteroProtocolHandler() {
this.wrappedJSObject = this;
this._principal = null;
this._extensions = {};
/**
* zotero://attachment/library/[itemKey]
* zotero://attachment/groups/[groupID]/[itemKey]
*/
var AttachmentExtension = {
loadAsChrome: false,
newChannel: function (uri) {
return new AsyncChannel(
uri,
function* () {
try {
var uriPath = uri.pathQueryRef;
if (!uriPath) {
return this._errorChannel("Invalid URL");
}
uriPath = uriPath.substr("//attachment/".length);
var params = {};
var router = new Zotero.Router(params);
router.add("library/items/:itemKey", function () {
params.libraryID = Zotero.Libraries.userLibraryID;
});
router.add("groups/:groupID/items/:itemKey");
router.run(uriPath);
if (params.groupID) {
params.libraryID = Zotero.Groups.getLibraryIDFromGroupID(
params.groupID
);
}
if (!params.itemKey) {
return this._errorChannel("Item key not provided");
}
var item = yield Zotero.Items.getByLibraryAndKeyAsync(
params.libraryID,
params.itemKey
);
if (!item) {
return this._errorChannel(`No item found for ${uriPath}`);
}
if (!item.isFileAttachment()) {
return this._errorChannel(
`Item for ${uriPath} is not a file attachment`
);
}
var path = yield item.getFilePathAsync();
if (!path) {
return this._errorChannel(`${path} not found`);
}
// Set originalURI so that it seems like we're serving from zotero:// protocol.
// This is necessary to allow url() links to work from within CSS files.
// Otherwise they try to link to files on the file:// protocol, which isn't allowed.
this.originalURI = uri;
return Zotero.File.pathToFile(path);
} catch (e) {
return this._errorChannel(e.message);
}
}.bind(this)
);
},
_errorChannel: function (msg) {
Zotero.logError(msg);
this.status = Components.results.NS_ERROR_FAILURE;
this.contentType = "text/plain";
return msg;
},
};
/**
* zotero://data/library/collection/ABCD1234/items?sort=itemType&direction=desc
* zotero://data/groups/12345/collection/ABCD1234/items?sort=title&direction=asc
*/
var DataExtension = {
loadAsChrome: false,
newChannel: function (uri) {
return new AsyncChannel(uri, function* () {
this.contentType = "text/plain";
path = uri.spec.match(/zotero:\/\/[^/]+(.*)/)[1];
try {
return Zotero.Utilities.Internal.getAsyncInputStream(
Zotero.API.Data.getGenerator(path)
);
} catch (e) {
if (e instanceof Zotero.Router.InvalidPathException) {
return "URL could not be parsed";
}
}
});
},
};
/*
* Report generation extension for Zotero protocol
*/
var ReportExtension = {
loadAsChrome: false,
newChannel: function (uri) {
return new AsyncChannel(uri, function* () {
var userLibraryID = Zotero.Libraries.userLibraryID;
var path = uri.pathQueryRef;
if (!path) {
return "Invalid URL";
}
path = path.substr("//report/".length);
// Proxy CSS files
if (path.endsWith(".css")) {
var chromeURL = "chrome://zotero/skin/report/" + path;
Zotero.debug(chromeURL);
let uri = ios.newURI(chromeURL, null, null);
var chromeReg = Components.classes[
"@mozilla.org/chrome/chrome-registry;1"
].getService(Components.interfaces.nsIChromeRegistry);
return chromeReg.convertChromeURL(uri);
}
var params = {
objectType: "item",
format: "html",
sort: "title",
};
var router = new Zotero.Router(params);
// Items within a collection or search
router.add("library/:scopeObject/:scopeObjectKey/items", function () {
params.libraryID = userLibraryID;
});
router.add("groups/:groupID/:scopeObject/:scopeObjectKey/items");
// All items
router.add("library/items/:objectKey", function () {
params.libraryID = userLibraryID;
});
router.add("groups/:groupID/items");
// Old-style URLs
router.add("collection/:id/html/report.html", function () {
params.scopeObject = "collections";
var lkh = Zotero.Collections.parseLibraryKeyHash(params.id);
if (lkh) {
params.libraryID = lkh.libraryID || userLibraryID;
params.scopeObjectKey = lkh.key;
} else {
params.scopeObjectID = params.id;
}
delete params.id;
});
router.add("search/:id/html/report.html", function () {
params.scopeObject = "searches";
var lkh = Zotero.Searches.parseLibraryKeyHash(this.id);
if (lkh) {
params.libraryID = lkh.libraryID || userLibraryID;
params.scopeObjectKey = lkh.key;
} else {
params.scopeObjectID = this.id;
}
delete params.id;
});
router.add("items/:ids/html/report.html", function () {
var ids = this.ids.split("-");
params.libraryID = ids[0].split("_")[0] || userLibraryID;
params.itemKey = ids.map((x) => x.split("_")[1]);
delete params.ids;
});
var parsed = router.run(path);
if (!parsed) {
return "URL could not be parsed";
}
// TODO: support old URLs
// collection
// search
// items
// item
if (params.sort.indexOf("/") != -1) {
let parts = params.sort.split("/");
params.sort = parts[0];
params.direction = parts[1] == "d" ? "desc" : "asc";
}
try {
Zotero.API.parseParams(params);
var results = yield Zotero.API.getResultsFromParams(params);
} catch (e) {
Zotero.debug(e, 1);
return e.toString();
}
var mimeType,
content = "";
var items = [];
var itemsHash = {}; // key = itemID, val = position in |items|
var searchItemIDs = new Set(); // All selected items
var searchParentIDs = new Set(); // Parents of selected child items
var searchChildIDs = new Set(); // Selected chlid items
var includeAllChildItems = Zotero.Prefs.get(
"report.includeAllChildItems"
);
var combineChildItems = Zotero.Prefs.get("report.combineChildItems");
var unhandledParents = {};
for (var i = 0; i < results.length; i++) {
// Don't add child items directly
// (instead mark their parents for inclusion below)
var parentItemID = results[i].parentItemID;
if (parentItemID) {
searchParentIDs.add(parentItemID);
searchChildIDs.add(results[i].id);
// Don't include all child items if any child
// items were selected
includeAllChildItems = false;
}
// If combining children or standalone note/attachment, add matching parents
else if (
combineChildItems ||
!results[i].isRegularItem() ||
results[i].numChildren() == 0
) {
itemsHash[results[i].id] = [items.length];
items.push(results[i].toJSON({ mode: "full" }));
// Flag item as a search match
items[items.length - 1].reportSearchMatch = true;
} else {
unhandledParents[i] = true;
}
searchItemIDs.add(results[i].id);
}
// If including all child items, add children of all matched
// parents to the child array
if (includeAllChildItems) {
for (let id of searchItemIDs) {
if (!searchChildIDs.has(id)) {
var children = [];
var item = yield Zotero.Items.getAsync(id);
if (!item.isRegularItem()) {
continue;
}
var func = function (ids) {
if (ids) {
for (var i = 0; i < ids.length; i++) {
searchChildIDs.add(ids[i]);
}
}
};
func(item.getNotes());
func(item.getAttachments());
}
}
}
// If not including all children, add matching parents,
// in case they don't have any matching children below
else {
for (var i in unhandledParents) {
itemsHash[results[i].id] = [items.length];
items.push(results[i].toJSON({ mode: "full" }));
// Flag item as a search match
items[items.length - 1].reportSearchMatch = true;
}
}
if (combineChildItems) {
// Add parents of matches if parents aren't matches themselves
for (let id of searchParentIDs) {
if (!searchItemIDs.has(id) && !itemsHash[id]) {
var item = yield Zotero.Items.getAsync(id);
itemsHash[id] = items.length;
items.push(item.toJSON({ mode: "full" }));
}
}
// Add children to reportChildren property of parents
for (let id of searchChildIDs) {
let item = yield Zotero.Items.getAsync(id);
var parentID = item.parentID;
if (!items[itemsHash[parentID]].reportChildren) {
items[itemsHash[parentID]].reportChildren = {
notes: [],
attachments: [],
};
}
if (item.isNote()) {
items[itemsHash[parentID]].reportChildren.notes.push(
item.toJSON({ mode: "full" })
);
}
if (item.isAttachment()) {
items[itemsHash[parentID]].reportChildren.attachments.push(
item.toJSON({ mode: "full" })
);
}
}
}
// If not combining children, add a parent/child pair
// for each matching child
else {
for (let id of searchChildIDs) {
var item = yield Zotero.Items.getAsync(id);
var parentID = item.parentID;
var parentItem = Zotero.Items.get(parentID);
if (!itemsHash[parentID]) {
// If parent is a search match and not yet added,
// add on its own
if (searchItemIDs.has(parentID)) {
itemsHash[parentID] = [items.length];
items.push(parentItem.toJSON({ mode: "full" }));
items[items.length - 1].reportSearchMatch = true;
} else {
itemsHash[parentID] = [];
}
}
// Now add parent and child
itemsHash[parentID].push(items.length);
items.push(parentItem.toJSON({ mode: "full" }));
if (item.isNote()) {
items[items.length - 1].reportChildren = {
notes: [item.toJSON({ mode: "full" })],
attachments: [],
};
} else if (item.isAttachment()) {
items[items.length - 1].reportChildren = {
notes: [],
attachments: [item.toJSON({ mode: "full" })],
};
}
}
}
// Sort items
// TODO: restore multiple sort fields
var sorts = [
{
field: params.sort,
order: params.direction != "desc" ? 1 : -1,
},
];
var collation = Zotero.getLocaleCollation();
var compareFunction = function (a, b) {
var index = 0;
// Multidimensional sort
do {
// In combineChildItems, use note or attachment as item
if (!combineChildItems) {
if (a.reportChildren) {
if (a.reportChildren.notes.length) {
a = a.reportChildren.notes[0];
} else {
a = a.reportChildren.attachments[0];
}
}
if (b.reportChildren) {
if (b.reportChildren.notes.length) {
b = b.reportChildren.notes[0];
} else {
b = b.reportChildren.attachments[0];
}
}
}
var valA, valB;
if (sorts[index].field == "title") {
// For notes, use content for 'title'
if (a.itemType == "note") {
valA = a.note;
} else {
valA = a.title;
}
if (b.itemType == "note") {
valB = b.note;
} else {
valB = b.title;
}
valA = Zotero.Items.getSortTitle(valA);
valB = Zotero.Items.getSortTitle(valB);
} else if (sorts[index].field == "date") {
var itemA = Zotero.Items.getByLibraryAndKey(
params.libraryID,
a.key
);
var itemB = Zotero.Items.getByLibraryAndKey(
params.libraryID,
b.key
);
valA = itemA.getField("date", true, true);
valB = itemB.getField("date", true, true);
}
// TEMP: This is an ugly hack to make creator sorting
// slightly less broken. To do this right, real creator
// sorting needs to be abstracted from itemTreeView.js.
else if (sorts[index].field == "firstCreator") {
var itemA = Zotero.Items.getByLibraryAndKey(
params.libraryID,
a.key
);
var itemB = Zotero.Items.getByLibraryAndKey(
params.libraryID,
b.key
);
valA = itemA.getField("firstCreator");
valB = itemB.getField("firstCreator");
} else {
valA = a[sorts[index].field];
valB = b[sorts[index].field];
}
// Put empty values last
if (!valA && valB) {
var cmp = 1;
} else if (valA && !valB) {
var cmp = -1;
} else {
var cmp = collation.compareString(0, valA, valB);
}
var result = 0;
if (cmp != 0) {
result = cmp * sorts[index].order;
}
index++;
} while (result == 0 && sorts[index]);
return result;
};
items.sort(compareFunction);
for (var i in items) {
if (items[i].reportChildren) {
items[i].reportChildren.notes.sort(compareFunction);
items[i].reportChildren.attachments.sort(compareFunction);
}
}
// Pass off to the appropriate handler
switch (params.format) {
case "rtf":
this.contentType = "text/rtf";
return "";
case "csv":
this.contentType = "text/plain";
return "";
default:
this.contentType = "text/html";
return Zotero.Utilities.Internal.getAsyncInputStream(
Zotero.Report.HTML.listGenerator(
items,
combineChildItems,
params.libraryID
),
function () {
Zotero.logError(e);
return '<span style="color: red; font-weight: bold">Error generating report</span>';
}
);
}
});
},
};
/**
* Generate MIT SIMILE Timeline
*
* Query string key abbreviations: intervals = i
* dateType = t
* timelineDate = d
*
* interval abbreviations: day = d | month = m | year = y | decade = e | century = c | millennium = i
* dateType abbreviations: date = d | dateAdded = da | dateModified = dm
* timelineDate format: shortMonthName.day.year (year is positive for A.D. and negative for B.C.)
*
* Defaults: intervals = month, year, decade
* dateType = date
* timelineDate = today's date
*/
var TimelineExtension = {
loadAsChrome: true,
newChannel: function (uri) {
return new AsyncChannel(uri, function* () {
var userLibraryID = Zotero.Libraries.userLibraryID;
var path = uri.spec.match(/zotero:\/\/[^/]+(.*)/)[1];
if (!path) {
this.contentType = "text/html";
return "Invalid URL";
}
var params = {};
var router = new Zotero.Router(params);
// HTML
router.add("library/:scopeObject/:scopeObjectKey", function () {
params.libraryID = userLibraryID;
params.controller = "html";
});
router.add("groups/:groupID/:scopeObject/:scopeObjectKey", function () {
params.controller = "html";
});
router.add("library", function () {
params.libraryID = userLibraryID;
params.controller = "html";
});
router.add("groups/:groupID", function () {
params.controller = "html";
});
// Data
router.add("data/library/:scopeObject/:scopeObjectKey", function () {
params.libraryID = userLibraryID;
params.controller = "data";
});
router.add(
"data/groups/:groupID/:scopeObject/:scopeObjectKey",
function () {
params.controller = "data";
}
);
router.add("data/library", function () {
params.libraryID = userLibraryID;
params.controller = "data";
});
router.add("data/groups/:groupID", function () {
params.controller = "data";
});
// Old-style HTML URLs
router.add("collection/:id", function () {
params.controller = "html";
params.scopeObject = "collections";
var lkh = Zotero.Collections.parseLibraryKeyHash(params.id);
if (lkh) {
params.libraryID = lkh.libraryID || userLibraryID;
params.scopeObjectKey = lkh.key;
} else {
params.scopeObjectID = params.id;
}
delete params.id;
});
router.add("search/:id", function () {
params.controller = "html";
params.scopeObject = "searches";
var lkh = Zotero.Searches.parseLibraryKeyHash(params.id);
if (lkh) {
params.libraryID = lkh.libraryID || userLibraryID;
params.scopeObjectKey = lkh.key;
} else {
params.scopeObjectID = params.id;
}
delete params.id;
});
router.add("/", function () {
params.controller = "html";
params.libraryID = userLibraryID;
});
var parsed = router.run(path);
if (!parsed) {
this.contentType = "text/html";
return "URL could not be parsed";
}
if (params.groupID) {
params.libraryID = Zotero.Groups.getLibraryIDFromGroupID(
params.groupID
);
}
var intervals = params.i ? params.i : "";
var timelineDate = params.d ? params.d : "";
var dateType = params.t ? params.t : "";
// Get the collection or search object
var collection, search;
switch (params.scopeObject) {
case "collections":
if (params.scopeObjectKey) {
collection = yield Zotero.Collections.getByLibraryAndKeyAsync(
params.libraryID,
params.scopeObjectKey
);
} else {
collection = yield Zotero.Collections.getAsync(
params.scopeObjectID
);
}
if (!collection) {
this.contentType = "text/html";
return "Invalid collection ID or key";
}
break;
case "searches":
if (params.scopeObjectKey) {
var s = yield Zotero.Searches.getByLibraryAndKeyAsync(
params.libraryID,
params.scopeObjectKey
);
} else {
var s = yield Zotero.Searches.getAsync(params.scopeObjectID);
}
if (!s) {
return "Invalid search ID or key";
}
// FIXME: Hack to exclude group libraries for now
var search = new Zotero.Search();
search.setScope(s);
var groups = Zotero.Groups.getAll();
for (let group of groups) {
search.addCondition("libraryID", "isNot", group.libraryID);
}
break;
}
//
// Create XML file
//
if (params.controller == "data") {
switch (params.scopeObject) {
case "collections":
var results = collection.getChildItems();
break;
case "searches":
var ids = yield search.search();
var results = yield Zotero.Items.getAsync(ids);
break;
default:
if (params.scopeObject) {
return "Invalid scope object '" + params.scopeObject + "'";
}
let s = new Zotero.Search();
s.addCondition("libraryID", "is", params.libraryID);
s.addCondition("noChildren", "true");
var ids = yield s.search();
var results = yield Zotero.Items.getAsync(ids);
}
var items = [];
// Only include parent items
for (let i = 0; i < results.length; i++) {
if (!results[i].parentItemID) {
items.push(results[i]);
}
}
var dateTypes = {
d: "date",
da: "dateAdded",
dm: "dateModified",
};
//default dateType = date
if (!dateType || !dateTypes[dateType]) {
dateType = "d";
}
this.contentType = "application/xml";
return Zotero.Utilities.Internal.getAsyncInputStream(
Zotero.Timeline.generateXMLDetails(items, dateTypes[dateType])
);
}
//
// Generate main HTML page
//
var content = Zotero.File.getContentsFromURL(
"chrome://zotero/skin/timeline/timeline.html"
);
this.contentType = "text/html";
if (!timelineDate) {
timelineDate = Date();
var dateParts = timelineDate.toString().split(" ");
timelineDate = dateParts[1] + "." + dateParts[2] + "." + dateParts[3];
}
if (!intervals || intervals.length < 3) {
intervals += "mye".substr(intervals.length);
}
var theIntervals = {
d: "Timeline.DateTime.DAY",
m: "Timeline.DateTime.MONTH",
y: "Timeline.DateTime.YEAR",
e: "Timeline.DateTime.DECADE",
c: "Timeline.DateTime.CENTURY",
i: "Timeline.DateTime.MILLENNIUM",
};
//sets the intervals of the timeline bands
var tempStr = '<body onload="onLoad(';
var a = theIntervals[intervals[0]]
? theIntervals[intervals[0]]
: "Timeline.DateTime.MONTH";
var b = theIntervals[intervals[1]]
? theIntervals[intervals[1]]
: "Timeline.DateTime.YEAR";
var c = theIntervals[intervals[2]]
? theIntervals[intervals[2]]
: "Timeline.DateTime.DECADE";
content = content.replace(
tempStr,
tempStr + a + "," + b + "," + c + ",'" + timelineDate + "'"
);
tempStr = 'document.write("<title>';
if (params.scopeObject == "collections") {
content = content.replace(tempStr, tempStr + collection.name + " - ");
} else if (params.scopeObject == "searches") {
content = content.replace(tempStr, tempStr + search.name + " - ");
} else {
content = content.replace(
tempStr,
tempStr + Zotero.getString("pane.collections.library") + " - "
);
}
tempStr = 'Timeline.loadXML("zotero://timeline/data/';
var d = "";
if (params.groupID) {
d += "groups/" + params.groupID + "/";
} else {
d += "library/";
}
if (params.scopeObject) {
d += params.scopeObject + "/" + params.scopeObjectKey;
}
if (dateType) {
d += "?t=" + dateType;
}
return content.replace(tempStr, tempStr + d);
});
},
};
/**
* Select an item
*
* zotero://select/library/items/[itemKey]
* zotero://select/groups/[groupID]/items/[itemKey]
*
* Deprecated:
*
* zotero://select/[type]/0_ABCD1234
* zotero://select/[type]/1234 (not consistent across synced machines)
*/
var SelectExtension = {
noContent: true,
doAction: Zotero.Promise.coroutine(function* (uri) {
var userLibraryID = Zotero.Libraries.userLibraryID;
var path = uri.pathQueryRef;
if (!path) {
return "Invalid URL";
}
path = path.substr("//select/".length);
var mimeType,
content = "";
var params = {
objectType: "item",
};
var router = new Zotero.Router(params);
// Item within a collection or search
router.add(
"library/:scopeObject/:scopeObjectKey/items/:objectKey",
function () {
params.libraryID = userLibraryID;
}
);
router.add(
"groups/:groupID/:scopeObject/:scopeObjectKey/items/:objectKey"
);
// All items
router.add("library/items/:objectKey", function () {
params.libraryID = userLibraryID;
});
router.add("groups/:groupID/items/:objectKey");
// Old-style URLs
router.add("items/:id", function () {
var lkh = Zotero.Items.parseLibraryKeyHash(params.id);
if (lkh) {
params.libraryID = lkh.libraryID || userLibraryID;
params.objectKey = lkh.key;
} else {
params.objectID = params.id;
}
delete params.id;
});
// Collection
router.add("library/collections/:objectKey", function () {
params.objectType = "collection";
params.libraryID = userLibraryID;
});
router.add("groups/:groupID/collections/:objectKey", function () {
params.objectType = "collection";
});
// Search
router.add("library/searches/:objectKey", function () {
params.objectType = "search";
params.libraryID = userLibraryID;
});
router.add("groups/:groupID/searches/:objectKey", function () {
params.objectType = "search";
});
router.run(path);
Zotero.API.parseParams(params);
if (!params.objectKey && !params.objectID && !params.itemKey) {
Zotero.debug("No objects specified");
return;
}
var results = yield Zotero.API.getResultsFromParams(params);
if (!results.length) {
var msg = "Objects not found";
Zotero.debug(msg, 2);
Components.utils.reportError(msg);
return;
}
var zp = Zotero.getActiveZoteroPane();
if (!zp) {
// TEMP
throw new Error("Pane not open");
}
if (params.objectType == "collection") {
return zp.collectionsView.selectCollection(results[0].id);
} else if (params.objectType == "search") {
return zp.collectionsView.selectSearch(results[0].id);
} else {
// Select collection first if specified
if (params.scopeObject == "collections") {
let col;
if (params.scopeObjectKey) {
col = Zotero.Collections.getByLibraryAndKey(
params.libraryID,
params.scopeObjectKey
);
} else {
col = Zotero.Collections.get(params.scopeObjectID);
}
yield zp.collectionsView.selectCollection(col.id);
} else if (params.scopeObject == "searches") {
let s;
if (params.scopeObjectKey) {
s = Zotero.Searches.getByLibraryAndKey(
params.libraryID,
params.scopeObjectKey
);
} else {
s = Zotero.Searches.get(params.scopeObjectID);
}
yield zp.collectionsView.selectSearch(s.id);
}
// If collection not specified, select library root
else {
yield zp.collectionsView.selectLibrary(params.libraryID);
}
return zp.selectItems(results.map((x) => x.id));
}
}),
newChannel: function (uri) {
this.doAction(uri);
},
};
/*
zotero://debug/
*/
var DebugExtension = {
loadAsChrome: false,
newChannel: function (uri) {
return new AsyncChannel(uri, function* () {
this.contentType = "text/plain";
try {
return Zotero.Debug.get();
} catch (e) {
Zotero.debug(e, 1);
throw e;
}
});
},
};
var ConnectorChannel = function (uri, data) {
var secMan = Components.classes[
"@mozilla.org/scriptsecuritymanager;1"
].getService(Components.interfaces.nsIScriptSecurityManager);
this.name = uri;
this.URI = ios.newURI(uri, "UTF-8", null);
this.owner = (
secMan.getCodebasePrincipal || secMan.getSimpleCodebasePrincipal
)(this.URI);
this._isPending = true;
var converter = Components.classes[
"@mozilla.org/intl/scriptableunicodeconverter"
].createInstance(Components.interfaces.nsIScriptableUnicodeConverter);
converter.charset = "UTF-8";
this._stream = converter.convertToInputStream(data);
this.contentLength = this._stream.available();
};
ConnectorChannel.prototype.contentCharset = "UTF-8";
ConnectorChannel.prototype.contentType = "text/html";
ConnectorChannel.prototype.notificationCallbacks = null;
ConnectorChannel.prototype.securityInfo = null;
ConnectorChannel.prototype.status = 0;
ConnectorChannel.prototype.loadGroup = null;
ConnectorChannel.prototype.loadFlags = 393216;
ConnectorChannel.prototype.__defineGetter__("originalURI", function () {
return this.URI;
});
ConnectorChannel.prototype.__defineSetter__("originalURI", function () {});
ConnectorChannel.prototype.asyncOpen = function (streamListener, context) {
if (this.loadGroup) this.loadGroup.addRequest(this, null);
streamListener.onStartRequest(this, context);
streamListener.onDataAvailable(
this,
context,
this._stream,
0,
this.contentLength
);
streamListener.onStopRequest(this, context, this.status);
this._isPending = false;
if (this.loadGroup) this.loadGroup.removeRequest(this, null, 0);
};
ConnectorChannel.prototype.isPending = function () {
return this._isPending;
};
ConnectorChannel.prototype.cancel = function (status) {
this.status = status;
this._isPending = false;
if (this._stream) this._stream.close();
};
ConnectorChannel.prototype.suspend = function () {};
ConnectorChannel.prototype.resume = function () {};
ConnectorChannel.prototype.open = function () {
return this._stream;
};
ConnectorChannel.prototype.QueryInterface = function (iid) {
if (
!iid.equals(Components.interfaces.nsIChannel) &&
!iid.equals(Components.interfaces.nsIRequest) &&
!iid.equals(Components.interfaces.nsISupports)
) {
throw Components.results.NS_ERROR_NO_INTERFACE;
}
return this;
};
/**
* zotero://connector/
*
* URI spoofing for transferring page data across boundaries
*/
var ConnectorExtension = new (function () {
this.loadAsChrome = false;
this.newChannel = function (uri) {
var secMan = Components.classes[
"@mozilla.org/scriptsecuritymanager;1"
].getService(Components.interfaces.nsIScriptSecurityManager);
var Zotero = Components.classes["@zotero.org/Zotero;1"].getService(
Components.interfaces.nsISupports
).wrappedJSObject;
try {
var originalURI = uri.pathQueryRef.substr("zotero://connector/".length);
originalURI = decodeURIComponent(originalURI);
if (!Zotero.Server.Connector.Data[originalURI]) {
return null;
} else {
return new ConnectorChannel(
originalURI,
Zotero.Server.Connector.Data[originalURI]
);
}
} catch (e) {
Zotero.debug(e);
throw e;
}
};
})();
/*
zotero://pdf.js/viewer.html
zotero://pdf.js/pdf/1/ABCD5678
*/
var PDFJSExtension = {
loadAsChrome: true,
newChannel: function (uri) {
return new AsyncChannel(
uri,
function* () {
try {
uri = uri.spec;
// Proxy PDF.js files
if (
uri.startsWith("zotero://pdf.js/") &&
!uri.startsWith("zotero://pdf.js/pdf/")
) {
uri = uri.replace(
/zotero:\/\/pdf.js\//,
"resource://zotero/pdf.js/"
);
let newURI = Services.io.newURI(uri, null, null);
return this.getURIInputStream(newURI);
}
// Proxy attachment PDFs
var pdfPrefix = "zotero://pdf.js/pdf/";
if (!uri.startsWith(pdfPrefix)) {
return this._errorChannel("File not found");
}
var [libraryID, key] = uri.substr(pdfPrefix.length).split("/");
libraryID = parseInt(libraryID);
var item = yield Zotero.Items.getByLibraryAndKeyAsync(
libraryID,
key
);
if (!item) {
return this._errorChannel("Item not found");
}
var path = yield item.getFilePathAsync();
if (!path) {
return this._errorChannel("File not found");
}
return this.getURIInputStream(OS.Path.toFileURI(path));
} catch (e) {
Zotero.debug(e, 1);
throw e;
}
}.bind(this)
);
},
getURIInputStream: function (uri) {
return new Zotero.Promise((resolve, reject) => {
NetUtil.asyncFetch(uri, function (inputStream, result) {
if (!Components.isSuccessCode(result)) {
// TODO: Handle error
return;
}
resolve(inputStream);
});
});
},
_errorChannel: function (msg) {
this.status = Components.results.NS_ERROR_FAILURE;
this.contentType = "text/plain";
return msg;
},
};
/**
* Open a PDF at a given page (or try to)
*
* zotero://open-pdf/library/items/[itemKey]?page=[page]
* zotero://open-pdf/groups/[groupID]/items/[itemKey]?page=[page]
*
* Also supports ZotFile format:
* zotero://open-pdf/[libraryID]_[key]/[page]
*/
var OpenPDFExtension = {
noContent: true,
doAction: async function (uri) {
var userLibraryID = Zotero.Libraries.userLibraryID;
var uriPath = uri.pathQueryRef;
if (!uriPath) {
return "Invalid URL";
}
uriPath = uriPath.substr("//open-pdf/".length);
var mimeType,
content = "";
var params = {
objectType: "item",
};
var router = new Zotero.Router(params);
// All items
router.add("library/items/:objectKey", function () {
params.libraryID = userLibraryID;
});
router.add("groups/:groupID/items/:objectKey");
// ZotFile URLs
router.add(":id/:page", function () {
var lkh = Zotero.Items.parseLibraryKeyHash(params.id);
if (!lkh) {
Zotero.warn(`Invalid URL ${url}`);
return;
}
params.libraryID = lkh.libraryID || userLibraryID;
params.objectKey = lkh.key;
delete params.id;
});
router.run(uriPath);
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;
if (!results.length) {
Zotero.warn(`No item found for ${uriPath}`);
return;
}
var item = results[0];
if (!item.isFileAttachment()) {
Zotero.warn(`Item for ${uriPath} is not a file attachment`);
return;
}
var path = await item.getFilePathAsync();
if (!path) {
Zotero.warn(`${path} not found`);
return;
}
if (
!path.toLowerCase().endsWith(".pdf") &&
Zotero.MIME.sniffForMIMEType(await Zotero.File.getSample(path)) !=
"application/pdf"
) {
Zotero.warn(`${path} is not a PDF`);
return;
}
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);
},
newChannel: function (uri) {
this.doAction(uri);
},
};
var openNoteExtension = {
noContent: true,
doAction: async function (uri) {
let message = {
type: "onNoteLink",
content: {
params: {
item: false,
infoText: "",
},
},
};
let [groupID, noteKey] = uri.spec
.substring("zotero://note/".length)
.split("/");
// User libraryID by defaultx
let libraryID = 1;
if (groupID !== "u") {
// Not a user item
groupID = parseInt(groupID);
libraryID = Zotero.Groups.getLibraryIDFromGroupID(groupID);
}
if (!libraryID) {
message.content.params.infoText =
"Library does not exist or access denied.";
} else {
let item = await Zotero.Items.getByLibraryAndKeyAsync(
libraryID,
noteKey
);
if (!item || !item.isNote()) {
message.content.params.infoText =
"Note does not exist or is not a note.";
} else {
message.content.params.item = item;
}
}
Zotero.debug(`Note link ${libraryID} : ${noteKey} called.`);
Zotero.Knowledge4Zotero.events.onEditorEvent.bind(
Zotero.Knowledge4Zotero.events
)(message);
},
newChannel: function (uri) {
this.doAction(uri);
},
};
this._extensions[ZOTERO_SCHEME + "://attachment"] = AttachmentExtension;
this._extensions[ZOTERO_SCHEME + "://data"] = DataExtension;
this._extensions[ZOTERO_SCHEME + "://report"] = ReportExtension;
this._extensions[ZOTERO_SCHEME + "://timeline"] = TimelineExtension;
this._extensions[ZOTERO_SCHEME + "://select"] = SelectExtension;
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 + "://note"] = openNoteExtension;
}
/*
* Implements nsIProtocolHandler
*/
ZoteroProtocolHandler.prototype = {
scheme: ZOTERO_SCHEME,
defaultPort: -1,
protocolFlags:
Components.interfaces.nsIProtocolHandler.URI_NORELATIVE |
Components.interfaces.nsIProtocolHandler.URI_NOAUTH |
// DEBUG: This should be URI_IS_LOCAL_FILE, and MUST be if any
// extensions that modify data are added
// - https://www.zotero.org/trac/ticket/1156
//
Components.interfaces.nsIProtocolHandler.URI_IS_LOCAL_FILE,
//Components.interfaces.nsIProtocolHandler.URI_LOADABLE_BY_ANYONE,
allowPort: function (port, scheme) {
return false;
},
getExtension: function (uri) {
let uriString = uri;
if (uri instanceof Components.interfaces.nsIURI) {
uriString = uri.spec;
}
uriString = uriString.toLowerCase();
for (let extSpec in this._extensions) {
if (uriString.startsWith(extSpec)) {
return this._extensions[extSpec];
}
}
return false;
},
newURI: function (spec, charset, baseURI) {
// A temporary workaround because baseURI.resolve(spec) just returns spec
if (baseURI) {
if (!spec.includes("://") && baseURI.spec.includes("/pdf.js/")) {
let parts = baseURI.spec.split("/");
parts.pop();
parts.push(spec);
spec = parts.join("/");
}
}
return Components.classes["@mozilla.org/network/simple-uri-mutator;1"]
.createInstance(Components.interfaces.nsIURIMutator)
.setSpec(spec)
.finalize();
},
newChannel: function (uri) {
var chromeService = Components.classes[
"@mozilla.org/network/protocol;1?name=chrome"
].getService(Components.interfaces.nsIProtocolHandler);
var newChannel = null;
try {
let ext = this.getExtension(uri);
if (!ext) {
// Return cancelled channel for unknown paths
//
// These can be in the form zotero://example.com/... -- maybe for "//example.com" URLs?
var chromeURI = chromeService.newURI(DUMMY_CHROME_URL, null, null);
var extChannel = chromeService.newChannel(chromeURI);
var chromeRequest = extChannel.QueryInterface(
Components.interfaces.nsIRequest
);
chromeRequest.cancel(0x804b0002); // BINDING_ABORTED
return extChannel;
}
if (!this._principal && ext.loadAsChrome) {
this._principal = Services.scriptSecurityManager.getSystemPrincipal();
}
var extChannel = ext.newChannel(uri);
// Extension returned null, so cancel request
if (!extChannel) {
var chromeURI = chromeService.newURI(DUMMY_CHROME_URL, null, null);
var extChannel = chromeService.newChannel(chromeURI);
var chromeRequest = extChannel.QueryInterface(
Components.interfaces.nsIRequest
);
chromeRequest.cancel(0x804b0002); // BINDING_ABORTED
}
// Apply cached principal to extension channel
if (this._principal) {
extChannel.owner = this._principal;
}
if (!extChannel.originalURI) extChannel.originalURI = uri;
return extChannel;
} catch (e) {
Components.utils.reportError(e);
Zotero.debug(e, 1);
throw Components.results.NS_ERROR_FAILURE;
}
return newChannel;
},
contractID: ZOTERO_PROTOCOL_CONTRACTID,
classDescription: ZOTERO_PROTOCOL_NAME,
classID: ZOTERO_PROTOCOL_CID,
QueryInterface: XPCOMUtils.generateQI([
Components.interfaces.nsISupports,
Components.interfaces.nsIProtocolHandler,
]),
};
/**
* nsIChannel implementation that takes a promise-yielding generator that returns a
* string, nsIAsyncInputStream, or file
*/
function AsyncChannel(uri, gen) {
this._generator = gen;
this._isPending = true;
// nsIRequest
this.name = uri;
this.loadFlags = 0;
this.loadGroup = null;
this.status = 0;
// nsIChannel
this.contentLength = -1;
this.contentType = "text/html";
this.contentCharset = "utf-8";
this.URI = uri;
this.originalURI = uri;
this.owner = null;
this.notificationCallbacks = null;
this.securityInfo = null;
}
AsyncChannel.prototype = {
asyncOpen: Zotero.Promise.coroutine(function* (streamListener, context) {
if (this.loadGroup) this.loadGroup.addRequest(this, null);
var channel = this;
var resolve;
var reject;
var promise = new Zotero.Promise(function () {
resolve = arguments[0];
reject = arguments[1];
});
var listenerWrapper = {
onStartRequest: function (request, context) {
//Zotero.debug("Starting request");
streamListener.onStartRequest(channel, context);
},
onDataAvailable: function (request, context, inputStream, offset, count) {
//Zotero.debug("onDataAvailable");
streamListener.onDataAvailable(
channel,
context,
inputStream,
offset,
count
);
},
onStopRequest: function (request, context, status) {
//Zotero.debug("Stopping request");
streamListener.onStopRequest(channel, context, status);
channel._isPending = false;
if (status == 0) {
resolve();
} else {
reject(
new Error("AsyncChannel request failed with status " + status)
);
}
},
};
//Zotero.debug("AsyncChannel's asyncOpen called");
var t = new Date();
var data;
try {
if (!data) {
data = yield Zotero.spawn(channel._generator, channel);
}
if (typeof data == "string") {
//Zotero.debug("AsyncChannel: Got string from generator");
listenerWrapper.onStartRequest(this, context);
let converter = Components.classes[
"@mozilla.org/intl/scriptableunicodeconverter"
].createInstance(Components.interfaces.nsIScriptableUnicodeConverter);
converter.charset = "UTF-8";
let inputStream = converter.convertToInputStream(data);
listenerWrapper.onDataAvailable(
this,
context,
inputStream,
0,
inputStream.available()
);
listenerWrapper.onStopRequest(this, context, this.status);
}
// If an async input stream is given, pass the data asynchronously to the stream listener
else if (data instanceof Ci.nsIAsyncInputStream) {
//Zotero.debug("AsyncChannel: Got input stream from generator");
var pump = Cc[
"@mozilla.org/network/input-stream-pump;1"
].createInstance(Ci.nsIInputStreamPump);
try {
pump.init(data, 0, 0, true);
} catch (e) {
pump.init(data, -1, -1, 0, 0, true);
}
pump.asyncRead(listenerWrapper, context);
} else if (data instanceof Ci.nsIFile || data instanceof Ci.nsIURI) {
if (data instanceof Ci.nsIFile) {
//Zotero.debug("AsyncChannel: Got file from generator");
data = ios.newFileURI(data);
} else {
//Zotero.debug("AsyncChannel: Got URI from generator");
}
let uri = data;
uri.QueryInterface(Ci.nsIURL);
this.contentType = Zotero.MIME.getMIMETypeFromExtension(
uri.fileExtension
);
if (!this.contentType) {
let sample = yield Zotero.File.getSample(data);
this.contentType = Zotero.MIME.getMIMETypeFromData(sample);
}
Components.utils.import("resource://gre/modules/NetUtil.jsm");
NetUtil.asyncFetch(
{ uri: data, loadUsingSystemPrincipal: true },
function (inputStream, status) {
if (!Components.isSuccessCode(status)) {
reject();
return;
}
listenerWrapper.onStartRequest(channel, context);
try {
listenerWrapper.onDataAvailable(
channel,
context,
inputStream,
0,
inputStream.available()
);
} catch (e) {
reject(e);
}
listenerWrapper.onStopRequest(channel, context, status);
}
);
} else if (data === undefined) {
this.cancel(0x804b0002); // BINDING_ABORTED
} else {
throw new Error(
"Invalid return type (" +
typeof data +
") from generator passed to AsyncChannel"
);
}
if (this._isPending) {
//Zotero.debug("AsyncChannel request succeeded in " + (new Date - t) + " ms");
channel._isPending = false;
}
return promise;
} catch (e) {
Zotero.debug(e, 1);
if (channel._isPending) {
streamListener.onStopRequest(
channel,
context,
Components.results.NS_ERROR_FAILURE
);
channel._isPending = false;
}
throw e;
} finally {
if (channel.loadGroup) channel.loadGroup.removeRequest(channel, null, 0);
}
}),
// nsIRequest
isPending: function () {
return this._isPending;
},
cancel: function (status) {
Zotero.debug("Cancelling");
this.status = status;
this._isPending = false;
},
resume: function () {
Zotero.debug("Resuming");
},
suspend: function () {
Zotero.debug("Suspending");
},
// nsIWritablePropertyBag
setProperty: function (prop, val) {
this[prop] = val;
},
deleteProperty: function (prop) {
delete this[prop];
},
QueryInterface: function (iid) {
if (
iid.equals(Components.interfaces.nsISupports) ||
iid.equals(Components.interfaces.nsIRequest) ||
iid.equals(Components.interfaces.nsIChannel) ||
// pdf.js wants this
iid.equals(Components.interfaces.nsIWritablePropertyBag)
) {
return this;
}
throw Components.results.NS_ERROR_NO_INTERFACE;
},
};
var NSGetFactory = XPCOMUtils.generateNSGetFactory([ZoteroProtocolHandler]);