www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | Submodules | README | LICENSE

zotero-protocol-handler.js (42793B)


      1 /*
      2     ***** BEGIN LICENSE BLOCK *****
      3     
      4     Copyright © 2009 Center for History and New Media
      5                      George Mason University, Fairfax, Virginia, USA
      6                      http://zotero.org
      7     
      8     This file is part of Zotero.
      9     
     10     Zotero is free software: you can redistribute it and/or modify
     11     it under the terms of the GNU Affero General Public License as published by
     12     the Free Software Foundation, either version 3 of the License, or
     13     (at your option) any later version.
     14     
     15     Zotero is distributed in the hope that it will be useful,
     16     but WITHOUT ANY WARRANTY; without even the implied warranty of
     17     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     18     GNU Affero General Public License for more details.
     19     
     20     You should have received a copy of the GNU Affero General Public License
     21     along with Zotero.  If not, see <http://www.gnu.org/licenses/>.
     22     
     23 	
     24 	Based on nsChromeExtensionHandler example code by Ed Anuff at
     25 	http://kb.mozillazine.org/Dev_:_Extending_the_Chrome_Protocol
     26 	
     27     ***** END LICENSE BLOCK *****
     28 */
     29 
     30 const ZOTERO_SCHEME = "zotero";
     31 const ZOTERO_PROTOCOL_CID = Components.ID("{9BC3D762-9038-486A-9D70-C997AF848A7C}");
     32 const ZOTERO_PROTOCOL_CONTRACTID = "@mozilla.org/network/protocol;1?name=" + ZOTERO_SCHEME;
     33 const ZOTERO_PROTOCOL_NAME = "Zotero Chrome Extension Protocol";
     34 
     35 const Cc = Components.classes;
     36 const Ci = Components.interfaces;
     37 const Cr = Components.results;
     38 
     39 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
     40 
     41 // Dummy chrome URL used to obtain a valid chrome channel
     42 // This one was chosen at random and should be able to be substituted
     43 // for any other well known chrome URL in the browser installation
     44 const DUMMY_CHROME_URL = "chrome://mozapps/content/xpinstall/xpinstallConfirm.xul";
     45 
     46 var Zotero = Components.classes["@zotero.org/Zotero;1"]
     47 	.getService(Components.interfaces.nsISupports)
     48 	.wrappedJSObject;
     49 
     50 var ioService = Components.classes["@mozilla.org/network/io-service;1"]
     51 	.getService(Components.interfaces.nsIIOService);
     52 
     53 function ZoteroProtocolHandler() {
     54 	this.wrappedJSObject = this;
     55 	this._principal = null;
     56 	this._extensions = {};
     57 	
     58 	
     59 	/**
     60 	 * zotero://data/library/collection/ABCD1234/items?sort=itemType&direction=desc
     61 	 * zotero://data/groups/12345/collection/ABCD1234/items?sort=title&direction=asc
     62 	 */
     63 	var DataExtension = {
     64 		loadAsChrome: false,
     65 		
     66 		newChannel: function (uri) {
     67 			return new AsyncChannel(uri, function* () {
     68 				this.contentType = 'text/plain';
     69 				
     70 				path = uri.spec.match(/zotero:\/\/[^/]+(.*)/)[1];
     71 				
     72 				try {
     73 					return Zotero.Utilities.Internal.getAsyncInputStream(
     74 						Zotero.API.Data.getGenerator(path)
     75 					);
     76 				}
     77 				catch (e) {
     78 					if (e instanceof Zotero.Router.InvalidPathException) {
     79 						return "URL could not be parsed";	
     80 					}
     81 				}
     82 			});
     83 		}
     84 	};
     85 	
     86 	
     87 	/*
     88 	 * Report generation extension for Zotero protocol
     89 	 */
     90 	var ReportExtension = {
     91 		loadAsChrome: false,
     92 		
     93 		newChannel: function (uri) {
     94 			return new AsyncChannel(uri, function* () {
     95 				var userLibraryID = Zotero.Libraries.userLibraryID;
     96 				
     97 				var path = uri.path;
     98 				if (!path) {
     99 					return 'Invalid URL';
    100 				}
    101 				// Strip leading '/'
    102 				path = path.substr(1);
    103 				
    104 				// Proxy CSS files
    105 				if (path.endsWith('.css')) {
    106 					var chromeURL = 'chrome://zotero/skin/report/' + path;
    107 					Zotero.debug(chromeURL);
    108 					var ios = Components.classes["@mozilla.org/network/io-service;1"]
    109 						.getService(Components.interfaces.nsIIOService);
    110 					let uri = ios.newURI(chromeURL, null, null);
    111 					var chromeReg = Components.classes["@mozilla.org/chrome/chrome-registry;1"]
    112 						.getService(Components.interfaces.nsIChromeRegistry);
    113 					return chromeReg.convertChromeURL(uri);
    114 				}
    115 				
    116 				var params = {
    117 					objectType: 'item',
    118 					format: 'html',
    119 					sort: 'title'
    120 				};
    121 				var router = new Zotero.Router(params);
    122 				
    123 				// Items within a collection or search
    124 				router.add('library/:scopeObject/:scopeObjectKey/items', function () {
    125 					params.libraryID = userLibraryID;
    126 				});
    127 				router.add('groups/:groupID/:scopeObject/:scopeObjectKey/items');
    128 				
    129 				// All items
    130 				router.add('library/items/:objectKey', function () {
    131 					params.libraryID = userLibraryID;
    132 				});
    133 				router.add('groups/:groupID/items');
    134 				
    135 				// Old-style URLs
    136 				router.add('collection/:id/html/report.html', function () {
    137 					params.scopeObject = 'collections';
    138 					var lkh = Zotero.Collections.parseLibraryKeyHash(params.id);
    139 					if (lkh) {
    140 						params.libraryID = lkh.libraryID || userLibraryID;
    141 						params.scopeObjectKey = lkh.key;
    142 					}
    143 					else {
    144 						params.scopeObjectID = params.id;
    145 					}
    146 					delete params.id;
    147 				});
    148 				router.add('search/:id/html/report.html', function () {
    149 					params.scopeObject = 'searches';
    150 					var lkh = Zotero.Searches.parseLibraryKeyHash(this.id);
    151 					if (lkh) {
    152 						params.libraryID = lkh.libraryID || userLibraryID;
    153 						params.scopeObjectKey = lkh.key;
    154 					}
    155 					else {
    156 						params.scopeObjectID = this.id;
    157 					}
    158 					delete params.id;
    159 				});
    160 				router.add('items/:ids/html/report.html', function () {
    161 					var ids = this.ids.split('-');
    162 					params.libraryID = ids[0].split('_')[0] || userLibraryID;
    163 					params.itemKey = ids.map(x => x.split('_')[1]);
    164 					delete params.ids;
    165 				});
    166 				
    167 				var parsed = router.run(path);
    168 				if (!parsed) {
    169 					return "URL could not be parsed";
    170 				}
    171 				
    172 				// TODO: support old URLs
    173 				// collection
    174 				// search
    175 				// items
    176 				// item
    177 				if (params.sort.indexOf('/') != -1) {
    178 					let parts = params.sort.split('/');
    179 					params.sort = parts[0];
    180 					params.direction = parts[1] == 'd' ? 'desc' : 'asc';
    181 				}
    182 				
    183 				try {
    184 					Zotero.API.parseParams(params);
    185 					var results = yield Zotero.API.getResultsFromParams(params);
    186 				}
    187 				catch (e) {
    188 					Zotero.debug(e, 1);
    189 					return e.toString();
    190 				}
    191 				
    192 				var mimeType, content = '';
    193 				var items = [];
    194 				var itemsHash = {}; // key = itemID, val = position in |items|
    195 				var searchItemIDs = new Set(); // All selected items
    196 				var searchParentIDs = new Set(); // Parents of selected child items
    197 				var searchChildIDs = new Set() // Selected chlid items
    198 				
    199 				var includeAllChildItems = Zotero.Prefs.get('report.includeAllChildItems');
    200 				var combineChildItems = Zotero.Prefs.get('report.combineChildItems');
    201 				
    202 				var unhandledParents = {};
    203 				for (var i=0; i<results.length; i++) {
    204 					// Don't add child items directly
    205 					// (instead mark their parents for inclusion below)
    206 					var parentItemID = results[i].parentItemID;
    207 					if (parentItemID) {
    208 						searchParentIDs.add(parentItemID);
    209 						searchChildIDs.add(results[i].id);
    210 						
    211 						// Don't include all child items if any child
    212 						// items were selected
    213 						includeAllChildItems = false;
    214 					}
    215 					// If combining children or standalone note/attachment, add matching parents
    216 					else if (combineChildItems || !results[i].isRegularItem()
    217 							|| results[i].numChildren() == 0) {
    218 						itemsHash[results[i].id] = [items.length];
    219 						items.push(results[i].toJSON({ mode: 'full' }));
    220 						// Flag item as a search match
    221 						items[items.length - 1].reportSearchMatch = true;
    222 					}
    223 					else {
    224 						unhandledParents[i] = true;
    225 					}
    226 					searchItemIDs.add(results[i].id);
    227 				}
    228 				
    229 				// If including all child items, add children of all matched
    230 				// parents to the child array
    231 				if (includeAllChildItems) {
    232 					for (let id of searchItemIDs) {
    233 						if (!searchChildIDs.has(id)) {
    234 							var children = [];
    235 							var item = yield Zotero.Items.getAsync(id);
    236 							if (!item.isRegularItem()) {
    237 								continue;
    238 							}
    239 							var func = function (ids) {
    240 								if (ids) {
    241 									for (var i=0; i<ids.length; i++) {
    242 										searchChildIDs.add(ids[i]);
    243 									}
    244 								}
    245 							};
    246 							func(item.getNotes());
    247 							func(item.getAttachments());
    248 						}
    249 					}
    250 				}
    251 				// If not including all children, add matching parents,
    252 				// in case they don't have any matching children below
    253 				else {
    254 					for (var i in unhandledParents) {
    255 						itemsHash[results[i].id] = [items.length];
    256 						items.push(results[i].toJSON({ mode: 'full' }));
    257 						// Flag item as a search match
    258 						items[items.length - 1].reportSearchMatch = true;
    259 					}
    260 				}
    261 				
    262 				if (combineChildItems) {
    263 					// Add parents of matches if parents aren't matches themselves
    264 					for (let id of searchParentIDs) {
    265 						if (!searchItemIDs.has(id) && !itemsHash[id]) {
    266 							var item = yield Zotero.Items.getAsync(id);
    267 							itemsHash[id] = items.length;
    268 							items.push(item.toJSON({ mode: 'full' }));
    269 						}
    270 					}
    271 					
    272 					// Add children to reportChildren property of parents
    273 					for (let id of searchChildIDs) {
    274 						let item = yield Zotero.Items.getAsync(id);
    275 						var parentID = item.parentID;
    276 						if (!items[itemsHash[parentID]].reportChildren) {
    277 							items[itemsHash[parentID]].reportChildren = {
    278 								notes: [],
    279 								attachments: []
    280 							};
    281 						}
    282 						if (item.isNote()) {
    283 							items[itemsHash[parentID]].reportChildren.notes.push(item.toJSON({ mode: 'full' }));
    284 						}
    285 						if (item.isAttachment()) {
    286 							items[itemsHash[parentID]].reportChildren.attachments.push(item.toJSON({ mode: 'full' }));
    287 						}
    288 					}
    289 				}
    290 				// If not combining children, add a parent/child pair
    291 				// for each matching child
    292 				else {
    293 					for (let id of searchChildIDs) {
    294 						var item = yield Zotero.Items.getAsync(id);
    295 						var parentID = item.parentID;
    296 						var parentItem = Zotero.Items.get(parentID);
    297 						
    298 						if (!itemsHash[parentID]) {
    299 							// If parent is a search match and not yet added,
    300 							// add on its own
    301 							if (searchItemIDs.has(parentID)) {
    302 								itemsHash[parentID] = [items.length];
    303 								items.push(parentItem.toJSON({ mode: 'full' }));
    304 								items[items.length - 1].reportSearchMatch = true;
    305 							}
    306 							else {
    307 								itemsHash[parentID] = [];
    308 							}
    309 						}
    310 						
    311 						// Now add parent and child
    312 						itemsHash[parentID].push(items.length);
    313 						items.push(parentItem.toJSON({ mode: 'full' }));
    314 						if (item.isNote()) {
    315 							items[items.length - 1].reportChildren = {
    316 								notes: [item.toJSON({ mode: 'full' })],
    317 								attachments: []
    318 							};
    319 						}
    320 						else if (item.isAttachment()) {
    321 							items[items.length - 1].reportChildren = {
    322 								notes: [],
    323 								attachments: [item.toJSON({ mode: 'full' })]
    324 							};
    325 						}
    326 					}
    327 				}
    328 				
    329 				// Sort items
    330 				// TODO: restore multiple sort fields
    331 				var sorts = [{
    332 					field: params.sort,
    333 					order: params.direction != 'desc' ? 1 : -1
    334 				}];
    335 				
    336 				
    337 				var collation = Zotero.getLocaleCollation();
    338 				var compareFunction = function(a, b) {
    339 					var index = 0;
    340 					
    341 					// Multidimensional sort
    342 					do {
    343 						// In combineChildItems, use note or attachment as item
    344 						if (!combineChildItems) {
    345 							if (a.reportChildren) {
    346 								if (a.reportChildren.notes.length) {
    347 									a = a.reportChildren.notes[0];
    348 								}
    349 								else {
    350 									a = a.reportChildren.attachments[0];
    351 								}
    352 							}
    353 							
    354 							if (b.reportChildren) {
    355 								if (b.reportChildren.notes.length) {
    356 									b = b.reportChildren.notes[0];
    357 								}
    358 								else {
    359 									b = b.reportChildren.attachments[0];
    360 								}
    361 							}
    362 						}
    363 						
    364 						var valA, valB;
    365 						
    366 						if (sorts[index].field == 'title') {
    367 							// For notes, use content for 'title'
    368 							if (a.itemType == 'note') {
    369 								valA = a.note;
    370 							}
    371 							else {
    372 								valA = a.title; 
    373 							}
    374 							
    375 							if (b.itemType == 'note') {
    376 								valB = b.note;
    377 							}
    378 							else {
    379 								valB = b.title; 
    380 							}
    381 							
    382 							valA = Zotero.Items.getSortTitle(valA);
    383 							valB = Zotero.Items.getSortTitle(valB);
    384 						}
    385 						else if (sorts[index].field == 'date') {
    386 							var itemA = Zotero.Items.getByLibraryAndKey(params.libraryID, a.key);
    387 							var itemB = Zotero.Items.getByLibraryAndKey(params.libraryID, b.key);
    388 							valA = itemA.getField('date', true, true);
    389 							valB = itemB.getField('date', true, true);
    390 						}
    391 						// TEMP: This is an ugly hack to make creator sorting
    392 						// slightly less broken. To do this right, real creator
    393 						// sorting needs to be abstracted from itemTreeView.js.
    394 						else if (sorts[index].field == 'firstCreator') {
    395 							var itemA = Zotero.Items.getByLibraryAndKey(params.libraryID, a.key);
    396 							var itemB = Zotero.Items.getByLibraryAndKey(params.libraryID, b.key);
    397 							valA = itemA.getField('firstCreator');
    398 							valB = itemB.getField('firstCreator');
    399 						}
    400 						else {
    401 							valA = a[sorts[index].field];
    402 							valB = b[sorts[index].field];
    403 						}
    404 						
    405 						// Put empty values last
    406 						if (!valA && valB) {
    407 							var cmp = 1;
    408 						}
    409 						else if (valA && !valB) {
    410 							var cmp = -1;
    411 						}
    412 						else {
    413 							var cmp = collation.compareString(0, valA, valB);
    414 						}
    415 						
    416 						var result = 0;
    417 						if (cmp != 0) {
    418 							result = cmp * sorts[index].order;
    419 						}
    420 						index++;
    421 					}
    422 					while (result == 0 && sorts[index]);
    423 					
    424 					return result;
    425 				};
    426 				
    427 				items.sort(compareFunction);
    428 				for (var i in items) {
    429 					if (items[i].reportChildren) {
    430 						items[i].reportChildren.notes.sort(compareFunction);
    431 						items[i].reportChildren.attachments.sort(compareFunction);
    432 					}
    433 				}
    434 				
    435 				// Pass off to the appropriate handler
    436 				switch (params.format) {
    437 					case 'rtf':
    438 						this.contentType = 'text/rtf';
    439 						return '';
    440 						
    441 					case 'csv':
    442 						this.contentType = 'text/plain';
    443 						return '';
    444 					
    445 					default:
    446 						this.contentType = 'text/html';
    447 						return Zotero.Utilities.Internal.getAsyncInputStream(
    448 							Zotero.Report.HTML.listGenerator(items, combineChildItems),
    449 							function () {
    450 								return '<span style="color: red; font-weight: bold">Error generating report</span>';
    451 							}
    452 						);
    453 				}
    454 			});
    455 		}
    456 	};
    457 	
    458 	/**
    459 	 * Generate MIT SIMILE Timeline
    460 	 *
    461 	 * Query string key abbreviations: intervals = i
    462 	 *                                 dateType = t
    463 	 *                                 timelineDate = d
    464 	 * 
    465 	 * interval abbreviations:  day = d  |  month = m  |  year = y  |  decade = e  |  century = c  |  millennium = i
    466 	 * dateType abbreviations:  date = d  |  dateAdded = da  |  dateModified = dm
    467 	 * timelineDate format:  shortMonthName.day.year  (year is positive for A.D. and negative for B.C.)
    468 	 * 
    469 	 * Defaults: intervals = month, year, decade
    470 	 *           dateType = date
    471 	 *           timelineDate = today's date
    472 	 */
    473 	var TimelineExtension = {
    474 		loadAsChrome: true,
    475 		
    476 		newChannel: function (uri) {
    477 			return new AsyncChannel(uri, function* () {
    478 				var userLibraryID = Zotero.Libraries.userLibraryID;
    479 				
    480 				path = uri.spec.match(/zotero:\/\/[^/]+(.*)/)[1];
    481 				if (!path) {
    482 					this.contentType = 'text/html';
    483 					return 'Invalid URL';
    484 				}
    485 				
    486 				var params = {};
    487 				var router = new Zotero.Router(params);
    488 				
    489 				// HTML
    490 				router.add('library/:scopeObject/:scopeObjectKey', function () {
    491 					params.libraryID = userLibraryID;
    492 					params.controller = 'html';
    493 				});
    494 				router.add('groups/:groupID/:scopeObject/:scopeObjectKey', function () {
    495 					params.controller = 'html';
    496 				});
    497 				router.add('library', function () {
    498 					params.libraryID = userLibraryID;
    499 					params.controller = 'html';
    500 				});
    501 				router.add('groups/:groupID', function () {
    502 					params.controller = 'html';
    503 				});
    504 				
    505 				// Data
    506 				router.add('data/library/:scopeObject/:scopeObjectKey', function () {
    507 					params.libraryID = userLibraryID;
    508 					params.controller = 'data';
    509 				});
    510 				router.add('data/groups/:groupID/:scopeObject/:scopeObjectKey', function () {
    511 					params.controller = 'data';
    512 				});
    513 				router.add('data/library', function () {
    514 					params.libraryID = userLibraryID;
    515 					params.controller = 'data';
    516 				});
    517 				router.add('data/groups/:groupID', function () {
    518 					params.controller = 'data';
    519 				});
    520 				
    521 				// Old-style HTML URLs
    522 				router.add('collection/:id', function () {
    523 					params.controller = 'html';
    524 					params.scopeObject = 'collections';
    525 					var lkh = Zotero.Collections.parseLibraryKeyHash(params.id);
    526 					if (lkh) {
    527 						params.libraryID = lkh.libraryID || userLibraryID;
    528 						params.scopeObjectKey = lkh.key;
    529 					}
    530 					else {
    531 						params.scopeObjectID = params.id;
    532 					}
    533 					delete params.id;
    534 				});
    535 				router.add('search/:id', function () {
    536 					params.controller = 'html';
    537 					params.scopeObject = 'searches';
    538 					var lkh = Zotero.Searches.parseLibraryKeyHash(params.id);
    539 					if (lkh) {
    540 						params.libraryID = lkh.libraryID || userLibraryID;
    541 						params.scopeObjectKey = lkh.key;
    542 					}
    543 					else {
    544 						params.scopeObjectID = params.id;
    545 					}
    546 					delete params.id;
    547 				});
    548 				router.add('/', function () {
    549 					params.controller = 'html';
    550 					params.libraryID = userLibraryID;
    551 				});
    552 				
    553 				var parsed = router.run(path);
    554 				if (!parsed) {
    555 					this.contentType = 'text/html';
    556 					return "URL could not be parsed";
    557 				}
    558 				if (params.groupID) {
    559 					params.libraryID = Zotero.Groups.getLibraryIDFromGroupID(params.groupID);
    560 				}
    561 				
    562 				var intervals = params.i ? params.i : '';
    563 				var timelineDate = params.d ? params.d : '';
    564 				var dateType = params.t ? params.t : '';
    565 				
    566 				// Get the collection or search object
    567 				var collection, search;
    568 				switch (params.scopeObject) {
    569 					case 'collections':
    570 						if (params.scopeObjectKey) {
    571 							collection = yield Zotero.Collections.getByLibraryAndKeyAsync(
    572 								params.libraryID, params.scopeObjectKey
    573 							);
    574 						}
    575 						else {
    576 							collection = yield Zotero.Collections.getAsync(params.scopeObjectID);
    577 						}
    578 						if (!collection) {
    579 							this.contentType = 'text/html';
    580 							return 'Invalid collection ID or key';
    581 						}
    582 						break;
    583 					
    584 					case 'searches':
    585 						if (params.scopeObjectKey) {
    586 							var s = yield Zotero.Searches.getByLibraryAndKeyAsync(
    587 								params.libraryID, params.scopeObjectKey
    588 							);
    589 						}
    590 						else {
    591 							var s = yield Zotero.Searches.getAsync(params.scopeObjectID);
    592 						}
    593 						if (!s) {
    594 							return 'Invalid search ID or key';
    595 						}
    596 						
    597 						// FIXME: Hack to exclude group libraries for now
    598 						var search = new Zotero.Search();
    599 						search.setScope(s);
    600 						var groups = Zotero.Groups.getAll();
    601 						for (let group of groups) {
    602 							search.addCondition('libraryID', 'isNot', group.libraryID);
    603 						}
    604 						break;
    605 				}
    606 				
    607 				//
    608 				// Create XML file
    609 				//
    610 				if (params.controller == 'data') {
    611 					switch (params.scopeObject) {
    612 						case 'collections':
    613 							var results = collection.getChildItems();
    614 							break;
    615 						
    616 						case 'searches':
    617 							var ids = yield search.search();
    618 							var results = yield Zotero.Items.getAsync(ids);
    619 							break;
    620 						
    621 						default:
    622 							if (params.scopeObject) {
    623 								return "Invalid scope object '" + params.scopeObject + "'";
    624 							}
    625 							
    626 							let s = new Zotero.Search();
    627 							s.addCondition('libraryID', 'is', params.libraryID);
    628 							s.addCondition('noChildren', 'true');
    629 							var ids = yield s.search();
    630 							var results = yield Zotero.Items.getAsync(ids);
    631 					}
    632 					
    633 					var items = [];
    634 					// Only include parent items
    635 					for (let i=0; i<results.length; i++) {
    636 						if (!results[i].parentItemID) {
    637 							items.push(results[i]);
    638 						}
    639 					}
    640 					
    641 					var dateTypes = {
    642 						d: 'date',
    643 						da: 'dateAdded',
    644 						dm: 'dateModified'
    645 					};
    646 					
    647 					//default dateType = date
    648 					if (!dateType || !dateTypes[dateType]) {
    649 						dateType = 'd';
    650 					}
    651 					
    652 					this.contentType = 'application/xml';
    653 					return Zotero.Utilities.Internal.getAsyncInputStream(
    654 						Zotero.Timeline.generateXMLDetails(items, dateTypes[dateType])
    655 					);
    656 				}
    657 				
    658 				//
    659 				// Generate main HTML page
    660 				//
    661 				content = Zotero.File.getContentsFromURL('chrome://zotero/skin/timeline/timeline.html');
    662 				this.contentType = 'text/html';
    663 				
    664 				if(!timelineDate){
    665 					timelineDate=Date();
    666 					var dateParts=timelineDate.toString().split(' ');
    667 					timelineDate=dateParts[1]+'.'+dateParts[2]+'.'+dateParts[3];
    668 				}
    669 				if (!intervals || intervals.length < 3) {
    670 					intervals += "mye".substr(intervals.length);
    671 				}
    672 				
    673 				var theIntervals = {
    674 					d: 'Timeline.DateTime.DAY',
    675 					m: 'Timeline.DateTime.MONTH',
    676 					y: 'Timeline.DateTime.YEAR',
    677 					e: 'Timeline.DateTime.DECADE',
    678 					c: 'Timeline.DateTime.CENTURY',
    679 					i: 'Timeline.DateTime.MILLENNIUM'
    680 				};
    681 				
    682 				//sets the intervals of the timeline bands
    683 				var tempStr = '<body onload="onLoad(';
    684 				var a = (theIntervals[intervals[0]]) ? theIntervals[intervals[0]] : 'Timeline.DateTime.MONTH';
    685 				var b = (theIntervals[intervals[1]]) ? theIntervals[intervals[1]] : 'Timeline.DateTime.YEAR';
    686 				var c = (theIntervals[intervals[2]]) ? theIntervals[intervals[2]] : 'Timeline.DateTime.DECADE';
    687 				content = content.replace(tempStr, tempStr + a + ',' + b + ',' + c + ',\'' + timelineDate + '\'');
    688 				
    689 				tempStr = 'document.write("<title>';
    690 				if (params.scopeObject == 'collections') {
    691 					content = content.replace(tempStr, tempStr + collection.name + ' - ');
    692 				}
    693 				else if (params.scopeObject == 'searches') {
    694 					content = content.replace(tempStr, tempStr + search.name + ' - ');
    695 				}
    696 				else {
    697 					content = content.replace(tempStr, tempStr + Zotero.getString('pane.collections.library') + ' - ');
    698 				}
    699 				
    700 				tempStr = 'Timeline.loadXML("zotero://timeline/data/';
    701 				var d = '';
    702 				if (params.groupID) {
    703 					d += 'groups/' + params.groupID + '/';
    704 				}
    705 				else {
    706 					d += 'library/';
    707 				}
    708 				if (params.scopeObject) {
    709 					d += params.scopeObject + "/" + params.scopeObjectKey;
    710 				}
    711 				if (dateType) {
    712 					d += '?t=' + dateType;
    713 				}
    714 				return content.replace(tempStr, tempStr + d);
    715 			});
    716 		}
    717 	};
    718 	
    719 	
    720 	/*
    721 		zotero://attachment/[id]/
    722 	*/
    723 	var AttachmentExtension = {
    724 		loadAsChrome: false,
    725 		
    726 		newChannel: function (uri) {
    727 			var self = this;
    728 			
    729 			return new AsyncChannel(uri, function* () {
    730 				try {
    731 					var errorMsg;
    732 					var [id, fileName] = uri.path.substr(1).split('/');
    733 					
    734 					if (parseInt(id) != id) {
    735 						// Proxy annotation icons
    736 						if (id.match(/^annotation.*\.(png|html|css|gif)$/)) {
    737 							var chromeURL = 'chrome://zotero/skin/' + id;
    738 							var ios = Components.classes["@mozilla.org/network/io-service;1"].
    739 										getService(Components.interfaces.nsIIOService);
    740 							let uri = ios.newURI(chromeURL, null, null);
    741 							var chromeReg = Components.classes["@mozilla.org/chrome/chrome-registry;1"]
    742 									.getService(Components.interfaces.nsIChromeRegistry);
    743 							var fileURI = chromeReg.convertChromeURL(uri);
    744 						}
    745 						else {
    746 							return self._errorChannel("Attachment id not an integer");
    747 						}
    748 					}
    749 					
    750 					if (!fileURI) {
    751 						var item = yield Zotero.Items.getAsync(id);
    752 						if (!item) {
    753 							return self._errorChannel("Item not found");
    754 						}
    755 						var path = yield item.getFilePathAsync();
    756 						if (!path) {
    757 							return self._errorChannel("File not found");
    758 						}
    759 						if (fileName) {
    760 							Components.utils.import("resource://gre/modules/osfile.jsm");
    761 							path = OS.Path.join(OS.Path.dirname(path), fileName)
    762 							if (!(yield OS.File.exists(path))) {
    763 								return self._errorChannel("File not found");
    764 							}
    765 						}
    766 					}
    767 					
    768 					//set originalURI so that it seems like we're serving from zotero:// protocol
    769 					//this is necessary to allow url() links to work from within css files
    770 					//otherwise they try to link to files on the file:// protocol, which is not allowed
    771 					this.originalURI = uri;
    772 					
    773 					return Zotero.File.pathToFile(path);
    774 				}
    775 				catch (e) {
    776 					Zotero.debug(e);
    777 					throw (e);
    778 				}
    779 			});
    780 		},
    781 		
    782 		
    783 		_errorChannel: function (msg) {
    784 			this.status = Components.results.NS_ERROR_FAILURE;
    785 			this.contentType = 'text/plain';
    786 			return msg;
    787 		}
    788 	};
    789 	
    790 	
    791 	/**
    792 	 * zotero://select/[type]/0_ABCD1234
    793 	 * zotero://select/[type]/1234 (not consistent across synced machines)
    794 	 */
    795 	var SelectExtension = {
    796 		noContent: true,
    797 		
    798 		doAction: Zotero.Promise.coroutine(function* (uri) {
    799 			var userLibraryID = Zotero.Libraries.userLibraryID;
    800 			
    801 			var path = uri.path;
    802 			if (!path) {
    803 				return 'Invalid URL';
    804 			}
    805 			// Strip leading '/'
    806 			path = path.substr(1);
    807 			var mimeType, content = '';
    808 			
    809 			var params = {
    810 				objectType: 'item'
    811 			};
    812 			var router = new Zotero.Router(params);
    813 			
    814 			// Item within a collection or search
    815 			router.add('library/:scopeObject/:scopeObjectKey/items/:objectKey', function () {
    816 				params.libraryID = userLibraryID;
    817 			});
    818 			router.add('groups/:groupID/:scopeObject/:scopeObjectKey/items/:objectKey');
    819 			
    820 			// All items
    821 			router.add('library/items/:objectKey', function () {
    822 				params.libraryID = userLibraryID;
    823 			});
    824 			router.add('groups/:groupID/items/:objectKey');
    825 			
    826 			// Old-style URLs
    827 			router.add('items/:id', function () {
    828 				var lkh = Zotero.Items.parseLibraryKeyHash(params.id);
    829 				if (lkh) {
    830 					params.libraryID = lkh.libraryID || userLibraryID;
    831 					params.objectKey = lkh.key;
    832 				}
    833 				else {
    834 					params.objectID = params.id;
    835 				}
    836 				delete params.id;
    837 			});
    838 			router.run(path);
    839 			
    840 			Zotero.API.parseParams(params);
    841 			var results = yield Zotero.API.getResultsFromParams(params);
    842 			
    843 			if (!results.length) {
    844 				var msg = "Items not found";
    845 				Zotero.debug(msg, 2);
    846 				Components.utils.reportError(msg);
    847 				return;
    848 			}
    849 			
    850 			var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
    851 				.getService(Components.interfaces.nsIWindowMediator);
    852 			var win = wm.getMostRecentWindow("navigator:browser");
    853 			
    854 			// TODO: Currently only able to select one item
    855 			return win.ZoteroPane.selectItem(results[0].id);
    856 		}),
    857 		
    858 		newChannel: function (uri) {
    859 			this.doAction(uri);
    860 		}
    861 	};
    862 	
    863 	/*
    864 		zotero://fullscreen
    865 	*/
    866 	var FullscreenExtension = {
    867 		loadAsChrome: false,
    868 		
    869 		newChannel: function (uri) {
    870 			return new AsyncChannel(uri, function* () {
    871 				try {
    872 					var window = Components.classes["@mozilla.org/embedcomp/window-watcher;1"]
    873 						.getService(Components.interfaces.nsIWindowWatcher)
    874 						.openWindow(null, 'chrome://zotero/content/standalone/standalone.xul', '',
    875 							'chrome,centerscreen,resizable', null);
    876 				}
    877 				catch (e) {
    878 					Zotero.debug(e, 1);
    879 					throw e;
    880 				}
    881 			});
    882 		}
    883 	};
    884 	
    885 	
    886 	/*
    887 		zotero://debug/
    888 	*/
    889 	var DebugExtension = {
    890 		loadAsChrome: false,
    891 		
    892 		newChannel: function (uri) {
    893 			return new AsyncChannel(uri, function* () {
    894 				this.contentType = "text/plain";
    895 				
    896 				try {
    897 					return Zotero.Debug.get();
    898 				}
    899 				catch (e) {
    900 					Zotero.debug(e, 1);
    901 					throw e;
    902 				}
    903 			});
    904 		}
    905 	};
    906 	
    907 	var ConnectorChannel = function(uri, data) {
    908 		var secMan = Components.classes["@mozilla.org/scriptsecuritymanager;1"]
    909 			.getService(Components.interfaces.nsIScriptSecurityManager);
    910 		var ioService = Components.classes["@mozilla.org/network/io-service;1"]
    911 			.getService(Components.interfaces.nsIIOService);
    912 		
    913 		this.name = uri;
    914 		this.URI = ioService.newURI(uri, "UTF-8", null);
    915 		this.owner = (secMan.getCodebasePrincipal || secMan.getSimpleCodebasePrincipal)(this.URI);
    916 		this._isPending = true;
    917 		
    918 		var converter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"].
    919 			createInstance(Components.interfaces.nsIScriptableUnicodeConverter);
    920 		converter.charset = "UTF-8";
    921 		this._stream = converter.convertToInputStream(data);
    922 		this.contentLength = this._stream.available();
    923 	}
    924 	
    925 	ConnectorChannel.prototype.contentCharset = "UTF-8";
    926 	ConnectorChannel.prototype.contentType = "text/html";
    927 	ConnectorChannel.prototype.notificationCallbacks = null;
    928 	ConnectorChannel.prototype.securityInfo = null;
    929 	ConnectorChannel.prototype.status = 0;
    930 	ConnectorChannel.prototype.loadGroup = null;
    931 	ConnectorChannel.prototype.loadFlags = 393216;
    932 	
    933 	ConnectorChannel.prototype.__defineGetter__("originalURI", function() { return this.URI });
    934 	ConnectorChannel.prototype.__defineSetter__("originalURI", function() { });
    935 	
    936 	ConnectorChannel.prototype.asyncOpen = function(streamListener, context) {
    937 		if(this.loadGroup) this.loadGroup.addRequest(this, null);
    938 		streamListener.onStartRequest(this, context);
    939 		streamListener.onDataAvailable(this, context, this._stream, 0, this.contentLength);
    940 		streamListener.onStopRequest(this, context, this.status);
    941 		this._isPending = false;
    942 		if(this.loadGroup) this.loadGroup.removeRequest(this, null, 0);
    943 	}
    944 	
    945 	ConnectorChannel.prototype.isPending = function() {
    946 		return this._isPending;
    947 	}
    948 	
    949 	ConnectorChannel.prototype.cancel = function(status) {
    950 		this.status = status;
    951 		this._isPending = false;
    952 		if(this._stream) this._stream.close();
    953 	}
    954 	
    955 	ConnectorChannel.prototype.suspend = function() {}
    956 	
    957 	ConnectorChannel.prototype.resume = function() {}
    958 	
    959 	ConnectorChannel.prototype.open = function() {
    960 		return this._stream;
    961 	}
    962 	
    963 	ConnectorChannel.prototype.QueryInterface = function(iid) {
    964 		if (!iid.equals(Components.interfaces.nsIChannel) && !iid.equals(Components.interfaces.nsIRequest) &&
    965 				!iid.equals(Components.interfaces.nsISupports)) {
    966 			throw Components.results.NS_ERROR_NO_INTERFACE;
    967 		}
    968 		return this;
    969 	}
    970 	
    971 	/**
    972 	 * zotero://connector/
    973 	 *
    974 	 * URI spoofing for transferring page data across boundaries
    975 	 */
    976 	var ConnectorExtension = new function() {
    977 		this.loadAsChrome = false;
    978 		
    979 		this.newChannel = function(uri) {
    980 			var ioService = Components.classes["@mozilla.org/network/io-service;1"]
    981 				.getService(Components.interfaces.nsIIOService);
    982 			var secMan = Components.classes["@mozilla.org/scriptsecuritymanager;1"]
    983 				.getService(Components.interfaces.nsIScriptSecurityManager);
    984 			var Zotero = Components.classes["@zotero.org/Zotero;1"]
    985 				.getService(Components.interfaces.nsISupports)
    986 				.wrappedJSObject;
    987 			
    988 			try {
    989 				var originalURI = uri.path;
    990 				originalURI = decodeURIComponent(originalURI.substr(originalURI.indexOf("/")+1));
    991 				if(!Zotero.Server.Connector.Data[originalURI]) {
    992 					return null;
    993 				} else {
    994 					return new ConnectorChannel(originalURI, Zotero.Server.Connector.Data[originalURI]);
    995 				}
    996 			} catch(e) {
    997 				Zotero.debug(e);
    998 				throw e;
    999 			}
   1000 		}
   1001 	};
   1002 	
   1003 	
   1004 	/**
   1005 	 * Open a PDF at a given page (or try to)
   1006 	 *
   1007 	 * zotero://open-pdf/library/items/[itemKey]?page=[page]
   1008 	 * zotero://open-pdf/groups/[groupID]/items/[itemKey]?page=[page]
   1009 	 *
   1010 	 * Also supports ZotFile format:
   1011 	 * zotero://open-pdf/[libraryID]_[key]/[page]
   1012 	 */
   1013 	var OpenPDFExtension = {
   1014 		noContent: true,
   1015 		
   1016 		doAction: async function (uri) {
   1017 			var userLibraryID = Zotero.Libraries.userLibraryID;
   1018 			
   1019 			var uriPath = uri.path;
   1020 			if (!uriPath) {
   1021 				return 'Invalid URL';
   1022 			}
   1023 			// Strip leading '/'
   1024 			uriPath = uriPath.substr(1);
   1025 			var mimeType, content = '';
   1026 			
   1027 			var params = {
   1028 				objectType: 'item'
   1029 			};
   1030 			var router = new Zotero.Router(params);
   1031 			
   1032 			// All items
   1033 			router.add('library/items/:objectKey', function () {
   1034 				params.libraryID = userLibraryID;
   1035 			});
   1036 			router.add('groups/:groupID/items/:objectKey');
   1037 			
   1038 			// ZotFile URLs
   1039 			router.add(':id/:page', function () {
   1040 				var lkh = Zotero.Items.parseLibraryKeyHash(params.id);
   1041 				if (!lkh) {
   1042 					Zotero.warn(`Invalid URL ${url}`);
   1043 					return;
   1044 				}
   1045 				params.libraryID = lkh.libraryID || userLibraryID;
   1046 				params.objectKey = lkh.key;
   1047 				delete params.id;
   1048 			});
   1049 			router.run(uriPath);
   1050 			
   1051 			Zotero.API.parseParams(params);
   1052 			var results = await Zotero.API.getResultsFromParams(params);
   1053 			var page = params.page;
   1054 			if (parseInt(page) != page) {
   1055 				page = null;
   1056 			}
   1057 			
   1058 			if (!results.length) {
   1059 				Zotero.warn(`No item found for ${uriPath}`);
   1060 				return;
   1061 			}
   1062 			
   1063 			var item = results[0];
   1064 			
   1065 			if (!item.isFileAttachment()) {
   1066 				Zotero.warn(`Item for ${uriPath} is not a file attachment`);
   1067 				return;
   1068 			}
   1069 			
   1070 			var path = await item.getFilePathAsync();
   1071 			if (!path) {
   1072 				Zotero.warn(`${path} not found`);
   1073 				return;
   1074 			}
   1075 			
   1076 			if (!path.toLowerCase().endsWith('.pdf')
   1077 					&& Zotero.MIME.sniffForMIMEType(await Zotero.File.getSample(path)) != 'application/pdf') {
   1078 				Zotero.warn(`${path} is not a PDF`);
   1079 				return;
   1080 			}
   1081 			
   1082 			// If no page number, just open normally
   1083 			if (!page) {
   1084 				let zp = Zotero.getActiveZoteroPane();
   1085 				// TODO: Open pane if closed (macOS)
   1086 				if (zp) {
   1087 					zp.viewAttachment([item.id]);
   1088 				}
   1089 				return;
   1090 			}
   1091 			
   1092 			try {
   1093 				var opened = Zotero.OpenPDF.openToPage(path, page);
   1094 			}
   1095 			catch (e) {
   1096 				Zotero.logError(e);
   1097 			}
   1098 			// If something went wrong, just open PDF without page
   1099 			if (!opened) {
   1100 				let zp = Zotero.getActiveZoteroPane();
   1101 				// TODO: Open pane if closed (macOS)
   1102 				if (zp) {
   1103 					zp.viewAttachment([item.id]);
   1104 				}
   1105 				return;
   1106 			}
   1107 			Zotero.Notifier.trigger('open', 'file', item.id);
   1108 		},
   1109 		
   1110 		
   1111 		newChannel: function (uri) {
   1112 			this.doAction(uri);
   1113 		}
   1114 	};
   1115 	
   1116 	this._extensions[ZOTERO_SCHEME + "://data"] = DataExtension;
   1117 	this._extensions[ZOTERO_SCHEME + "://report"] = ReportExtension;
   1118 	this._extensions[ZOTERO_SCHEME + "://timeline"] = TimelineExtension;
   1119 	this._extensions[ZOTERO_SCHEME + "://attachment"] = AttachmentExtension;
   1120 	this._extensions[ZOTERO_SCHEME + "://select"] = SelectExtension;
   1121 	this._extensions[ZOTERO_SCHEME + "://fullscreen"] = FullscreenExtension;
   1122 	this._extensions[ZOTERO_SCHEME + "://debug"] = DebugExtension;
   1123 	this._extensions[ZOTERO_SCHEME + "://connector"] = ConnectorExtension;
   1124 	this._extensions[ZOTERO_SCHEME + "://open-pdf"] = OpenPDFExtension;
   1125 }
   1126 
   1127 
   1128 /*
   1129  * Implements nsIProtocolHandler
   1130  */
   1131 ZoteroProtocolHandler.prototype = {
   1132 	scheme: ZOTERO_SCHEME,
   1133 	
   1134 	defaultPort : -1,
   1135 	
   1136 	protocolFlags :
   1137 		Components.interfaces.nsIProtocolHandler.URI_NORELATIVE |
   1138 		Components.interfaces.nsIProtocolHandler.URI_NOAUTH |
   1139 		// DEBUG: This should be URI_IS_LOCAL_FILE, and MUST be if any
   1140 		// extensions that modify data are added
   1141 		//  - https://www.zotero.org/trac/ticket/1156
   1142 		//
   1143 		Components.interfaces.nsIProtocolHandler.URI_IS_LOCAL_FILE,
   1144 		//Components.interfaces.nsIProtocolHandler.URI_LOADABLE_BY_ANYONE,
   1145 		
   1146 	allowPort : function(port, scheme) {
   1147 		return false;
   1148 	},
   1149 	
   1150 	getExtension: function (uri) {
   1151 		let uriString = uri;
   1152 		if (uri instanceof Components.interfaces.nsIURI) {
   1153 			uriString = uri.spec;
   1154 		}
   1155 		uriString = uriString.toLowerCase();
   1156 		
   1157 		for (let extSpec in this._extensions) {
   1158 			if (uriString.startsWith(extSpec)) {
   1159 				return this._extensions[extSpec];
   1160 			}
   1161 		}
   1162 		
   1163 		return false;
   1164 	},
   1165 				
   1166 	newURI : function(spec, charset, baseURI) {
   1167 		var newURL = Components.classes["@mozilla.org/network/standard-url;1"]
   1168 			.createInstance(Components.interfaces.nsIStandardURL);
   1169 		newURL.init(1, -1, spec, charset, baseURI);
   1170 		return newURL.QueryInterface(Components.interfaces.nsIURI);
   1171 	},
   1172 	
   1173 	newChannel : function(uri) {
   1174 		var ioService = Components.classes["@mozilla.org/network/io-service;1"]
   1175 			.getService(Components.interfaces.nsIIOService);
   1176 		
   1177 		var chromeService = Components.classes["@mozilla.org/network/protocol;1?name=chrome"]
   1178 			.getService(Components.interfaces.nsIProtocolHandler);
   1179 		
   1180 		var newChannel = null;
   1181 		
   1182 		try {
   1183 			let ext = this.getExtension(uri);
   1184 			
   1185 			if (!ext) {
   1186 				// Return cancelled channel for unknown paths
   1187 				//
   1188 				// These can be in the form zotero://example.com/... -- maybe for "//example.com" URLs?
   1189 				var chromeURI = chromeService.newURI(DUMMY_CHROME_URL, null, null);
   1190 				var extChannel = chromeService.newChannel(chromeURI);
   1191 				var chromeRequest = extChannel.QueryInterface(Components.interfaces.nsIRequest);
   1192 				chromeRequest.cancel(0x804b0002); // BINDING_ABORTED
   1193 				return extChannel;
   1194 			}
   1195 			
   1196 			if (!this._principal) {
   1197 				if (ext.loadAsChrome) {
   1198 					var chromeURI = chromeService.newURI(DUMMY_CHROME_URL, null, null);
   1199 					var chromeChannel = chromeService.newChannel(chromeURI);
   1200 					
   1201 					// Cache System Principal from chrome request
   1202 					// so proxied pages load with chrome privileges
   1203 					this._principal = chromeChannel.owner;
   1204 					
   1205 					var chromeRequest = chromeChannel.QueryInterface(Components.interfaces.nsIRequest);
   1206 					chromeRequest.cancel(0x804b0002); // BINDING_ABORTED
   1207 				}
   1208 			}
   1209 			
   1210 			var extChannel = ext.newChannel(uri);
   1211 			// Extension returned null, so cancel request
   1212 			if (!extChannel) {
   1213 				var chromeURI = chromeService.newURI(DUMMY_CHROME_URL, null, null);
   1214 				var extChannel = chromeService.newChannel(chromeURI);
   1215 				var chromeRequest = extChannel.QueryInterface(Components.interfaces.nsIRequest);
   1216 				chromeRequest.cancel(0x804b0002); // BINDING_ABORTED
   1217 			}
   1218 			
   1219 			// Apply cached principal to extension channel
   1220 			if (this._principal) {
   1221 				extChannel.owner = this._principal;
   1222 			}
   1223 			
   1224 			if(!extChannel.originalURI) extChannel.originalURI = uri;
   1225 			
   1226 			return extChannel;
   1227 		}
   1228 		catch (e) {
   1229 			Components.utils.reportError(e);
   1230 			Zotero.debug(e, 1);
   1231 			throw Components.results.NS_ERROR_FAILURE;
   1232 		}
   1233 		
   1234 		return newChannel;
   1235 	},
   1236 	
   1237 	contractID: ZOTERO_PROTOCOL_CONTRACTID,
   1238 	classDescription: ZOTERO_PROTOCOL_NAME,
   1239 	classID: ZOTERO_PROTOCOL_CID,
   1240 	QueryInterface: XPCOMUtils.generateQI([Components.interfaces.nsISupports,
   1241 	                                       Components.interfaces.nsIProtocolHandler])
   1242 };
   1243 
   1244 
   1245 /**
   1246  * nsIChannel implementation that takes a promise-yielding generator that returns a
   1247  * string, nsIAsyncInputStream, or file
   1248  */
   1249 function AsyncChannel(uri, gen) {
   1250 	this._generator = gen;
   1251 	this._isPending = true;
   1252 	
   1253 	// nsIRequest
   1254 	this.name = uri;
   1255 	this.loadFlags = 0;
   1256 	this.loadGroup = null;
   1257 	this.status = 0;
   1258 	
   1259 	// nsIChannel
   1260 	this.contentLength = -1;
   1261 	this.contentType = "text/html";
   1262 	this.contentCharset = "utf-8";
   1263 	this.URI = uri;
   1264 	this.originalURI = uri;
   1265 	this.owner = null;
   1266 	this.notificationCallbacks = null;
   1267 	this.securityInfo = null;
   1268 }
   1269 
   1270 AsyncChannel.prototype = {
   1271 	asyncOpen: Zotero.Promise.coroutine(function* (streamListener, context) {
   1272 		if (this.loadGroup) this.loadGroup.addRequest(this, null);
   1273 		
   1274 		var channel = this;
   1275 		
   1276 		var resolve;
   1277 		var reject;
   1278 		var promise = new Zotero.Promise(function () {
   1279 			resolve = arguments[0];
   1280 			reject = arguments[1];
   1281 		});
   1282 		
   1283 		var listenerWrapper = {
   1284 			onStartRequest: function (request, context) {
   1285 				//Zotero.debug("Starting request");
   1286 				streamListener.onStartRequest(channel, context);
   1287 			},
   1288 			onDataAvailable: function (request, context, inputStream, offset, count) {
   1289 				//Zotero.debug("onDataAvailable");
   1290 				streamListener.onDataAvailable(channel, context, inputStream, offset, count);
   1291 			},
   1292 			onStopRequest: function (request, context, status) {
   1293 				//Zotero.debug("Stopping request");
   1294 				streamListener.onStopRequest(channel, context, status);
   1295 				channel._isPending = false;
   1296 				if (status == 0) {
   1297 					resolve();
   1298 				}
   1299 				else {
   1300 					reject(new Error("AsyncChannel request failed with status " + status));
   1301 				}
   1302 			}
   1303 		};
   1304 		
   1305 		//Zotero.debug("AsyncChannel's asyncOpen called");
   1306 		var t = new Date;
   1307 		
   1308 		// Proxy requests to other zotero:// URIs
   1309 		let uri2 = this.URI.clone();
   1310 		if (uri2.path.startsWith('/proxy/')) {
   1311 			let re = new RegExp(uri2.scheme + '://' + uri2.host + '/proxy/([^/]+)(.*)');
   1312 			let matches = uri2.spec.match(re);
   1313 			uri2.spec = uri2.scheme + '://' + matches[1] + '/' + (matches[2] ? matches[2] : '');
   1314 			var data = Zotero.File.getContentsFromURL(uri2.spec);
   1315 		}
   1316 		try {
   1317 			if (!data) {
   1318 				data = yield Zotero.spawn(channel._generator, channel)
   1319 			}
   1320 			if (typeof data == 'string') {
   1321 				//Zotero.debug("AsyncChannel: Got string from generator");
   1322 				
   1323 				listenerWrapper.onStartRequest(this, context);
   1324 				
   1325 				let converter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"]
   1326 					.createInstance(Components.interfaces.nsIScriptableUnicodeConverter);
   1327 				converter.charset = "UTF-8";
   1328 				let inputStream = converter.convertToInputStream(data);
   1329 				listenerWrapper.onDataAvailable(this, context, inputStream, 0, inputStream.available());
   1330 				
   1331 				listenerWrapper.onStopRequest(this, context, this.status);
   1332 			}
   1333 			// If an async input stream is given, pass the data asynchronously to the stream listener
   1334 			else if (data instanceof Ci.nsIAsyncInputStream) {
   1335 				//Zotero.debug("AsyncChannel: Got input stream from generator");
   1336 				
   1337 				var pump = Cc["@mozilla.org/network/input-stream-pump;1"].createInstance(Ci.nsIInputStreamPump);
   1338 				pump.init(data, -1, -1, 0, 0, true);
   1339 				pump.asyncRead(listenerWrapper, context);
   1340 			}
   1341 			else if (data instanceof Ci.nsIFile || data instanceof Ci.nsIURI) {
   1342 				if (data instanceof Ci.nsIFile) {
   1343 					//Zotero.debug("AsyncChannel: Got file from generator");
   1344 					data = ioService.newFileURI(data);
   1345 				}
   1346 				else {
   1347 					//Zotero.debug("AsyncChannel: Got URI from generator");
   1348 				}
   1349 
   1350 				let uri = data;
   1351 				uri.QueryInterface(Ci.nsIURL);
   1352 				this.contentType = Zotero.MIME.getMIMETypeFromExtension(uri.fileExtension);
   1353 				if (!this.contentType) {
   1354 					let sample = yield Zotero.File.getSample(data);
   1355 					this.contentType = Zotero.MIME.getMIMETypeFromData(sample);
   1356 				}
   1357 				
   1358 				Components.utils.import("resource://gre/modules/NetUtil.jsm");
   1359 				NetUtil.asyncFetch(data, function (inputStream, status) {
   1360 					if (!Components.isSuccessCode(status)) {
   1361 						reject();
   1362 						return;
   1363 					}
   1364 					
   1365 					listenerWrapper.onStartRequest(channel, context);
   1366 					try {
   1367 						listenerWrapper.onDataAvailable(channel, context, inputStream, 0, inputStream.available());
   1368 					}
   1369 					catch (e) {
   1370 						reject(e);
   1371 					}
   1372 					listenerWrapper.onStopRequest(channel, context, status);
   1373 				});
   1374 			}
   1375 			else if (data === undefined) {
   1376 				this.cancel(0x804b0002); // BINDING_ABORTED
   1377 			}
   1378 			else {
   1379 				throw new Error("Invalid return type (" + typeof data + ") from generator passed to AsyncChannel");
   1380 			}
   1381 			
   1382 			if (this._isPending) {
   1383 				//Zotero.debug("AsyncChannel request succeeded in " + (new Date - t) + " ms");
   1384 				channel._isPending = false;
   1385 			}
   1386 			
   1387 			return promise;
   1388 		} catch (e) {
   1389 			Zotero.debug(e, 1);
   1390 			if (channel._isPending) {
   1391 				streamListener.onStopRequest(channel, context, Components.results.NS_ERROR_FAILURE);
   1392 				channel._isPending = false;
   1393 			}
   1394 			throw e;
   1395 		} finally {
   1396 			if (channel.loadGroup) channel.loadGroup.removeRequest(channel, null, 0);
   1397 		}
   1398 	}),
   1399 	
   1400 	// nsIRequest
   1401 	isPending: function () {
   1402 		return this._isPending;
   1403 	},
   1404 	
   1405 	cancel: function (status) {
   1406 		Zotero.debug("Cancelling");
   1407 		this.status = status;
   1408 		this._isPending = false;
   1409 	},
   1410 	
   1411 	resume: function () {
   1412 		Zotero.debug("Resuming");
   1413 	},
   1414 	
   1415 	suspend: function () {
   1416 		Zotero.debug("Suspending");
   1417 	},
   1418 	
   1419 	// nsIWritablePropertyBag
   1420 	setProperty: function (prop, val) {
   1421 		this[prop] = val;
   1422 	},
   1423 	
   1424 	
   1425 	deleteProperty: function (prop) {
   1426 		delete this[prop];
   1427 	},
   1428 	
   1429 	
   1430 	QueryInterface: function (iid) {
   1431 		if (iid.equals(Components.interfaces.nsISupports)
   1432 				|| iid.equals(Components.interfaces.nsIRequest)
   1433 				|| iid.equals(Components.interfaces.nsIChannel)
   1434 				// pdf.js wants this
   1435 				|| iid.equals(Components.interfaces.nsIWritablePropertyBag)) {
   1436 			return this;
   1437 		}
   1438 		throw Components.results.NS_ERROR_NO_INTERFACE;
   1439 	}
   1440 };
   1441 
   1442 
   1443 var NSGetFactory = XPCOMUtils.generateNSGetFactory([ZoteroProtocolHandler]);