www

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

server_connector.js (44238B)


      1 /*
      2     ***** BEGIN LICENSE BLOCK *****
      3     
      4     Copyright © 2011 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     ***** END LICENSE BLOCK *****
     24 */
     25 const CONNECTOR_API_VERSION = 2;
     26 
     27 Zotero.Server.Connector = {
     28 	_waitingForSelection: {},
     29 	
     30 	getSaveTarget: function (allowReadOnly) {
     31 		var zp = Zotero.getActiveZoteroPane();
     32 		var library = null;
     33 		var collection = null;
     34 		var editable = null;
     35 		
     36 		if (zp && zp.collectionsView) {
     37 			if (zp.collectionsView.editable || allowReadOnly) {
     38 				library = Zotero.Libraries.get(zp.getSelectedLibraryID());
     39 				collection = zp.getSelectedCollection();
     40 				editable = zp.collectionsView.editable;
     41 			}
     42 			// If not editable, switch to My Library if it exists and is editable
     43 			else {
     44 				let userLibrary = Zotero.Libraries.userLibrary;
     45 				if (userLibrary && userLibrary.editable) {
     46 					Zotero.debug("Save target isn't editable -- switching to My Library");
     47 					
     48 					// Don't wait for this, because we don't want to slow down all conenctor
     49 					// requests by making this function async
     50 					zp.collectionsView.selectByID(userLibrary.treeViewID);
     51 					
     52 					library = userLibrary;
     53 					collection = null;
     54 					editable = true;
     55 				}
     56 			}
     57 		}
     58 		else {
     59 			let id = Zotero.Prefs.get('lastViewedFolder');
     60 			if (id) {
     61 				({ library, collection, editable } = this.resolveTarget(id));
     62 				if (!editable && !allowReadOnly) {
     63 					let userLibrary = Zotero.Libraries.userLibrary;
     64 					if (userLibrary && userLibrary.editable) {
     65 						Zotero.debug("Save target isn't editable -- switching to My Library");
     66 						let treeViewID = userLibrary.treeViewID;
     67 						Zotero.Prefs.set('lastViewedFolder', treeViewID);
     68 						({ library, collection, editable } = this.resolveTarget(treeViewID));
     69 					}
     70 				}
     71 			}
     72 		}
     73 		
     74 		// Default to My Library if present if pane not yet opened
     75 		// (which should never be the case anymore)
     76 		if (!library) {
     77 			let userLibrary = Zotero.Libraries.userLibrary;
     78 			if (userLibrary && userLibrary.editable) {
     79 				library = userLibrary;
     80 			}
     81 		}
     82 		
     83 		return { library, collection, editable };
     84 	},
     85 	
     86 	resolveTarget: function (targetID) {
     87 		var library;
     88 		var collection;
     89 		var editable;
     90 		
     91 		var type = targetID[0];
     92 		var id = parseInt(('' + targetID).substr(1));
     93 		
     94 		switch (type) {
     95 		case 'L':
     96 			library = Zotero.Libraries.get(id);
     97 			editable = library.editable;
     98 			break;
     99 		
    100 		case 'C':
    101 			collection = Zotero.Collections.get(id);
    102 			library = collection.library;
    103 			editable = collection.editable;
    104 			break;
    105 		
    106 		default:
    107 			throw new Error(`Unsupported target type '${type}'`);
    108 		}
    109 		
    110 		return { library, collection, editable };
    111 	}
    112 };
    113 Zotero.Server.Connector.Data = {};
    114 
    115 Zotero.Server.Connector.SessionManager = {
    116 	_sessions: new Map(),
    117 	
    118 	get: function (id) {
    119 		return this._sessions.get(id);
    120 	},
    121 	
    122 	create: function (id, action, requestData) {
    123 		// Legacy connector
    124 		if (!id) {
    125 			Zotero.debug("No session id provided by client", 2);
    126 			id = Zotero.Utilities.randomString();
    127 		}
    128 		if (this._sessions.has(id)) {
    129 			throw new Error(`Session ID ${id} exists`);
    130 		}
    131 		Zotero.debug("Creating connector save session " + id);
    132 		var session = new Zotero.Server.Connector.SaveSession(id, action, requestData);
    133 		this._sessions.set(id, session);
    134 		this.gc();
    135 		return session;
    136 	},
    137 	
    138 	gc: function () {
    139 		// Delete sessions older than 10 minutes, or older than 1 minute if more than 10 sessions
    140 		var ttl = this._sessions.size >= 10 ? 60 : 600;
    141 		var deleteBefore = new Date() - ttl * 1000;
    142 		
    143 		for (let session of this._sessions) {
    144 			if (session.created < deleteBefore) {
    145 				this._session.delete(session.id);
    146 			}
    147 		}
    148 	}
    149 };
    150 
    151 
    152 Zotero.Server.Connector.SaveSession = function (id, action, requestData) {
    153 	this.id = id;
    154 	this.created = new Date();
    155 	this._action = action;
    156 	this._requestData = requestData;
    157 	this._items = new Set();
    158 };
    159 
    160 Zotero.Server.Connector.SaveSession.prototype.addItem = async function (item) {
    161 	return this.addItems([item]);
    162 };
    163 
    164 Zotero.Server.Connector.SaveSession.prototype.addItems = async function (items) {
    165 	for (let item of items) {
    166 		this._items.add(item);
    167 	}
    168 	
    169 	// Update the items with the current target data, in case it changed since the save began
    170 	await this._updateItems(items);
    171 };
    172 
    173 /**
    174  * Change the target data for this session and update any items that have already been saved
    175  */
    176 Zotero.Server.Connector.SaveSession.prototype.update = async function (targetID, tags) {
    177 	var previousTargetID = this._currentTargetID;
    178 	this._currentTargetID = targetID;
    179 	this._currentTags = tags || "";
    180 	
    181 	// Select new destination in collections pane
    182 	var zp = Zotero.getActiveZoteroPane();
    183 	if (zp && zp.collectionsView) {
    184 		await zp.collectionsView.selectByID(targetID);
    185 	}
    186 	// If window is closed, select target collection re-open
    187 	else {
    188 		Zotero.Prefs.set('lastViewedFolder', targetID);
    189 	}
    190 	
    191 	// If moving from a non-filesEditable library to a filesEditable library, resave from
    192 	// original data, since there might be files that weren't saved or were removed
    193 	if (previousTargetID && previousTargetID != targetID) {
    194 		let { library: oldLibrary } = Zotero.Server.Connector.resolveTarget(previousTargetID);
    195 		let { library: newLibrary } = Zotero.Server.Connector.resolveTarget(targetID);
    196 		if (oldLibrary != newLibrary && !oldLibrary.filesEditable && newLibrary.filesEditable) {
    197 			Zotero.debug("Resaving items to filesEditable library");
    198 			if (this._action == 'saveItems' || this._action == 'saveSnapshot') {
    199 				// Delete old items
    200 				for (let item of this._items) {
    201 					await item.eraseTx();
    202 				}
    203 				let actionUC = Zotero.Utilities.capitalize(this._action);
    204 				let newItems = await Zotero.Server.Connector[actionUC].prototype[this._action](
    205 					targetID, this._requestData
    206 				);
    207 				// saveSnapshot only returns a single item
    208 				if (this._action == 'saveSnapshot') {
    209 					newItems = [newItems];
    210 				}
    211 				this._items = new Set(newItems);
    212 			}
    213 		}
    214 	}
    215 	
    216 	await this._updateItems(this._items);
    217 	
    218 	// If a single item was saved, select it (or its parent, if it now has one)
    219 	if (zp && zp.collectionsView && this._items.size == 1) {
    220 		let item = Array.from(this._items)[0];
    221 		item = item.isTopLevelItem() ? item : item.parentItem;
    222 		// Don't select if in trash
    223 		if (!item.deleted) {
    224 			await zp.selectItem(item.id);
    225 		}
    226 	}
    227 };
    228 
    229 /**
    230  * Update the passed items with the current target and tags
    231  */
    232 Zotero.Server.Connector.SaveSession.prototype._updateItems = Zotero.serial(async function (items) {
    233 	if (items.length == 0) {
    234 		return;
    235 	}
    236 	
    237 	var { library, collection, editable } = Zotero.Server.Connector.resolveTarget(this._currentTargetID);
    238 	var libraryID = library.libraryID;
    239 	
    240 	var tags = this._currentTags.trim();
    241 	tags = tags ? tags.split(/\s*,\s*/) : [];
    242 	
    243 	Zotero.debug("Updating items for connector save session " + this.id);
    244 	
    245 	for (let item of items) {
    246 		let newLibrary = Zotero.Libraries.get(library.libraryID);
    247 		
    248 		if (item.libraryID != libraryID) {
    249 			let newItem = await item.moveToLibrary(libraryID);
    250 			// Replace item in session
    251 			this._items.delete(item);
    252 			this._items.add(newItem);
    253 		}
    254 		
    255 		// If the item is now a child item (e.g., from Retrieve Metadata for PDF), update the
    256 		// parent item instead
    257 		if (!item.isTopLevelItem()) {
    258 			item = item.parentItem;
    259 		}
    260 		// Skip deleted items
    261 		if (!Zotero.Items.exists(item.id)) {
    262 			Zotero.debug(`Item ${item.id} in save session no longer exists`);
    263 			continue;
    264 		}
    265 		// Keep automatic tags
    266 		let originalTags = item.getTags().filter(tag => tag.type == 1);
    267 		item.setTags(originalTags.concat(tags));
    268 		item.setCollections(collection ? [collection.id] : []);
    269 		await item.saveTx();
    270 	}
    271 	
    272 	this._updateRecents();
    273 });
    274 
    275 
    276 Zotero.Server.Connector.SaveSession.prototype._updateRecents = function () {
    277 	var targetID = this._currentTargetID;
    278 	try {
    279 		let numRecents = 7;
    280 		let recents = Zotero.Prefs.get('recentSaveTargets') || '[]';
    281 		recents = JSON.parse(recents);
    282 		// If there's already a target from this session in the list, update it
    283 		for (let recent of recents) {
    284 			if (recent.sessionID == this.id) {
    285 				recent.id = targetID;
    286 				break;
    287 			}
    288 		}
    289 		// If a session is found with the same target, move it to the end without changing
    290 		// the sessionID. This could be the current session that we updated above or a different
    291 		// one. (We need to leave the old sessionID for the same target or we'll end up removing
    292 		// the previous target from the history if it's changed in the current one.)
    293 		let pos = recents.findIndex(r => r.id == targetID);
    294 		if (pos != -1) {
    295 			recents = [
    296 				...recents.slice(0, pos),
    297 				...recents.slice(pos + 1),
    298 				recents[pos]
    299 			];
    300 		}
    301 		// Otherwise just add this one to the end
    302 		else {
    303 			recents = recents.concat([{
    304 				id: targetID,
    305 				sessionID: this.id
    306 			}]);
    307 		}
    308 		recents = recents.slice(-1 * numRecents);
    309 		Zotero.Prefs.set('recentSaveTargets', JSON.stringify(recents));
    310 	}
    311 	catch (e) {
    312 		Zotero.logError(e);
    313 		Zotero.Prefs.clear('recentSaveTargets');
    314 	}
    315 };
    316 
    317 
    318 Zotero.Server.Connector.AttachmentProgressManager = new function() {
    319 	var attachmentsInProgress = new WeakMap(),
    320 		attachmentProgress = {},
    321 		id = 1;
    322 	
    323 	/**
    324 	 * Adds attachments to attachment progress manager
    325 	 */
    326 	this.add = function(attachments) {
    327 		for(var i=0; i<attachments.length; i++) {
    328 			var attachment = attachments[i];
    329 			attachmentsInProgress.set(attachment, (attachment.id = id++));
    330 		}
    331 	};
    332 	
    333 	/**
    334 	 * Called on attachment progress
    335 	 */
    336 	this.onProgress = function(attachment, progress, error) {
    337 		attachmentProgress[attachmentsInProgress.get(attachment)] = progress;
    338 	};
    339 		
    340 	/**
    341 	 * Gets progress for a given progressID
    342 	 */
    343 	this.getProgressForID = function(progressID) {
    344 		return progressID in attachmentProgress ? attachmentProgress[progressID] : 0;
    345 	};
    346 
    347 	/**
    348 	 * Check if we have received progress for a given attachment
    349 	 */
    350 	this.has = function(attachment) {
    351 		return attachmentsInProgress.has(attachment)
    352 			&& attachmentsInProgress.get(attachment) in attachmentProgress;
    353 	}
    354 };
    355 
    356 /**
    357  * Lists all available translators, including code for translators that should be run on every page
    358  *
    359  * Accepts:
    360  *		Nothing
    361  * Returns:
    362  *		Array of Zotero.Translator objects
    363  */
    364 Zotero.Server.Connector.GetTranslators = function() {};
    365 Zotero.Server.Endpoints["/connector/getTranslators"] = Zotero.Server.Connector.GetTranslators;
    366 Zotero.Server.Connector.GetTranslators.prototype = {
    367 	supportedMethods: ["POST"],
    368 	supportedDataTypes: ["application/json"],
    369 	permitBookmarklet: true,
    370 	
    371 	/**
    372 	 * Gets available translator list and other important data
    373 	 * @param {Object} data POST data or GET query string
    374 	 * @param {Function} sendResponseCallback function to send HTTP response
    375 	 */
    376 	init: function(data, sendResponseCallback) {
    377 		// Translator data
    378 		var me = this;
    379 		if(data.url) {
    380 			Zotero.Translators.getWebTranslatorsForLocation(data.url, data.rootUrl).then(function(data) {				
    381 				sendResponseCallback(200, "application/json",
    382 						JSON.stringify(me._serializeTranslators(data[0])));
    383 			});
    384 		} else {
    385 			Zotero.Translators.getAll().then(function(translators) {
    386 				var responseData = me._serializeTranslators(translators);
    387 				sendResponseCallback(200, "application/json", JSON.stringify(responseData));
    388 			}).catch(function(e) {
    389 				sendResponseCallback(500);
    390 				throw e;
    391 			}).done();
    392 		}
    393 	},
    394 	
    395 	_serializeTranslators: function(translators) {
    396 		var responseData = [];
    397 		let properties = ["translatorID", "translatorType", "label", "creator", "target", "targetAll",
    398 			"minVersion", "maxVersion", "priority", "browserSupport", "inRepository", "lastUpdated"];
    399 		for (var translator of translators) {
    400 			responseData.push(translator.serialize(properties));
    401 		}
    402 		return responseData;
    403 	}
    404 }
    405 
    406 /**
    407  * Detects whether there is an available translator to handle a given page
    408  *
    409  * Accepts:
    410  *		uri - The URI of the page to be saved
    411  *		html - document.innerHTML or equivalent
    412  *		cookie - document.cookie or equivalent
    413  *
    414  * Returns a list of available translators as an array
    415  */
    416 Zotero.Server.Connector.Detect = function() {};
    417 Zotero.Server.Endpoints["/connector/detect"] = Zotero.Server.Connector.Detect;
    418 Zotero.Server.Connector.Detect.prototype = {
    419 	supportedMethods: ["POST"],
    420 	supportedDataTypes: ["application/json"],
    421 	permitBookmarklet: true,
    422 	
    423 	/**
    424 	 * Loads HTML into a hidden browser and initiates translator detection
    425 	 * @param {Object} data POST data or GET query string
    426 	 * @param {Function} sendResponseCallback function to send HTTP response
    427 	 */
    428 	init: function(url, data, sendResponseCallback) {
    429 		this.sendResponse = sendResponseCallback;
    430 		this._parsedPostData = data;
    431 		
    432 		this._translate = new Zotero.Translate("web");
    433 		this._translate.setHandler("translators", function(obj, item) { me._translatorsAvailable(obj, item) });
    434 		
    435 		Zotero.Server.Connector.Data[this._parsedPostData["uri"]] = "<html>"+this._parsedPostData["html"]+"</html>";
    436 		this._browser = Zotero.Browser.createHiddenBrowser();
    437 		
    438 		var ioService = Components.classes["@mozilla.org/network/io-service;1"]  
    439 								  .getService(Components.interfaces.nsIIOService);  
    440 		var uri = ioService.newURI(this._parsedPostData["uri"], "UTF-8", null); 
    441 		
    442 		var pageShowCalled = false;
    443 		var me = this;
    444 		this._translate.setCookieSandbox(new Zotero.CookieSandbox(this._browser,
    445 			this._parsedPostData["uri"], this._parsedPostData["cookie"], url.userAgent));
    446 		this._browser.addEventListener("DOMContentLoaded", function() {
    447 			try {
    448 				if(me._browser.contentDocument.location.href == "about:blank") return;
    449 				if(pageShowCalled) return;
    450 				pageShowCalled = true;
    451 				delete Zotero.Server.Connector.Data[me._parsedPostData["uri"]];
    452 				
    453 				// get translators
    454 				me._translate.setDocument(me._browser.contentDocument);
    455 				me._translate.setLocation(me._parsedPostData["uri"], me._parsedPostData["uri"]);
    456 				me._translate.getTranslators();
    457 			} catch(e) {
    458 				sendResponseCallback(500);
    459 				throw e;
    460 			}
    461 		}, false);
    462 		
    463 		me._browser.loadURI("zotero://connector/"+encodeURIComponent(this._parsedPostData["uri"]));
    464 	},
    465 
    466 	/**
    467 	 * Callback to be executed when list of translators becomes available. Sends standard
    468 	 * translator passing properties with proxies where available for translators.
    469 	 * @param {Zotero.Translate} translate
    470 	 * @param {Zotero.Translator[]} translators
    471 	 */
    472 	_translatorsAvailable: function(translate, translators) {
    473 		translators = translators.map(function(translator) {
    474 			translator = translator.serialize(TRANSLATOR_PASSING_PROPERTIES.concat('proxy'));
    475 			translator.proxy = translator.proxy ? translator.proxy.toJSON() : null;
    476 			return translator;
    477 		});
    478 		this.sendResponse(200, "application/json", JSON.stringify(translators));
    479 		
    480 		Zotero.Browser.deleteHiddenBrowser(this._browser);
    481 	}
    482 }
    483 
    484 /**
    485  * Performs translation of a given page
    486  *
    487  * Accepts:
    488  *		uri - The URI of the page to be saved
    489  *		html - document.innerHTML or equivalent
    490  *		cookie - document.cookie or equivalent
    491  *		translatorID [optional] - a translator ID as returned by /connector/detect
    492  *
    493  * Returns:
    494  *		If a single item, sends response code 201 with item in body.
    495  *		If multiple items, sends response code 300 with the following content:
    496  *			items - list of items in the format typically passed to the selectItems handler
    497  *			instanceID - an ID that must be maintained for the subsequent Zotero.Connector.Select call
    498  *			uri - the URI of the page for which multiple items are available
    499  */
    500 Zotero.Server.Connector.SavePage = function() {};
    501 Zotero.Server.Endpoints["/connector/savePage"] = Zotero.Server.Connector.SavePage;
    502 Zotero.Server.Connector.SavePage.prototype = {
    503 	supportedMethods: ["POST"],
    504 	supportedDataTypes: ["application/json"],
    505 	permitBookmarklet: true,
    506 	
    507 	/**
    508 	 * Either loads HTML into a hidden browser and initiates translation, or saves items directly
    509 	 * to the database
    510 	 * @param {Object} data POST data or GET query string
    511 	 * @param {Function} sendResponseCallback function to send HTTP response
    512 	 */
    513 	init: function(url, data, sendResponseCallback) {
    514 		var { library, collection, editable } = Zotero.Server.Connector.getSaveTarget();
    515 		
    516 		// Shouldn't happen as long as My Library exists
    517 		if (!library.editable) {
    518 			Zotero.logError("Can't add item to read-only library " + library.name);
    519 			return sendResponseCallback(500, "application/json", JSON.stringify({ libraryEditable: false }));
    520 		}
    521 		
    522 		this.sendResponse = sendResponseCallback;
    523 		Zotero.Server.Connector.Detect.prototype.init.apply(this, [url, data, sendResponseCallback])
    524 	},
    525 
    526 	/**
    527 	 * Callback to be executed when items must be selected
    528 	 * @param {Zotero.Translate} translate
    529 	 * @param {Object} itemList ID=>text pairs representing available items
    530 	 */
    531 	_selectItems: function(translate, itemList, callback) {
    532 		var instanceID = Zotero.randomString();
    533 		Zotero.Server.Connector._waitingForSelection[instanceID] = this;
    534 		
    535 		// Fix for translators that don't create item lists as objects
    536 		if(itemList.push && typeof itemList.push === "function") {
    537 			var newItemList = {};
    538 			for(var item in itemList) {
    539 				newItemList[item] = itemList[item];
    540 			}
    541 			itemList = newItemList;
    542 		}
    543 		
    544 		// Send "Multiple Choices" HTTP response
    545 		this.sendResponse(300, "application/json", JSON.stringify({selectItems: itemList, instanceID: instanceID, uri: this._parsedPostData.uri}));
    546 		this.selectedItemsCallback = callback;
    547 	},
    548 
    549 	/**
    550 	 * Callback to be executed when list of translators becomes available. Opens progress window,
    551 	 * selects specified translator, and initiates translation.
    552 	 * @param {Zotero.Translate} translate
    553 	 * @param {Zotero.Translator[]} translators
    554 	 */
    555 	_translatorsAvailable: function(translate, translators) {
    556 		// make sure translatorsAvailable succeded
    557 		if(!translators.length) {
    558 			Zotero.Browser.deleteHiddenBrowser(this._browser);
    559 			this.sendResponse(500);
    560 			return;
    561 		}
    562 		
    563 		var { library, collection, editable } = Zotero.Server.Connector.getSaveTarget();
    564 		var libraryID = library.libraryID;
    565 		
    566 		// set handlers for translation
    567 		var me = this;
    568 		var jsonItems = [];
    569 		translate.setHandler("select", function(obj, item, callback) { return me._selectItems(obj, item, callback) });
    570 		translate.setHandler("itemDone", function(obj, item, jsonItem) {
    571 			Zotero.Server.Connector.AttachmentProgressManager.add(jsonItem.attachments);
    572 			jsonItems.push(jsonItem);
    573 		});
    574 		translate.setHandler("attachmentProgress", function(obj, attachment, progress, error) {
    575 			Zotero.Server.Connector.AttachmentProgressManager.onProgress(attachment, progress, error);
    576 		});
    577 		translate.setHandler("done", function(obj, item) {
    578 			Zotero.Browser.deleteHiddenBrowser(me._browser);
    579 			if(jsonItems.length || me.selectedItems === false) {
    580 				me.sendResponse(201, "application/json", JSON.stringify({items: jsonItems}));
    581 			} else {
    582 				me.sendResponse(500);
    583 			}
    584 		});
    585 		
    586 		if (this._parsedPostData.translatorID) {
    587 			translate.setTranslator(this._parsedPostData.translatorID);
    588 		} else {
    589 			translate.setTranslator(translators[0]);
    590 		}
    591 		translate.translate({libraryID, collections: collection ? [collection.id] : false});
    592 	}
    593 }
    594 
    595 /**
    596  * Saves items to DB
    597  *
    598  * Accepts:
    599  *		items - an array of JSON format items
    600  * Returns:
    601  *		201 response code with item in body.
    602  */
    603 Zotero.Server.Connector.SaveItems = function() {};
    604 Zotero.Server.Endpoints["/connector/saveItems"] = Zotero.Server.Connector.SaveItems;
    605 Zotero.Server.Connector.SaveItems.prototype = {
    606 	supportedMethods: ["POST"],
    607 	supportedDataTypes: ["application/json"],
    608 	permitBookmarklet: true,
    609 	
    610 	/**
    611 	 * Either loads HTML into a hidden browser and initiates translation, or saves items directly
    612 	 * to the database
    613 	 */
    614 	init: Zotero.Promise.coroutine(function* (requestData) {
    615 		var data = requestData.data;
    616 		
    617 		var { library, collection, editable } = Zotero.Server.Connector.getSaveTarget();
    618 		var libraryID = library.libraryID;
    619 		var targetID = collection ? collection.treeViewID : library.treeViewID;
    620 		
    621 		try {
    622 			var session = Zotero.Server.Connector.SessionManager.create(
    623 				data.sessionID,
    624 				'saveItems',
    625 				requestData
    626 			);
    627 		}
    628 		catch (e) {
    629 			return [409, "application/json", JSON.stringify({ error: "SESSION_EXISTS" })];
    630 		}
    631 		yield session.update(targetID);
    632 		
    633 		// Shouldn't happen as long as My Library exists
    634 		if (!library.editable) {
    635 			Zotero.logError("Can't add item to read-only library " + library.name);
    636 			return [500, "application/json", JSON.stringify({ libraryEditable: false })];
    637 		}
    638 		
    639 		return new Zotero.Promise((resolve) => {
    640 			try {
    641 				this.saveItems(
    642 					targetID,
    643 					requestData,
    644 					function (topLevelItems) {
    645 						resolve([201, "application/json", JSON.stringify({items: topLevelItems})]);
    646 					}
    647 				)
    648 				// Add items to session once all attachments have been saved
    649 				.then(function (items) {
    650 					session.addItems(items);
    651 				});
    652 			}
    653 			catch (e) {
    654 				Zotero.logError(e);
    655 				resolve(500);
    656 			}
    657 		});
    658 	}),
    659 	
    660 	saveItems: async function (target, requestData, onTopLevelItemsDone) {
    661 		var { library, collection, editable } = Zotero.Server.Connector.resolveTarget(target);
    662 		
    663 		var data = requestData.data;
    664 		var cookieSandbox = data.uri
    665 			? new Zotero.CookieSandbox(
    666 				null,
    667 				data.uri,
    668 				data.detailedCookies ? "" : data.cookie || "",
    669 				requestData.headers["User-Agent"]
    670 			)
    671 			: null;
    672 		if (cookieSandbox && data.detailedCookies) {
    673 			cookieSandbox.addCookiesFromHeader(data.detailedCookies);
    674 		}
    675 		
    676 		for (let item of data.items) {
    677 			Zotero.Server.Connector.AttachmentProgressManager.add(item.attachments);
    678 		}
    679 		
    680 		var proxy = data.proxy && new Zotero.Proxy(data.proxy);
    681 		
    682 		// Save items
    683 		var itemSaver = new Zotero.Translate.ItemSaver({
    684 			libraryID: library.libraryID,
    685 			collections: collection ? [collection.id] : undefined,
    686 			attachmentMode: Zotero.Translate.ItemSaver.ATTACHMENT_MODE_DOWNLOAD,
    687 			forceTagType: 1,
    688 			referrer: data.uri,
    689 			cookieSandbox,
    690 			proxy
    691 		});
    692 		return itemSaver.saveItems(
    693 			data.items,
    694 			Zotero.Server.Connector.AttachmentProgressManager.onProgress,
    695 			function () {
    696 				// Remove attachments from item.attachments that aren't being saved. We have to
    697 				// clone the items so that we don't mutate the data stored in the session.
    698 				var savedItems = [...data.items.map(item => Object.assign({}, item))];
    699 				for (let item of savedItems) {
    700 					item.attachments = item.attachments
    701 						.filter(attachment => {
    702 							return Zotero.Server.Connector.AttachmentProgressManager.has(attachment);
    703 						});
    704 				}
    705 				if (onTopLevelItemsDone) {
    706 					onTopLevelItemsDone(savedItems);
    707 				}
    708 			}
    709 		);
    710 	}
    711 }
    712 
    713 /**
    714  * Saves a snapshot to the DB
    715  *
    716  * Accepts:
    717  *		uri - The URI of the page to be saved
    718  *		html - document.innerHTML or equivalent
    719  *		cookie - document.cookie or equivalent
    720  * Returns:
    721  *		Nothing (200 OK response)
    722  */
    723 Zotero.Server.Connector.SaveSnapshot = function() {};
    724 Zotero.Server.Endpoints["/connector/saveSnapshot"] = Zotero.Server.Connector.SaveSnapshot;
    725 Zotero.Server.Connector.SaveSnapshot.prototype = {
    726 	supportedMethods: ["POST"],
    727 	supportedDataTypes: ["application/json"],
    728 	permitBookmarklet: true,
    729 	
    730 	/**
    731 	 * Save snapshot
    732 	 */
    733 	init: async function (requestData) {
    734 		var data = requestData.data;
    735 		
    736 		var { library, collection, editable } = Zotero.Server.Connector.getSaveTarget();
    737 		var targetID = collection ? collection.treeViewID : library.treeViewID;
    738 		
    739 		try {
    740 			var session = Zotero.Server.Connector.SessionManager.create(
    741 				data.sessionID,
    742 				'saveSnapshot',
    743 				requestData
    744 			);
    745 		}
    746 		catch (e) {
    747 			return [409, "application/json", JSON.stringify({ error: "SESSION_EXISTS" })];
    748 		}
    749 		await session.update(collection ? collection.treeViewID : library.treeViewID);
    750 		
    751 		// Shouldn't happen as long as My Library exists
    752 		if (!library.editable) {
    753 			Zotero.logError("Can't add item to read-only library " + library.name);
    754 			return [500, "application/json", JSON.stringify({ libraryEditable: false })];
    755 		}
    756 		
    757 		try {
    758 			let item = await this.saveSnapshot(targetID, requestData);
    759 			await session.addItem(item);
    760 		}
    761 		catch (e) {
    762 			Zotero.logError(e);
    763 			return 500;
    764 		}
    765 		
    766 		return 201;
    767 	},
    768 	
    769 	saveSnapshot: async function (target, requestData) {
    770 		var { library, collection, editable } = Zotero.Server.Connector.resolveTarget(target);
    771 		var libraryID = library.libraryID;
    772 		var data = requestData.data;
    773 		
    774 		var cookieSandbox = data.url
    775 			? new Zotero.CookieSandbox(
    776 				null,
    777 				data.url,
    778 				data.detailedCookies ? "" : data.cookie || "",
    779 				requestData.headers["User-Agent"]
    780 			)
    781 			: null;
    782 		if (cookieSandbox && data.detailedCookies) {
    783 			cookieSandbox.addCookiesFromHeader(data.detailedCookies);
    784 		}
    785 		
    786 		if (data.pdf && library.filesEditable) {
    787 			let item = await Zotero.Attachments.importFromURL({
    788 				libraryID,
    789 				url: data.url,
    790 				collections: collection ? [collection.id] : undefined,
    791 				contentType: "application/pdf",
    792 				cookieSandbox
    793 			});
    794 			
    795 			// Automatically recognize PDF
    796 			Zotero.RecognizePDF.autoRecognizeItems([item]);
    797 			
    798 			return item;
    799 		}
    800 		
    801 		return new Zotero.Promise((resolve, reject) => {
    802 			Zotero.Server.Connector.Data[data.url] = "<html>" + data.html + "</html>";
    803 			Zotero.HTTP.loadDocuments(
    804 				["zotero://connector/" + encodeURIComponent(data.url)],
    805 				async function (doc) {
    806 					delete Zotero.Server.Connector.Data[data.url];
    807 					
    808 					try {
    809 						// Create new webpage item
    810 						let item = new Zotero.Item("webpage");
    811 						item.libraryID = libraryID;
    812 						item.setField("title", doc.title);
    813 						item.setField("url", data.url);
    814 						item.setField("accessDate", "CURRENT_TIMESTAMP");
    815 						if (collection) {
    816 							item.setCollections([collection.id]);
    817 						}
    818 						var itemID = await item.saveTx();
    819 						
    820 						// Save snapshot
    821 						if (library.filesEditable && !data.skipSnapshot) {
    822 							await Zotero.Attachments.importFromDocument({
    823 								document: doc,
    824 								parentItemID: itemID
    825 							});
    826 						}
    827 						
    828 						resolve(item);
    829 					}
    830 					catch (e) {
    831 						reject(e);
    832 					}
    833 				},
    834 				null,
    835 				null,
    836 				false,
    837 				cookieSandbox
    838 			);
    839 		});
    840 	}
    841 }
    842 
    843 /**
    844  * Handle item selection
    845  *
    846  * Accepts:
    847  *		selectedItems - a list of items to translate in ID => text format as returned by a selectItems handler
    848  *		instanceID - as returned by savePage call
    849  * Returns:
    850  *		201 response code with empty body
    851  */
    852 Zotero.Server.Connector.SelectItems = function() {};
    853 Zotero.Server.Endpoints["/connector/selectItems"] = Zotero.Server.Connector.SelectItems;
    854 Zotero.Server.Connector.SelectItems.prototype = {
    855 	supportedMethods: ["POST"],
    856 	supportedDataTypes: ["application/json"],
    857 	permitBookmarklet: true,
    858 	
    859 	/**
    860 	 * Finishes up translation when item selection is complete
    861 	 * @param {String} data POST data or GET query string
    862 	 * @param {Function} sendResponseCallback function to send HTTP response
    863 	 */
    864 	init: function(data, sendResponseCallback) {
    865 		var saveInstance = Zotero.Server.Connector._waitingForSelection[data.instanceID];
    866 		saveInstance.sendResponse = sendResponseCallback;
    867 		
    868 		var selectedItems = false;
    869 		for(var i in data.selectedItems) {
    870 			selectedItems = data.selectedItems;
    871 			break;
    872 		}
    873 		saveInstance.selectedItemsCallback(selectedItems);
    874 	}
    875 }
    876 
    877 /**
    878  * 
    879  *
    880  * Accepts:
    881  *		sessionID - A session ID previously passed to /saveItems
    882  *		target - A treeViewID (L1, C23, etc.) for the library or collection to save to
    883  *		tags - A string of tags separated by commas
    884  *
    885  * Returns:
    886  *		200 response on successful change
    887  *		400 on error with 'error' property in JSON
    888  */
    889 Zotero.Server.Connector.UpdateSession = function() {};
    890 Zotero.Server.Endpoints["/connector/updateSession"] = Zotero.Server.Connector.UpdateSession;
    891 Zotero.Server.Connector.UpdateSession.prototype = {
    892 	supportedMethods: ["POST"],
    893 	supportedDataTypes: ["application/json"],
    894 	permitBookmarklet: true,
    895 	
    896 	init: async function (requestData) {
    897 		var data = requestData.data
    898 		
    899 		if (!data.sessionID) {
    900 			return [400, "application/json", JSON.stringify({ error: "SESSION_ID_NOT_PROVIDED" })];
    901 		}
    902 		
    903 		var session = Zotero.Server.Connector.SessionManager.get(data.sessionID);
    904 		if (!session) {
    905 			Zotero.debug("Can't find session " + data.sessionID, 1);
    906 			return [400, "application/json", JSON.stringify({ error: "SESSION_NOT_FOUND" })];
    907 		}
    908 		
    909 		// Parse treeViewID
    910 		var [type, id] = [data.target[0], parseInt(data.target.substr(1))];
    911 		var tags = data.tags;
    912 		
    913 		if (type == 'C') {
    914 			let collection = await Zotero.Collections.getAsync(id);
    915 			if (!collection) {
    916 				return [400, "application/json", JSON.stringify({ error: "COLLECTION_NOT_FOUND" })];
    917 			}
    918 		}
    919 		
    920 		await session.update(data.target, tags);
    921 		
    922 		return [200, "application/json", JSON.stringify({})];
    923 	}
    924 };
    925 
    926 Zotero.Server.Connector.DelaySync = function () {};
    927 Zotero.Server.Endpoints["/connector/delaySync"] = Zotero.Server.Connector.DelaySync;
    928 Zotero.Server.Connector.DelaySync.prototype = {
    929 	supportedMethods: ["POST"],
    930 	
    931 	init: async function (requestData) {
    932 		Zotero.Sync.Runner.delaySync(10000);
    933 		return [204];
    934 	}
    935 };
    936 
    937 /**
    938  * Gets progress for an attachment that is currently being saved
    939  *
    940  * Accepts:
    941  *      Array of attachment IDs returned by savePage, saveItems, or saveSnapshot
    942  * Returns:
    943  *      200 response code with current progress in body. Progress is either a number
    944  *      between 0 and 100 or "false" to indicate that saving failed.
    945  */
    946 Zotero.Server.Connector.Progress = function() {};
    947 Zotero.Server.Endpoints["/connector/attachmentProgress"] = Zotero.Server.Connector.Progress;
    948 Zotero.Server.Connector.Progress.prototype = {
    949 	supportedMethods: ["POST"],
    950 	supportedDataTypes: ["application/json"],
    951 	permitBookmarklet: true,
    952 	
    953 	/**
    954 	 * @param {String} data POST data or GET query string
    955 	 * @param {Function} sendResponseCallback function to send HTTP response
    956 	 */
    957 	init: function(data, sendResponseCallback) {
    958 		sendResponseCallback(200, "application/json",
    959 			JSON.stringify(data.map(id => Zotero.Server.Connector.AttachmentProgressManager.getProgressForID(id))));
    960 	}
    961 };
    962 
    963 /**
    964  * Translates resources using import translators
    965  * 	
    966  * Returns:
    967  * 	- Object[Item] an array of imported items
    968  */
    969  
    970 Zotero.Server.Connector.Import = function() {};
    971 Zotero.Server.Endpoints["/connector/import"] = Zotero.Server.Connector.Import;
    972 Zotero.Server.Connector.Import.prototype = {
    973 	supportedMethods: ["POST"],
    974 	supportedDataTypes: '*',
    975 	permitBookmarklet: false,
    976 	
    977 	init: async function (requestData) {
    978 		let translate = new Zotero.Translate.Import();
    979 		translate.setString(requestData.data);
    980 		let translators = await translate.getTranslators();
    981 		if (!translators || !translators.length) {
    982 			return 400;
    983 		}
    984 		translate.setTranslator(translators[0]);
    985 		var { library, collection, editable } = Zotero.Server.Connector.getSaveTarget();
    986 		var libraryID = library.libraryID;
    987 		
    988 		// Shouldn't happen as long as My Library exists
    989 		if (!library.editable) {
    990 			Zotero.logError("Can't import into read-only library " + library.name);
    991 			return [500, "application/json", JSON.stringify({ libraryEditable: false })];
    992 		}
    993 		
    994 		try {
    995 			var session = Zotero.Server.Connector.SessionManager.create(requestData.query.session);
    996 		}
    997 		catch (e) {
    998 			return [409, "application/json", JSON.stringify({ error: "SESSION_EXISTS" })];
    999 		}
   1000 		await session.update(collection ? collection.treeViewID : library.treeViewID);
   1001 		
   1002 		let items = await translate.translate({
   1003 			libraryID,
   1004 			collections: collection ? [collection.id] : null,
   1005 			forceTagType: 1,
   1006 			// Import translation skips selection by default, so force it to occur
   1007 			saveOptions: {
   1008 				skipSelect: false
   1009 			}
   1010 		});
   1011 		session.addItems(items);
   1012 		
   1013 		return [201, "application/json", JSON.stringify(items)];
   1014 	}
   1015 }
   1016 
   1017 /**
   1018  * Install CSL styles
   1019  * 	
   1020  * Returns:
   1021  * 	- {name: styleName}
   1022  */
   1023  
   1024 Zotero.Server.Connector.InstallStyle = function() {};
   1025 Zotero.Server.Endpoints["/connector/installStyle"] = Zotero.Server.Connector.InstallStyle;
   1026 Zotero.Server.Connector.InstallStyle.prototype = {
   1027 	supportedMethods: ["POST"],
   1028 	supportedDataTypes: '*',
   1029 	permitBookmarklet: false,
   1030 	
   1031 	init: Zotero.Promise.coroutine(function* (requestData) {
   1032 		try {
   1033 			var styleName = yield Zotero.Styles.install(
   1034 				requestData.data, requestData.query.origin || null, true
   1035 			);
   1036 		} catch (e) {
   1037 			return [400, "text/plain", e.message];
   1038 		}
   1039 		return [201, "application/json", JSON.stringify({name: styleName})];
   1040 	})
   1041 };
   1042 
   1043 /**
   1044  * Get code for a translator
   1045  *
   1046  * Accepts:
   1047  *		translatorID
   1048  * Returns:
   1049  *		code - translator code
   1050  */
   1051 Zotero.Server.Connector.GetTranslatorCode = function() {};
   1052 Zotero.Server.Endpoints["/connector/getTranslatorCode"] = Zotero.Server.Connector.GetTranslatorCode;
   1053 Zotero.Server.Connector.GetTranslatorCode.prototype = {
   1054 	supportedMethods: ["POST"],
   1055 	supportedDataTypes: ["application/json"],
   1056 	permitBookmarklet: true,
   1057 	
   1058 	/**
   1059 	 * Returns a 200 response to say the server is alive
   1060 	 * @param {String} data POST data or GET query string
   1061 	 * @param {Function} sendResponseCallback function to send HTTP response
   1062 	 */
   1063 	init: function(postData, sendResponseCallback) {
   1064 		var translator = Zotero.Translators.get(postData.translatorID);
   1065 		translator.getCode().then(function(code) {
   1066 			sendResponseCallback(200, "application/javascript", code);
   1067 		});
   1068 	}
   1069 }
   1070 
   1071 /**
   1072  * Get selected collection
   1073  *
   1074  * Accepts:
   1075  *		Nothing
   1076  * Returns:
   1077  *		libraryID
   1078  *      libraryName
   1079  *      collectionID
   1080  *      collectionName
   1081  */
   1082 Zotero.Server.Connector.GetSelectedCollection = function() {};
   1083 Zotero.Server.Endpoints["/connector/getSelectedCollection"] = Zotero.Server.Connector.GetSelectedCollection;
   1084 Zotero.Server.Connector.GetSelectedCollection.prototype = {
   1085 	supportedMethods: ["POST"],
   1086 	supportedDataTypes: ["application/json"],
   1087 	permitBookmarklet: true,
   1088 	
   1089 	/**
   1090 	 * Returns a 200 response to say the server is alive
   1091 	 * @param {String} data POST data or GET query string
   1092 	 * @param {Function} sendResponseCallback function to send HTTP response
   1093 	 */
   1094 	init: function(postData, sendResponseCallback) {
   1095 		var { library, collection, editable } = Zotero.Server.Connector.getSaveTarget(true);
   1096 		var response = {
   1097 			libraryID: library.libraryID,
   1098 			libraryName: library.name,
   1099 			libraryEditable: library.editable,
   1100 			editable
   1101 		};
   1102 		
   1103 		if(collection && collection.id) {
   1104 			response.id = collection.id;
   1105 			response.name = collection.name;
   1106 		} else {
   1107 			response.id = null;
   1108 			response.name = response.libraryName;
   1109 		}
   1110 		
   1111 		// Get list of editable libraries and collections
   1112 		var collections = [];
   1113 		var originalLibraryID = library.libraryID;
   1114 		for (let library of Zotero.Libraries.getAll()) {
   1115 			if (!library.editable) continue;
   1116 			
   1117 			// Add recent: true for recent targets
   1118 			
   1119 			collections.push(
   1120 				{
   1121 					id: library.treeViewID,
   1122 					name: library.name,
   1123 					level: 0
   1124 				},
   1125 				...Zotero.Collections.getByLibrary(library.libraryID, true).map(c => ({
   1126 					id: c.treeViewID,
   1127 					name: c.name,
   1128 					level: c.level + 1 || 1 // Added by Zotero.Collections._getByContainer()
   1129 				}))
   1130 			);
   1131 		}
   1132 		response.targets = collections;
   1133 		
   1134 		// Mark recent targets
   1135 		try {
   1136 			let recents = Zotero.Prefs.get('recentSaveTargets');
   1137 			if (recents) {
   1138 				recents = new Set(JSON.parse(recents).map(o => o.id));
   1139 				for (let target of response.targets) {
   1140 					if (recents.has(target.id)) {
   1141 						target.recent = true;
   1142 					}
   1143 				}
   1144 			}
   1145 		}
   1146 		catch (e) {
   1147 			Zotero.logError(e);
   1148 			Zotero.Prefs.clear('recentSaveTargets');
   1149 		}
   1150 		
   1151 		sendResponseCallback(
   1152 			200,
   1153 			"application/json",
   1154 			JSON.stringify(response),
   1155 			{
   1156 				// Filter out collection names in debug output
   1157 				logFilter: function (str) {
   1158 					try {
   1159 						let json = JSON.parse(str.match(/^{"libraryID"[^]+/m)[0]);
   1160 						json.targets.forEach(t => t.name = "\u2026");
   1161 						return JSON.stringify(json);
   1162 					}
   1163 					catch (e) {
   1164 						return str;
   1165 					}
   1166 				}
   1167 			}
   1168 		);
   1169 	}
   1170 }
   1171 
   1172 /**
   1173  * Get a list of client hostnames (reverse local IP DNS)
   1174  *
   1175  * Accepts:
   1176  *		Nothing
   1177  * Returns:
   1178  * 		{Array} hostnames
   1179  */
   1180 Zotero.Server.Connector.GetClientHostnames = {};
   1181 Zotero.Server.Connector.GetClientHostnames = function() {};
   1182 Zotero.Server.Endpoints["/connector/getClientHostnames"] = Zotero.Server.Connector.GetClientHostnames;
   1183 Zotero.Server.Connector.GetClientHostnames.prototype = {
   1184 	supportedMethods: ["POST"],
   1185 	supportedDataTypes: ["application/json"],
   1186 	permitBookmarklet: false,
   1187 	
   1188 	/**
   1189 	 * Returns a 200 response to say the server is alive
   1190 	 */
   1191 	init: Zotero.Promise.coroutine(function* (requestData) {
   1192 		try {
   1193 			var hostnames = yield Zotero.Proxies.DNS.getHostnames();
   1194 		} catch(e) {
   1195 			return 500;
   1196 		}
   1197 		return [200, "application/json", JSON.stringify(hostnames)];
   1198 	})
   1199 };
   1200 
   1201 /**
   1202  * Get a list of stored proxies
   1203  *
   1204  * Accepts:
   1205  *		Nothing
   1206  * Returns:
   1207  * 		{Array} hostnames
   1208  */
   1209 Zotero.Server.Connector.Proxies = {};
   1210 Zotero.Server.Connector.Proxies = function() {};
   1211 Zotero.Server.Endpoints["/connector/proxies"] = Zotero.Server.Connector.Proxies;
   1212 Zotero.Server.Connector.Proxies.prototype = {
   1213 	supportedMethods: ["POST"],
   1214 	supportedDataTypes: ["application/json"],
   1215 	permitBookmarklet: false,
   1216 	
   1217 	/**
   1218 	 * Returns a 200 response to say the server is alive
   1219 	 */
   1220 	init: Zotero.Promise.coroutine(function* () {
   1221 		let proxies = Zotero.Proxies.proxies.map((p) => Object.assign(p.toJSON(), {hosts: p.hosts}));
   1222 		return [200, "application/json", JSON.stringify(proxies)];
   1223 	})
   1224 };
   1225 
   1226 
   1227 /**
   1228  * Test connection
   1229  *
   1230  * Accepts:
   1231  *		Nothing
   1232  * Returns:
   1233  *		Nothing (200 OK response)
   1234  */
   1235 Zotero.Server.Connector.Ping = function() {};
   1236 Zotero.Server.Endpoints["/connector/ping"] = Zotero.Server.Connector.Ping;
   1237 Zotero.Server.Connector.Ping.prototype = {
   1238 	supportedMethods: ["GET", "POST"],
   1239 	supportedDataTypes: ["application/json", "text/plain"],
   1240 	permitBookmarklet: true,
   1241 	
   1242 	/**
   1243 	 * Sends 200 and HTML status on GET requests
   1244 	 * @param data {Object} request information defined in connector.js
   1245 	 */
   1246 	init: function (req) {
   1247 		if (req.method == 'GET') {
   1248 			return [200, "text/html", '<!DOCTYPE html><html><head>' +
   1249 				'<title>Zotero Connector Server is Available</title></head>' +
   1250 				'<body>Zotero Connector Server is Available</body></html>'];
   1251 		} else {
   1252 			// Store the active URL so it can be used for site-specific Quick Copy
   1253 			if (req.data.activeURL) {
   1254 				//Zotero.debug("Setting active URL to " + req.data.activeURL);
   1255 				Zotero.QuickCopy.lastActiveURL = req.data.activeURL;
   1256 			}
   1257 			
   1258 			let response = {
   1259 				prefs: {
   1260 					automaticSnapshots: Zotero.Prefs.get('automaticSnapshots')
   1261 				}
   1262 			};
   1263 			if (Zotero.QuickCopy.hasSiteSettings()) {
   1264 				response.prefs.reportActiveURL = true;
   1265 			}
   1266 			
   1267 			this.versionWarning(req);
   1268 			
   1269 			return [200, 'application/json', JSON.stringify(response)];
   1270 		}
   1271 	},
   1272 	
   1273 	
   1274 	/**
   1275 	 * Warn on outdated connector version
   1276 	 *
   1277 	 * We can remove this once the connector checks and warns on its own and most people are on
   1278 	 * a version that does that.
   1279 	 */
   1280 	versionWarning: function (req) {
   1281 		try {
   1282 			if (!Zotero.Prefs.get('showConnectorVersionWarning')) return;
   1283 			if (!req.headers) return;
   1284 			
   1285 			var minVersion = ZOTERO_CONFIG.CONNECTOR_MIN_VERSION;
   1286 			var appName = ZOTERO_CONFIG.CLIENT_NAME;
   1287 			var domain = ZOTERO_CONFIG.DOMAIN_NAME;
   1288 			var origin = req.headers.Origin;
   1289 			
   1290 			var browser;
   1291 			var message;
   1292 			var showDownloadButton = false;
   1293 			if (origin && origin.startsWith('safari-extension')) {
   1294 				browser = 'safari';
   1295 				message = `An update is available for the ${appName} Connector for Safari.\n\n`
   1296 					+ 'You can upgrade from the Extensions pane of the Safari preferences.';
   1297 			}
   1298 			else if (origin && origin.startsWith('chrome-extension')) {
   1299 				browser = 'chrome';
   1300 				message = `An update is available for the ${appName} Connector for Chrome.\n\n`
   1301 					+ `You can upgrade to the latest version from ${domain}.`;
   1302 				showDownloadButton = true;
   1303 			}
   1304 			else if (req.headers['User-Agent'] && req.headers['User-Agent'].includes('Firefox/')) {
   1305 				browser = 'firefox';
   1306 				message = `An update is available for the ${appName} Connector for Firefox.\n\n`
   1307 					+ `You can upgrade to the latest version from ${domain}.`;
   1308 				showDownloadButton = true;
   1309 			}
   1310 			else {
   1311 				Zotero.debug("Unknown browser");
   1312 				return;
   1313 			}
   1314 			
   1315 			if (Zotero.Server.Connector['skipVersionWarning-' + browser]) return;
   1316 			
   1317 			var version = req.headers['X-Zotero-Version'];
   1318 			if (!version || version == '4.999.0') return;
   1319 			
   1320 			// If connector is up to date, bail
   1321 			if (Services.vc.compare(version, minVersion) >= 0) return;
   1322 			
   1323 			var showNextPref = `nextConnectorVersionWarning.${browser}`;
   1324 			var showNext = Zotero.Prefs.get(showNextPref);
   1325 			if (showNext && new Date() < new Date(showNext * 1000)) return;
   1326 			
   1327 			// Don't show again for this browser until restart
   1328 			Zotero.Server.Connector['skipVersionWarning-' + browser] = true;
   1329 			var ps = Services.prompt;
   1330 			var buttonFlags;
   1331 			if (showDownloadButton) {
   1332 				buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING
   1333 					+ ps.BUTTON_POS_1 * ps.BUTTON_TITLE_IS_STRING;
   1334 			}
   1335 			else {
   1336 				buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_OK;
   1337 			}
   1338 			setTimeout(function () {
   1339 				var dontShow = {};
   1340 				var index = ps.confirmEx(null,
   1341 					Zotero.getString('general.updateAvailable'),
   1342 					message,
   1343 					buttonFlags,
   1344 					showDownloadButton ? Zotero.getString('general.upgrade') : null,
   1345 					showDownloadButton ? Zotero.getString('general.notNow') : null,
   1346 					null,
   1347 					"Don\u0027t show again for a month",
   1348 					dontShow
   1349 				);
   1350 				
   1351 				var nextShowDays;
   1352 				if (dontShow.value) {
   1353 					nextShowDays = 30;
   1354 				}
   1355 				// Don't show again for at least a day, even after a restart
   1356 				else {
   1357 					nextShowDays = 1;
   1358 				}
   1359 				Zotero.Prefs.set(showNextPref, Math.round(Date.now() / 1000) + 86400 * nextShowDays);
   1360 				
   1361 				if (showDownloadButton && index == 0) {
   1362 					Zotero.launchURL(ZOTERO_CONFIG.CONNECTORS_URL);
   1363 				}
   1364 			}, 500);
   1365 		}
   1366 		catch (e) {
   1367 			Zotero.debug(e, 2);
   1368 		}
   1369 	}
   1370 }
   1371 
   1372 /**
   1373  * IE messaging hack
   1374  *
   1375  * Accepts:
   1376  *		Nothing
   1377  * Returns:
   1378  *		Static Response
   1379  */
   1380 Zotero.Server.Connector.IEHack = function() {};
   1381 Zotero.Server.Endpoints["/connector/ieHack"] = Zotero.Server.Connector.IEHack;
   1382 Zotero.Server.Connector.IEHack.prototype = {
   1383 	supportedMethods: ["GET"],
   1384 	permitBookmarklet: true,
   1385 	
   1386 	/**
   1387 	 * Sends a fixed webpage
   1388 	 * @param {String} data POST data or GET query string
   1389 	 * @param {Function} sendResponseCallback function to send HTTP response
   1390 	 */
   1391 	init: function(postData, sendResponseCallback) {
   1392 		sendResponseCallback(200, "text/html",
   1393 			'<!DOCTYPE html><html><head>'+
   1394 			'<script src="'+ZOTERO_CONFIG.BOOKMARKLET_URL+'common_ie.js"></script>'+
   1395 			'<script src="'+ZOTERO_CONFIG.BOOKMARKLET_URL+'ie_hack.js"></script>'+
   1396 			'</head><body></body></html>');
   1397 	}
   1398 }
   1399 
   1400 // XXX For compatibility with older connectors; to be removed
   1401 Zotero.Server.Connector.IncompatibleVersion = function() {};
   1402 Zotero.Server.Connector.IncompatibleVersion._errorShown = false
   1403 Zotero.Server.Endpoints["/translate/list"] = Zotero.Server.Connector.IncompatibleVersion;
   1404 Zotero.Server.Endpoints["/translate/detect"] = Zotero.Server.Connector.IncompatibleVersion;
   1405 Zotero.Server.Endpoints["/translate/save"] = Zotero.Server.Connector.IncompatibleVersion;
   1406 Zotero.Server.Endpoints["/translate/select"] = Zotero.Server.Connector.IncompatibleVersion;
   1407 Zotero.Server.Connector.IncompatibleVersion.prototype = {
   1408 	supportedMethods: ["POST"],
   1409 	supportedDataTypes: ["application/json"],
   1410 	permitBookmarklet: true,
   1411 	
   1412 	init: function(postData, sendResponseCallback) {
   1413 		sendResponseCallback(404);
   1414 		if(Zotero.Server.Connector.IncompatibleVersion._errorShown) return;
   1415 		
   1416 		Zotero.Utilities.Internal.activate();
   1417 		var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"].
   1418 				createInstance(Components.interfaces.nsIPromptService);
   1419 		ps.alert(null,
   1420 			Zotero.getString("connector.error.title"),
   1421 			Zotero.getString("integration.error.incompatibleVersion2",
   1422 				["Standalone "+Zotero.version, "Connector", "2.999.1"]));
   1423 		Zotero.Server.Connector.IncompatibleVersion._errorShown = true;
   1424 	}
   1425 };