/*
    ***** 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 *****
*/

const ZOTERO_SCHEME = "zotero";
const ZOTERO_PROTOCOL_CID = Components.ID("{9BC3D762-9038-486A-9D70-C997AF848A7C}");
const ZOTERO_PROTOCOL_CONTRACTID = "@mozilla.org/network/protocol;1?name=" + ZOTERO_SCHEME;
const ZOTERO_PROTOCOL_NAME = "Zotero Chrome Extension Protocol";

const Cc = Components.classes;
const Ci = Components.interfaces;
const Cr = Components.results;

Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");

// Dummy chrome URL used to obtain a valid chrome channel
// This one was chosen at random and should be able to be substituted
// for any other well known chrome URL in the browser installation
const DUMMY_CHROME_URL = "chrome://mozapps/content/xpinstall/xpinstallConfirm.xul";

var Zotero = Components.classes["@zotero.org/Zotero;1"]
	.getService(Components.interfaces.nsISupports)
	.wrappedJSObject;

var ioService = Components.classes["@mozilla.org/network/io-service;1"]
	.getService(Components.interfaces.nsIIOService);

function ZoteroProtocolHandler() {
	this.wrappedJSObject = this;
	this._principal = null;
	this._extensions = {};
	
	
	/**
	 * 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.path;
				if (!path) {
					return 'Invalid URL';
				}
				// Strip leading '/'
				path = path.substr(1);
				
				// Proxy CSS files
				if (path.endsWith('.css')) {
					var chromeURL = 'chrome://zotero/skin/report/' + path;
					Zotero.debug(chromeURL);
					var ios = Components.classes["@mozilla.org/network/io-service;1"]
						.getService(Components.interfaces.nsIIOService);
					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),
							function () {
								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;
				
				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
				//
				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);
			});
		}
	};
	
	
	/*
		zotero://attachment/[id]/
	*/
	var AttachmentExtension = {
		loadAsChrome: false,
		
		newChannel: function (uri) {
			var self = this;
			
			return new AsyncChannel(uri, function* () {
				try {
					var errorMsg;
					var [id, fileName] = uri.path.substr(1).split('/');
					
					if (parseInt(id) != id) {
						// Proxy annotation icons
						if (id.match(/^annotation.*\.(png|html|css|gif)$/)) {
							var chromeURL = 'chrome://zotero/skin/' + id;
							var ios = Components.classes["@mozilla.org/network/io-service;1"].
										getService(Components.interfaces.nsIIOService);
							let uri = ios.newURI(chromeURL, null, null);
							var chromeReg = Components.classes["@mozilla.org/chrome/chrome-registry;1"]
									.getService(Components.interfaces.nsIChromeRegistry);
							var fileURI = chromeReg.convertChromeURL(uri);
						}
						else {
							return self._errorChannel("Attachment id not an integer");
						}
					}
					
					if (!fileURI) {
						var item = yield Zotero.Items.getAsync(id);
						if (!item) {
							return self._errorChannel("Item not found");
						}
						var path = yield item.getFilePathAsync();
						if (!path) {
							return self._errorChannel("File not found");
						}
						if (fileName) {
							Components.utils.import("resource://gre/modules/osfile.jsm");
							path = OS.Path.join(OS.Path.dirname(path), fileName)
							if (!(yield OS.File.exists(path))) {
								return self._errorChannel("File 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 is not allowed
					this.originalURI = uri;
					
					return Zotero.File.pathToFile(path);
				}
				catch (e) {
					Zotero.debug(e);
					throw (e);
				}
			});
		},
		
		
		_errorChannel: function (msg) {
			this.status = Components.results.NS_ERROR_FAILURE;
			this.contentType = 'text/plain';
			return msg;
		}
	};
	
	
	/**
	 * 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.path;
			if (!path) {
				return 'Invalid URL';
			}
			// Strip leading '/'
			path = path.substr(1);
			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;
			});
			router.run(path);
			
			Zotero.API.parseParams(params);
			var results = yield Zotero.API.getResultsFromParams(params);
			
			if (!results.length) {
				var msg = "Items not found";
				Zotero.debug(msg, 2);
				Components.utils.reportError(msg);
				return;
			}
			
			var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
				.getService(Components.interfaces.nsIWindowMediator);
			var win = wm.getMostRecentWindow("navigator:browser");
			
			// TODO: Currently only able to select one item
			return win.ZoteroPane.selectItem(results[0].id);
		}),
		
		newChannel: function (uri) {
			this.doAction(uri);
		}
	};
	
	/*
		zotero://fullscreen
	*/
	var FullscreenExtension = {
		loadAsChrome: false,
		
		newChannel: function (uri) {
			return new AsyncChannel(uri, function* () {
				try {
					var window = Components.classes["@mozilla.org/embedcomp/window-watcher;1"]
						.getService(Components.interfaces.nsIWindowWatcher)
						.openWindow(null, 'chrome://zotero/content/standalone/standalone.xul', '',
							'chrome,centerscreen,resizable', null);
				}
				catch (e) {
					Zotero.debug(e, 1);
					throw e;
				}
			});
		}
	};
	
	
	/*
		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);
		var ioService = Components.classes["@mozilla.org/network/io-service;1"]
			.getService(Components.interfaces.nsIIOService);
		
		this.name = uri;
		this.URI = ioService.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 ioService = Components.classes["@mozilla.org/network/io-service;1"]
				.getService(Components.interfaces.nsIIOService);
			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.path;
				originalURI = decodeURIComponent(originalURI.substr(originalURI.indexOf("/")+1));
				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;
			}
		}
	};
	
	this._extensions[ZOTERO_SCHEME + "://data"] = DataExtension;
	this._extensions[ZOTERO_SCHEME + "://report"] = ReportExtension;
	this._extensions[ZOTERO_SCHEME + "://timeline"] = TimelineExtension;
	this._extensions[ZOTERO_SCHEME + "://attachment"] = AttachmentExtension;
	this._extensions[ZOTERO_SCHEME + "://select"] = SelectExtension;
	this._extensions[ZOTERO_SCHEME + "://fullscreen"] = FullscreenExtension;
	this._extensions[ZOTERO_SCHEME + "://debug"] = DebugExtension;
	this._extensions[ZOTERO_SCHEME + "://connector"] = ConnectorExtension;
}


/*
 * 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) {
		var newURL = Components.classes["@mozilla.org/network/standard-url;1"]
			.createInstance(Components.interfaces.nsIStandardURL);
		newURL.init(1, -1, spec, charset, baseURI);
		return newURL.QueryInterface(Components.interfaces.nsIURI);
	},
	
	newChannel : function(uri) {
		var ioService = Components.classes["@mozilla.org/network/io-service;1"]
			.getService(Components.interfaces.nsIIOService);
		
		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) {
				if (ext.loadAsChrome) {
					var chromeURI = chromeService.newURI(DUMMY_CHROME_URL, null, null);
					var chromeChannel = chromeService.newChannel(chromeURI);
					
					// Cache System Principal from chrome request
					// so proxied pages load with chrome privileges
					this._principal = chromeChannel.owner;
					
					var chromeRequest = chromeChannel.QueryInterface(Components.interfaces.nsIRequest);
					chromeRequest.cancel(0x804b0002); // BINDING_ABORTED
				}
			}
			
			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;
		
		// Proxy requests to other zotero:// URIs
		let uri2 = this.URI.clone();
		if (uri2.path.startsWith('/proxy/')) {
			let re = new RegExp(uri2.scheme + '://' + uri2.host + '/proxy/([^/]+)(.*)');
			let matches = uri2.spec.match(re);
			uri2.spec = uri2.scheme + '://' + matches[1] + '/' + (matches[2] ? matches[2] : '');
			var data = Zotero.File.getContentsFromURL(uri2.spec);
		}
		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 = ioService.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(data, 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]);