www

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

syncAPIClient.js (23897B)


      1 /*
      2     ***** BEGIN LICENSE BLOCK *****
      3     
      4     Copyright © 2014 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 
     26 if (!Zotero.Sync) {
     27 	Zotero.Sync = {};
     28 }
     29 
     30 Zotero.Sync.APIClient = function (options) {
     31 	if (!options.baseURL) throw new Error("baseURL not set");
     32 	if (!options.apiVersion) throw new Error("apiVersion not set");
     33 	if (!options.caller) throw new Error("caller not set");
     34 	
     35 	this.baseURL = options.baseURL;
     36 	this.apiVersion = options.apiVersion;
     37 	this.apiKey = options.apiKey;
     38 	this.caller = options.caller;
     39 	this.debugUploadPolicy = Zotero.Prefs.get('sync.debugUploadPolicy');
     40 	
     41 	this.failureDelayIntervals = [2500, 5000, 10000, 20000, 40000, 60000, 120000, 240000, 300000];
     42 	this.failureDelayMax = 60 * 60 * 1000; // 1 hour
     43 	this.rateDelayIntervals = [30, 60, 300];
     44 	this.rateDelayPosition = 0;
     45 }
     46 
     47 Zotero.Sync.APIClient.prototype = {
     48 	MAX_OBJECTS_PER_REQUEST: 100,
     49 	MIN_GZIP_SIZE: 1000,
     50 	
     51 	
     52 	getKeyInfo: Zotero.Promise.coroutine(function* (options={}) {
     53 		var uri = this.baseURL + "keys/current";
     54 		let opts = {};
     55 		Object.assign(opts, options);
     56 		opts.successCodes = [200, 403, 404];
     57 		var xmlhttp = yield this.makeRequest("GET", uri, opts);
     58 		if (xmlhttp.status == 403) {
     59 			return false;
     60 		}
     61 		var json = this._parseJSON(xmlhttp.responseText);
     62 		delete json.key;
     63 		return json;
     64 	}),
     65 	
     66 	
     67 	/**
     68 	 * Get group metadata versions
     69 	 *
     70 	 * Note: This is the version for group metadata, not library data.
     71 	 */
     72 	getGroupVersions: Zotero.Promise.coroutine(function* (userID) {
     73 		if (!userID) throw new Error("User ID not provided");
     74 		
     75 		var uri = this.baseURL + "users/" + userID + "/groups?format=versions";
     76 		var xmlhttp = yield this.makeRequest("GET", uri);
     77 		return this._parseJSON(xmlhttp.responseText);
     78 	}),
     79 	
     80 	/**
     81 	 * Get group metadata for userID
     82 	 *
     83 	 * @param {Integer} userID
     84 	 * @return {Object} - Group metadata response
     85 	 */	
     86 	getGroups: Zotero.Promise.coroutine(function* (userID) {
     87 		if (!userID) throw new Error("User ID not provided");
     88 		
     89 		var uri = this.baseURL + "users/" + userID + "/groups";
     90 		return yield this.getPaginatedResults(
     91 			uri,
     92 			(previous, xmlhttp, restart) => [...previous, ...this._parseJSON(xmlhttp.responseText)],
     93 			[]
     94 		);
     95 	}),
     96 	
     97 	
     98 	/**
     99 	 * @param {Integer} groupID
    100 	 * @return {Object|false} - Group metadata response, or false if group not found
    101 	 */
    102 	getGroup: Zotero.Promise.coroutine(function* (groupID) {
    103 		if (!groupID) throw new Error("Group ID not provided");
    104 		
    105 		var uri = this.baseURL + "groups/" + groupID;
    106 		var xmlhttp = yield this.makeRequest("GET", uri, { successCodes: [200, 404] });
    107 		if (xmlhttp.status == 404) {
    108 			return false;
    109 		}
    110 		return this._parseJSON(xmlhttp.responseText);
    111 	}),
    112 	
    113 	
    114 	getSettings: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, since) {
    115 		var params = {
    116 			libraryType: libraryType,
    117 			libraryTypeID: libraryTypeID,
    118 			target: "settings"
    119 		};
    120 		if (since) {
    121 			params.since = since;
    122 		}
    123 		var uri = this.buildRequestURI(params);
    124 		var options = {
    125 			successCodes: [200, 304]
    126 		};
    127 		if (since) {
    128 			options.headers = {
    129 				"If-Modified-Since-Version": since
    130 			};
    131 		}
    132 		var xmlhttp = yield this.makeRequest("GET", uri, options);
    133 		if (xmlhttp.status == 304) {
    134 			return false;
    135 		}
    136 		return {
    137 			libraryVersion: this._getLastModifiedVersion(xmlhttp),
    138 			settings: this._parseJSON(xmlhttp.responseText)
    139 		};
    140 	}),
    141 	
    142 	
    143 	/**
    144 	 * @return {Object|false} - An object with 'libraryVersion' and a 'deleted' object, or
    145 	 *     false if 'since' is earlier than the beginning of the delete log
    146 	 */
    147 	getDeleted: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, since) {
    148 		var params = {
    149 			target: "deleted",
    150 			libraryType: libraryType,
    151 			libraryTypeID: libraryTypeID,
    152 			since: since || 0
    153 		};
    154 		var uri = this.buildRequestURI(params);
    155 		var xmlhttp = yield this.makeRequest("GET", uri, { successCodes: [200, 409] });
    156 		if (xmlhttp.status == 409) {
    157 			Zotero.debug(`'since' value '${since}' is earlier than the beginning of the delete log`);
    158 			return false;
    159 		}
    160 		return {
    161 			libraryVersion: this._getLastModifiedVersion(xmlhttp),
    162 			deleted: this._parseJSON(xmlhttp.responseText)
    163 		};
    164 	}),
    165 	
    166 	
    167 	getKeys: async function (libraryType, libraryTypeID, queryParams) {
    168 		var params = {
    169 			libraryType: libraryType,
    170 			libraryTypeID: libraryTypeID,
    171 			format: 'keys'
    172 		};
    173 		if (queryParams) {
    174 			for (let i in queryParams) {
    175 				params[i] = queryParams[i];
    176 			}
    177 		}
    178 		
    179 		// TODO: Use pagination
    180 		var uri = this.buildRequestURI(params);
    181 		
    182 		var options = {
    183 			successCodes: [200, 304]
    184 		};
    185 		var xmlhttp = await this.makeRequest("GET", uri, options);
    186 		if (xmlhttp.status == 304) {
    187 			return false;
    188 		}
    189 		return {
    190 			libraryVersion: this._getLastModifiedVersion(xmlhttp),
    191 			keys: xmlhttp.responseText.trim().split(/\n/).filter(key => key)
    192 		};
    193 	},
    194 	
    195 	
    196 	/**
    197 	 * Return a promise for a JS object with object keys as keys and version
    198 	 * numbers as values. By default, returns all objects in the library.
    199 	 * Additional parameters (such as 'since', 'sincetime', 'libraryVersion')
    200 	 * can be passed in 'params'.
    201 	 *
    202 	 * @param {String} libraryType  'user' or 'group'
    203 	 * @param {Integer} libraryTypeID  userID or groupID
    204 	 * @param {String} objectType  'item', 'collection', 'search'
    205 	 * @param {Object} queryParams  Query parameters (see buildRequestURI())
    206 	 * @return {Promise<Object>|false} - Object with 'libraryVersion' and 'results', or false if
    207 	 *     nothing changed since specified library version
    208 	 */
    209 	getVersions: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, objectType, queryParams) {
    210 		var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
    211 		
    212 		var params = {
    213 			target: objectTypePlural,
    214 			libraryType: libraryType,
    215 			libraryTypeID: libraryTypeID,
    216 			format: 'versions'
    217 		};
    218 		if (queryParams) {
    219 			if (queryParams.top) {
    220 				params.target += "/top";
    221 				delete queryParams.top;
    222 			}
    223 			for (let i in queryParams) {
    224 				params[i] = queryParams[i];
    225 			}
    226 		}
    227 		if (objectType == 'item') {
    228 			params.includeTrashed = 1;
    229 		}
    230 		
    231 		// TODO: Use pagination
    232 		var uri = this.buildRequestURI(params);
    233 		
    234 		var options = {
    235 			successCodes: [200, 304]
    236 		};
    237 		var xmlhttp = yield this.makeRequest("GET", uri, options);
    238 		if (xmlhttp.status == 304) {
    239 			return false;
    240 		}
    241 		var libraryVersion = xmlhttp.getResponseHeader('Last-Modified-Version');
    242 		if (!libraryVersion) {
    243 			throw new Error("Last-Modified-Version not provided");
    244 		}
    245 		return {
    246 			libraryVersion: libraryVersion,
    247 			versions: this._parseJSON(xmlhttp.responseText)
    248 		};
    249 	}),
    250 	
    251 	
    252 	/**
    253 	 * Retrieve JSON from API for requested objects
    254 	 *
    255 	 * If necessary, multiple API requests will be made.
    256 	 *
    257 	 * @param {String} libraryType - 'user', 'group'
    258 	 * @param {Integer} libraryTypeID - userID or groupID
    259 	 * @param {String} objectType - 'collection', 'item', 'search'
    260 	 * @param {String[]} objectKeys - Keys of objects to request
    261 	 * @return {Array<Promise<Object[]|Error[]>>} - An array of promises for batches of JSON objects
    262 	 *     or Errors for failures
    263 	 */
    264 	downloadObjects: function (libraryType, libraryTypeID, objectType, objectKeys) {
    265 		if (!objectKeys.length) {
    266 			return [];
    267 		}
    268 		
    269 		// If more than max per request, call in batches
    270 		if (objectKeys.length > this.MAX_OBJECTS_PER_REQUEST) {
    271 			let allKeys = objectKeys.concat();
    272 			let promises = [];
    273 			while (true)  {
    274 				let requestKeys = allKeys.splice(0, this.MAX_OBJECTS_PER_REQUEST)
    275 				if (!requestKeys.length) {
    276 					break;
    277 				}
    278 				let promise = this.downloadObjects(
    279 					libraryType,
    280 					libraryTypeID,
    281 					objectType,
    282 					requestKeys
    283 				)[0];
    284 				if (promise) {
    285 					promises.push(promise);
    286 				}
    287 			}
    288 			return promises;
    289 		}
    290 		
    291 		// Otherwise make request
    292 		var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
    293 		
    294 		Zotero.debug("Retrieving " + objectKeys.length + " "
    295 			+ (objectKeys.length == 1 ? objectType : objectTypePlural));
    296 		
    297 		var params = {
    298 			target: objectTypePlural,
    299 			libraryType: libraryType,
    300 			libraryTypeID: libraryTypeID,
    301 			format: 'json'
    302 		};
    303 		params[objectType + "Key"] = objectKeys.join(",");
    304 		if (objectType == 'item') {
    305 			params.includeTrashed = 1;
    306 		}
    307 		var uri = this.buildRequestURI(params);
    308 		
    309 		return [
    310 			this.makeRequest("GET", uri)
    311 			.then(function (xmlhttp) {
    312 				return this._parseJSON(xmlhttp.responseText)
    313 			}.bind(this))
    314 			// Return the error without failing the whole chain
    315 			.catch(function (e) {
    316 				Zotero.logError(e);
    317 				if (e instanceof Zotero.HTTP.UnexpectedStatusException && e.is4xx()) {
    318 					throw e;
    319 				}
    320 				return e;
    321 			})
    322 		];
    323 	},
    324 	
    325 	
    326 	uploadSettings: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, libraryVersion, settings) {
    327 		var method = "POST";
    328 		var objectType = "setting";
    329 		var objectTypePlural = "settings";
    330 		var numSettings = Object.keys(settings).length;
    331 		
    332 		Zotero.debug(`Uploading ${numSettings} ${numSettings == 1 ? objectType : objectTypePlural}`);
    333 		
    334 		Zotero.debug("Sending If-Unmodified-Since-Version: " + libraryVersion);
    335 		
    336 		var json = JSON.stringify(settings);
    337 		var params = {
    338 			target: objectTypePlural,
    339 			libraryType: libraryType,
    340 			libraryTypeID: libraryTypeID
    341 		};
    342 		var uri = this.buildRequestURI(params);
    343 		
    344 		var xmlhttp = yield this.makeRequest(method, uri, {
    345 			headers: {
    346 				"Content-Type": "application/json",
    347 				"If-Unmodified-Since-Version": libraryVersion
    348 			},
    349 			body: json,
    350 			successCodes: [204, 412]
    351 		});
    352 		this._check412(xmlhttp);
    353 		return this._getLastModifiedVersion(xmlhttp);
    354 	}),
    355 	
    356 	
    357 	uploadObjects: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, method, libraryVersion, objectType, objects) {
    358 		if (method != 'POST' && method != 'PATCH') {
    359 			throw new Error("Invalid method '" + method + "'");
    360 		}
    361 		
    362 		var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
    363 		
    364 		Zotero.debug("Uploading " + objects.length + " "
    365 			+ (objects.length == 1 ? objectType : objectTypePlural));
    366 		
    367 		Zotero.debug("Sending If-Unmodified-Since-Version: " + libraryVersion);
    368 		
    369 		var json = JSON.stringify(objects);
    370 		var params = {
    371 			target: objectTypePlural,
    372 			libraryType: libraryType,
    373 			libraryTypeID: libraryTypeID
    374 		};
    375 		var uri = this.buildRequestURI(params);
    376 		
    377 		var xmlhttp = yield this.makeRequest(method, uri, {
    378 			headers: {
    379 				"Content-Type": "application/json",
    380 				"If-Unmodified-Since-Version": libraryVersion
    381 			},
    382 			body: json,
    383 			successCodes: [200, 412]
    384 		});
    385 		this._check412(xmlhttp);
    386 		return {
    387 			libraryVersion: this._getLastModifiedVersion(xmlhttp),
    388 			results: this._parseJSON(xmlhttp.responseText)
    389 		};
    390 	}),
    391 	
    392 	
    393 	uploadDeletions: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, libraryVersion, objectType, keys) {
    394 		var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
    395 		
    396 		Zotero.debug(`Uploading ${keys.length} ${objectType} deletion`
    397 			+ (keys.length == 1 ? '' : 's'));
    398 		
    399 		Zotero.debug("Sending If-Unmodified-Since-Version: " + libraryVersion);
    400 		
    401 		var params = {
    402 			target: objectTypePlural,
    403 			libraryType: libraryType,
    404 			libraryTypeID: libraryTypeID
    405 		};
    406 		if (objectType == 'tag') {
    407 			params.tags = keys.join("||");
    408 		}
    409 		else {
    410 			params[objectType + "Key"] = keys.join(",");
    411 		}
    412 		var uri = this.buildRequestURI(params);
    413 		var xmlhttp = yield this.makeRequest("DELETE", uri, {
    414 			headers: {
    415 				"If-Unmodified-Since-Version": libraryVersion
    416 			},
    417 			successCodes: [204, 412]
    418 		});
    419 		this._check412(xmlhttp);
    420 		return this._getLastModifiedVersion(xmlhttp);
    421 	}),
    422 	
    423 	
    424 	getFullTextVersions: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, since) {
    425 		var params = {
    426 			libraryType: libraryType,
    427 			libraryTypeID: libraryTypeID,
    428 			target: "fulltext",
    429 			format: "versions"
    430 		};
    431 		if (since) {
    432 			params.since = since;
    433 		}
    434 		
    435 		// TODO: Use pagination
    436 		var uri = this.buildRequestURI(params);
    437 		
    438 		var xmlhttp = yield this.makeRequest("GET", uri);
    439 		return {
    440 			libraryVersion: this._getLastModifiedVersion(xmlhttp),
    441 			versions: this._parseJSON(xmlhttp.responseText)
    442 		};
    443 	}),
    444 	
    445 	
    446 	getFullTextForItem: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, itemKey) {
    447 		var params = {
    448 			libraryType: libraryType,
    449 			libraryTypeID: libraryTypeID,
    450 			target: `items/${itemKey}/fulltext`
    451 		};
    452 		var uri = this.buildRequestURI(params);
    453 		var xmlhttp = yield this.makeRequest("GET", uri, { successCodes: [200, 404] });
    454 		if (xmlhttp.status == 404) {
    455 			return false;
    456 		}
    457 		var version = xmlhttp.getResponseHeader('Last-Modified-Version');
    458 		if (!version) {
    459 			throw new Error("Last-Modified-Version not provided");
    460 		}
    461 		return {
    462 			version: this._getLastModifiedVersion(xmlhttp),
    463 			data: this._parseJSON(xmlhttp.responseText)
    464 		};
    465 	}),
    466 	
    467 	
    468 	setFullTextForItems: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, libraryVersion, data) {
    469 		var params = {
    470 			libraryType: libraryType,
    471 			libraryTypeID: libraryTypeID,
    472 			target: "fulltext"
    473 		};
    474 		var uri = this.buildRequestURI(params);
    475 		var xmlhttp = yield this.makeRequest(
    476 			"POST",
    477 			uri,
    478 			{
    479 				headers: {
    480 					"Content-Type": "application/json",
    481 					"If-Unmodified-Since-Version": libraryVersion
    482 				},
    483 				body: JSON.stringify(data),
    484 				successCodes: [200, 412],
    485 				debug: true
    486 			}
    487 		);
    488 		this._check412(xmlhttp);
    489 		return {
    490 			libraryVersion: this._getLastModifiedVersion(xmlhttp),
    491 			results: this._parseJSON(xmlhttp.responseText)
    492 		};
    493 	}),
    494 	
    495 	
    496 	createAPIKeyFromCredentials: Zotero.Promise.coroutine(function* (username, password) {
    497 		var body = JSON.stringify({
    498 			username,
    499 			password,
    500 			name: "Automatic Zotero Client Key",
    501 			access: {
    502 				user: {
    503 					library: true,
    504 					notes: true,
    505 					write: true,
    506 					files: true
    507 				},
    508 				groups: {
    509 					all: {
    510 						library: true,
    511 						write: true
    512 					}
    513 				}
    514 			}
    515 		});
    516 		var headers = {
    517 			"Content-Type": "application/json"
    518 		};
    519 		var uri = this.baseURL + "keys";
    520 		var response = yield this.makeRequest("POST", uri, {
    521 			body, headers, successCodes: [201, 403], noAPIKey: true
    522 		});
    523 		if (response.status == 403) {
    524 			return false;
    525 		}
    526 		
    527 		var json = this._parseJSON(response.responseText);
    528 		if (!json.key) {
    529 			throw new Error('json.key not present in POST /keys response')
    530 		}
    531 
    532 		return json;
    533 	}),
    534 
    535 
    536 	// Deletes current API key
    537 	deleteAPIKey: Zotero.Promise.coroutine(function* () {
    538 		yield this.makeRequest("DELETE", this.baseURL + "keys/current");
    539 	}),
    540 	
    541 	
    542 	buildRequestURI: function (params) {
    543 		var uri = this.baseURL;
    544 		
    545 		switch (params.libraryType) {
    546 		case 'publications':
    547 			uri += 'users/' + params.libraryTypeID + '/' + params.libraryType;
    548 			break;
    549 		
    550 		default:
    551 			uri += params.libraryType + 's/' + params.libraryTypeID;
    552 			break;
    553 		}
    554 		
    555 		if (params.target === undefined) {
    556 			throw new Error("'target' not provided");
    557 		}
    558 		
    559 		uri += "/" + params.target;
    560 		
    561 		if (params.objectKey) {
    562 			uri += "/" + params.objectKey;
    563 		}
    564 		
    565 		var queryString = '?';
    566 		var queryParamsArray = [];
    567 		var queryParamOptions = [
    568 			'session',
    569 			'format',
    570 			'include',
    571 			'includeTrashed',
    572 			'itemType',
    573 			'itemKey',
    574 			'collectionKey',
    575 			'searchKey',
    576 			'tag',
    577 			'linkMode',
    578 			'start',
    579 			'limit',
    580 			'sort',
    581 			'direction',
    582 			'since',
    583 			'sincetime'
    584 		];
    585 		queryParams = {};
    586 		
    587 		for (let option in params) {
    588 			let value = params[option];
    589 			if (value !== undefined && value !== '' && queryParamOptions.indexOf(option) != -1) {
    590 				queryParams[option] = value;
    591 			}
    592 		}
    593 		
    594 		for (let index in queryParams) {
    595 			let value = queryParams[index];
    596 			if (Array.isArray(value)) {
    597 				value.forEach(function(v, i) {
    598 					queryParamsArray.push(encodeURIComponent(index) + '=' + encodeURIComponent(v));
    599 				});
    600 			}
    601 			else {
    602 				queryParamsArray.push(encodeURIComponent(index) + '=' + encodeURIComponent(value));
    603 			}
    604 		}
    605 		
    606 		return uri + (queryParamsArray.length ? "?" + queryParamsArray.join('&') : "");
    607 	},
    608 	
    609 	
    610 	getHeaders: function (headers = {}) {
    611 		let newHeaders = {};
    612 		newHeaders = Object.assign(newHeaders, headers);
    613 		newHeaders["Zotero-API-Version"] = this.apiVersion;
    614 		if (this.apiKey) {
    615 			newHeaders["Zotero-API-Key"] = this.apiKey;
    616 		}
    617 		return newHeaders;
    618 	},
    619 	
    620 	
    621 	makeRequest: Zotero.Promise.coroutine(function* (method, uri, options = {}) {
    622 		if (!this.apiKey && !options.noAPIKey) {
    623 			throw new Error('API key not set');
    624 		}
    625 		
    626 		if (Zotero.HTTP.isWriteMethod(method) && this.debugUploadPolicy) {
    627 			// Confirm uploads when extensions.zotero.sync.debugUploadPolicy is 1
    628 			if (this.debugUploadPolicy === 1) {
    629 				if (options.body) {
    630 					Zotero.debug(options.body);
    631 				}
    632 				if (!Services.prompt.confirm(null, "Allow Upload?", `Allow ${method} to ${uri}?`)) {
    633 					throw new Error(method + " request denied");
    634 				}
    635 			}
    636 			// Deny uploads when extensions.zotero.sync.debugUploadPolicy is 2
    637 			else if (this.debugUploadPolicy === 2) {
    638 				throw new Error(`Can't make ${method} request in read-only mode`);
    639 			}
    640 		}
    641 		
    642 		let opts = {}
    643 		Object.assign(opts, options);
    644 		opts.headers = this.getHeaders(options.headers);
    645 		opts.dontCache = true;
    646 		opts.foreground = !options.background;
    647 		opts.responseType = options.responseType || 'text';
    648 		if (options.body && options.body.length >= this.MIN_GZIP_SIZE
    649 				&& Zotero.Prefs.get('sync.server.compressData')) {
    650 			opts.compressBody = true;
    651 		}
    652 		
    653 		var tries = 0;
    654 		var failureDelayGenerator = null;
    655 		while (true) {
    656 			var result = yield this.caller.start(Zotero.Promise.coroutine(function* () {
    657 				try {
    658 					var xmlhttp = yield Zotero.HTTP.request(method, uri, opts);
    659 					this._checkBackoff(xmlhttp);
    660 					this.rateDelayPosition = 0;
    661 					return xmlhttp;
    662 				}
    663 				catch (e) {
    664 					tries++;
    665 					if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
    666 						this._checkConnection(e.xmlhttp, e.channel);
    667 						if (this._check429(e.xmlhttp)) {
    668 							// Return false to keep retrying request
    669 							return false;
    670 						}
    671 						
    672 						if (e.is5xx()) {
    673 							Zotero.logError(e);
    674 							if (e.xmlhttp.status == 503 && this._checkRetry(e.xmlhttp)) {
    675 								return false;
    676 							}
    677 							
    678 							if (!failureDelayGenerator) {
    679 								// Keep trying for up to an hour
    680 								failureDelayGenerator = Zotero.Utilities.Internal.delayGenerator(
    681 									this.failureDelayIntervals, this.failureDelayMax
    682 								);
    683 							}
    684 							let keepGoing = yield failureDelayGenerator.next().value;
    685 							if (!keepGoing) {
    686 								Zotero.logError("Failed too many times");
    687 								throw e;
    688 							}
    689 							return false;
    690 						}
    691 					}
    692 					else if (e instanceof Zotero.HTTP.BrowserOfflineException) {
    693 						e.fatal = true;
    694 					}
    695 					throw e;
    696 				}
    697 			}.bind(this)));
    698 			
    699 			if (result) {
    700 				return result;
    701 			}
    702 		}
    703 	}),
    704 	
    705 	
    706 	/**
    707 	 * Retrieve paginated requests automatically based on the Link header, passing the results to a
    708 	 * reducer
    709 	 *
    710 	 * @param {String} initialURL
    711 	 * @param {Function} reducer - Reducer function taking (previousValue, xmlhttp, restart)
    712 	 *                                accumulator: Return value from previous invocation, or initialValue
    713 	 *                                xmlhttp: XMLHTTPRequest object from previous request
    714 	 *                                restart: A function to restart from the beginning
    715 	 * @param {mixed} initialValue
    716 	 * @return {mixed} - The reduced value
    717 	 */
    718 	getPaginatedResults: Zotero.Promise.coroutine(function* (initialURL, reducer, initialValue) {
    719 		let url = initialURL;
    720 		let accumulator;
    721 		let restart = false;
    722 		while (true) {
    723 			let xmlhttp = yield this.makeRequest("GET", url);
    724 			accumulator = reducer(
    725 				accumulator === undefined ? initialValue : accumulator,
    726 				xmlhttp,
    727 				function () {
    728 					restart = true;
    729 				}
    730 			);
    731 			if (restart) {
    732 				accumulator = undefined;
    733 				url = initialURL;
    734 				restart = false;
    735 				continue;
    736 			}
    737 			let link = this._parseLinkHeader(xmlhttp.getResponseHeader('Link'));
    738 			if (link && link.next) {
    739 				url = link.next;
    740 			}
    741 			else {
    742 				break;
    743 			}
    744 		}
    745 		return accumulator;
    746 	}),
    747 	
    748 	
    749 	/**
    750 	 * Parse a Link header
    751 	 *
    752 	 * From https://gist.github.com/deiu/9335803
    753 	 * MIT-licensed
    754 	 */
    755 	_parseLinkHeader: function (link) {
    756 		var linkexp = /<[^>]*>\s*(\s*;\s*[^\(\)<>@,;:"\/\[\]\?={} \t]+=(([^\(\)<>@,;:"\/\[\]\?={} \t]+)|("[^"]*")))*(,|$)/g;
    757 		var paramexp = /[^\(\)<>@,;:"\/\[\]\?={} \t]+=(([^\(\)<>@,;:"\/\[\]\?={} \t]+)|("[^"]*"))/g;
    758 		var matches = link.match(linkexp);
    759 		var rels = {};
    760 		for (var i = 0; i < matches.length; i++) {
    761 			var split = matches[i].split('>');
    762 			var href = split[0].substring(1);
    763 			var ps = split[1];
    764 			var s = ps.match(paramexp);
    765 			for (var j = 0; j < s.length; j++) {
    766 				var p = s[j];
    767 				var paramsplit = p.split('=');
    768 				var name = paramsplit[0];
    769 				var rel = paramsplit[1].replace(/["']/g, '');
    770 				rels[rel] = href;
    771 			}
    772 		}
    773 		return rels;
    774 	},
    775 	
    776 	
    777 	_parseJSON: function (json) {
    778 		try {
    779 			json = JSON.parse(json);
    780 		}
    781 		catch (e) {
    782 			Zotero.debug(e, 1);
    783 			Zotero.debug(json, 1);
    784 			throw e;
    785 		}
    786 		return json;
    787 	},
    788 	
    789 	
    790 	/**
    791 	 * Check connection for certificate errors, interruptions, and empty responses and
    792 	 * throw an appropriate error
    793 	 */
    794 	_checkConnection: function (xmlhttp, channel) {
    795 		const Ci = Components.interfaces;
    796 		
    797 		if (!xmlhttp.responseText && (xmlhttp.status == 0 || xmlhttp.status == 200)) {
    798 			let msg = null;
    799 			let dialogButtonText = null;
    800 			let dialogButtonCallback = null;
    801 			
    802 			if (xmlhttp.status === 0) {
    803 				msg = Zotero.getString('sync.error.checkConnection');
    804 			}
    805 			if (!msg) {
    806 				msg = Zotero.getString('sync.error.emptyResponseServer')
    807 					+ Zotero.getString('general.tryAgainLater');
    808 			}
    809 			throw new Zotero.Error(
    810 				msg,
    811 				0,
    812 				{
    813 					dialogButtonText,
    814 					dialogButtonCallback
    815 				}
    816 			);
    817 		}
    818 	},
    819 	
    820 	
    821 	_checkBackoff: function (xmlhttp) {
    822 		var backoff = xmlhttp.getResponseHeader("Backoff");
    823 		if (backoff && parseInt(backoff) == backoff) {
    824 			// TODO: Update status?
    825 			this.caller.pause(backoff * 1000);
    826 		}
    827 	},
    828 	
    829 	
    830 	_checkRetry: function (xmlhttp) {
    831 		var retryAfter = xmlhttp.getResponseHeader("Retry-After");
    832 		var delay;
    833 		if (!retryAfter) return false;
    834 		if (parseInt(retryAfter) != retryAfter) {
    835 			Zotero.logError(`Invalid Retry-After delay ${retryAfter}`);
    836 			return false;
    837 		}
    838 		// TODO: Update status?
    839 		delay = retryAfter;
    840 		this.caller.pause(delay * 1000);
    841 		return true;
    842 	},
    843 	
    844 	
    845 	_check412: function (xmlhttp) {
    846 		// Avoid logging error from Zotero.HTTP.request() in ConcurrentCaller
    847 		if (xmlhttp.status == 412) {
    848 			Zotero.debug("Server returned 412: " + xmlhttp.responseText, 2);
    849 			throw new Zotero.HTTP.UnexpectedStatusException(xmlhttp);
    850 		}
    851 	},
    852 	
    853 	
    854 	_check429: function (xmlhttp) {
    855 		if (xmlhttp.status != 429) return false;
    856 		
    857 		// If there's a Retry-After header, use that
    858 		if (this._checkRetry(xmlhttp)) {
    859 			return true;
    860 		}
    861 		
    862 		// Otherwise, pause for increasing amounts, or max amount if no more
    863 		var delay = this.rateDelayIntervals[this.rateDelayPosition++]
    864 			|| this.rateDelayIntervals[this.rateDelayIntervals.length - 1];
    865 		this.caller.pause(delay * 1000);
    866 		return true;
    867 	},
    868 	
    869 	
    870 	_getLastModifiedVersion: function (xmlhttp) {
    871 		libraryVersion = xmlhttp.getResponseHeader('Last-Modified-Version');
    872 		if (!libraryVersion) {
    873 			throw new Error("Last-Modified-Version not provided");
    874 		}
    875 		return libraryVersion;
    876 	}
    877 }