www

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

locateManager.js (15069B)


      1 /*
      2     ***** BEGIN LICENSE BLOCK *****
      3     
      4     Copyright © 2009 Center for History and New Media
      5                      George Mason University, Fairfax, Virginia, USA
      6                      http://zotero.org
      7     
      8     This file is part of Zotero.
      9     
     10     Zotero is free software: you can redistribute it and/or modify
     11     it under the terms of the GNU Affero General Public License as published by
     12     the Free Software Foundation, either version 3 of the License, or
     13     (at your option) any later version.
     14     
     15     Zotero is distributed in the hope that it will be useful,
     16     but WITHOUT ANY WARRANTY; without even the implied warranty of
     17     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     18     GNU Affero General Public License for more details.
     19     
     20     You should have received a copy of the GNU Affero General Public License
     21     along with Zotero.  If not, see <http://www.gnu.org/licenses/>.
     22     
     23     ***** END LICENSE BLOCK *****
     24 */
     25 
     26 Zotero.LocateManager = new function() {
     27 	const LOCATE_FILE_NAME = "engines.json";
     28 	const LOCATE_DIR_NAME = "locate";
     29 	
     30 	var _jsonFile;
     31 	var _locateEngines;
     32 	var _ios;
     33 	var _timer;
     34 	
     35 	/**
     36 	 * Read locateEngines JSON file to initialize locate manager
     37 	 */
     38 	this.init = async function() {
     39 		_ios = Components.classes["@mozilla.org/network/io-service;1"].
     40 				  getService(Components.interfaces.nsIIOService);
     41 		
     42 		_jsonFile = _getLocateFile();
     43 		
     44 		try {
     45 			if (await OS.File.exists(_jsonFile)) {
     46 				_locateEngines = JSON.parse(await Zotero.File.getContentsAsync(_jsonFile))
     47 					.map(engine => new LocateEngine(engine));
     48 			}
     49 			else {
     50 				await this.restoreDefaultEngines();
     51 			}
     52 		}
     53 		catch (e) {
     54 			Zotero.logError(e);
     55 		}
     56 	}
     57 	
     58 	/**
     59 	 * Adds a new search engine
     60 	 * confirm parameter is currently ignored
     61 	 */
     62 	this.addEngine = function(engineURL, dataType, iconURL, confirm) {
     63 		if(dataType !== Components.interfaces.nsISearchEngine.TYPE_OPENSEARCH) {
     64 			throw "LocateManager supports only OpenSearch engines";
     65 		}
     66 		
     67 		Zotero.HTTP.doGet(engineURL, function(xmlhttp) {
     68 			var engine = new LocateEngine();
     69 			engine.initWithXML(xmlhttp.responseText, iconURL);
     70 		});
     71 	}
     72 	
     73 	/**
     74 	 * Gets all default search engines (not currently used)
     75 	 */
     76 	this.getDefaultEngines = function () {
     77 		return JSON.parse(Zotero.File.getContentsFromURL(_getDefaultFile()))
     78 			.map(engine => new LocateEngine(engine));
     79 	}
     80 	
     81 	/**
     82 	 * Returns an array of all search engines
     83 	 */
     84 	this.getEngines = function() { return _locateEngines.slice(0); }
     85 	
     86 	/**
     87 	 * Returns an array of all search engines visible that should be visible in the dropdown
     88 	 */
     89 	this.getVisibleEngines = function () {
     90 		return _locateEngines.filter(engine => !engine.hidden);
     91 	}
     92 	
     93 	/**
     94 	 * Returns an engine with a specific name
     95 	 */
     96 	this.getEngineByName = function(engineName) {
     97 		engineName = engineName.toLowerCase();
     98 		for (let engine of _locateEngines) if(engine.name.toLowerCase() == engineName) return engine;
     99 		return null;
    100 	}
    101 	
    102 	/**
    103 	 * Returns the first engine with a specific alias
    104 	 */
    105 	this.getEngineByAlias = function(engineAlias) {
    106 		engineAlias = engineAlias.toLowerCase();
    107 		for (let engine of _locateEngines) if(engine.alias.toLowerCase() == engineAlias) return engine;
    108 		return null;
    109 	}
    110 	
    111 	/**
    112 	 * Moves an engine in the list
    113 	 */
    114 	this.moveEngine = function(engine, newIndex) {
    115 		this.removeEngine(engine);
    116 		_locateEngines.splice(newIndex, engine);
    117 	}
    118 	
    119 	/**
    120 	 * Removes an engine from the list
    121 	 */
    122 	this.removeEngine = function(engine) {
    123 		var oldIndex = _locateEngines.indexOf(engine);
    124 		if(oldIndex === -1) throw "Engine is not currently listed";
    125 		_locateEngines.splice(oldIndex, 1);
    126 		engine._removeIcon();
    127 		_serializeLocateEngines();
    128 	}
    129 	
    130 	/**
    131 	 * Restore default engines by copying file from extension dir
    132 	 */
    133 	this.restoreDefaultEngines = async function () {
    134 		// get locate dir
    135 		var locateDir = _getLocateDirectory();
    136 		
    137 		// remove old locate dir
    138 		await OS.File.removeDir(locateDir, { ignoreAbsent: true, ignorePermissions: true });
    139 		
    140 		// create new locate dir
    141 		await OS.File.makeDir(locateDir, { unixMode: 0o755 });
    142 		
    143 		// copy default file to new locate dir
    144 		await Zotero.File.putContentsAsync(
    145 			_jsonFile,
    146 			await Zotero.File.getContentsFromURLAsync(_getDefaultFile())
    147 		);
    148 		
    149 		// reread locate engines
    150 		await this.init();
    151 		
    152 		// reload icons for default locate engines
    153 		for (let engine of this.getEngines()) engine._updateIcon();
    154 	}
    155 	
    156 	/**
    157 	 * Writes the engines to disk; called from the nsITimer spawned by _serializeLocateEngines
    158 	 */
    159 	this.notify = async function () {
    160 		await Zotero.File.putContentsAsync(_jsonFile, JSON.stringify(_locateEngines, null, "\t"));
    161 		_timer = undefined;
    162 	}
    163 	
    164 	/**
    165 	 * Gets the JSON file containing engine info
    166 	 */
    167 	function _getLocateFile() {
    168 		return OS.Path.join(_getLocateDirectory(), LOCATE_FILE_NAME);
    169 	}
    170 	
    171 	/**
    172 	 * Gets the dir containing the JSON file and engine icons
    173 	 */
    174 	function _getLocateDirectory() {
    175 		return OS.Path.join(Zotero.DataDirectory.dir, LOCATE_DIR_NAME);
    176 	}
    177 	
    178 	/**
    179 	 * Gets the JSON file containing the engine info for the default engines
    180 	 */
    181 	function _getDefaultFile() {
    182 		return "resource://zotero/schema/"+LOCATE_FILE_NAME;
    183 	}
    184 	
    185 	
    186 	/**
    187 	 * Writes the engines to disk when the current block is finished executing
    188 	 */
    189 	function _serializeLocateEngines() {
    190 		if(_timer) return;
    191 		_timer = Components.classes["@mozilla.org/timer;1"].
    192 				createInstance(Components.interfaces.nsITimer);
    193 		_timer.initWithCallback(Zotero.LocateManager, 0, Components.interfaces.nsITimer.TYPE_ONE_SHOT);
    194 	}
    195 	
    196 	/**
    197 	 * Function to call to attach to watch engine properties and perform deferred serialization
    198 	 */
    199 	function _watchLocateEngineProperties(id, oldval, newval) {
    200 		if(oldval !== newval) _serializeLocateEngines();
    201 		return newval;
    202 	}
    203 	
    204 	/**
    205 	 * Looks up a parameter in our list
    206 	 *
    207 	 * Supported parameters include
    208 	 * - all standard OpenURL parameters, identified by any OpenURL namespace
    209 	 * - "version", "identifier", and "format" identified by the OpenURL ctx namespace
    210 	 * - "openURL" identified by the Zotero namespace (= the whole openURL)
    211 	 * - "year" identified by the Zotero namespace
    212 	 * - any Zotero field identified by the Zotero namespace
    213 	 */
    214 	function _lookupParam(item, itemOpenURL, engine, nsPrefix, param) {
    215 		const OPENURL_ITEM_PREFIXES = [
    216 			"info:ofi/fmt:kev:mtx:journal",
    217 			"info:ofi/fmt:kev:mtx:book",
    218 			"info:ofi/fmt:kev:mtx:patent",
    219 			"info:ofi/fmt:kev:mtx:sch_svc",
    220 			"info:ofi/fmt:kev:mtx:dissertation"
    221 		];
    222 		
    223 		const OPENURL_CONTEXT_MAPPINGS = {
    224 			"version":"ctx_ver",
    225 			"identifier":"rfr_id",
    226 			"format":"rft_val_fmt"
    227 		};
    228 		
    229 		if(nsPrefix) {
    230 			var ns = engine._urlNamespaces[nsPrefix];
    231 			if(!ns) return false;
    232 		} else {
    233 			if(param === "searchTerms") return [item.title];
    234 			return false;
    235 		}
    236 		
    237 		if(OPENURL_ITEM_PREFIXES.indexOf(ns) !== -1) {
    238 			// take a normal "title," even though we don't use it, because it is valid (but not
    239 			// preferred) OpenURL
    240 			if(param === "title") {
    241 				var title = item.title;
    242 				return (title ? [encodeURIComponent(title)] : false);
    243 			}
    244 			
    245 			if(!itemOpenURL["rft."+param]) {
    246 				return false;
    247 			}
    248 			
    249 			return itemOpenURL["rft."+param].map(val => encodeURIComponent(val));
    250 		} else if(ns === "info:ofi/fmt:kev:mtx:ctx") {
    251 			if(!OPENURL_CONTEXT_MAPPINGS[param] || !itemOpenURL[OPENURL_CONTEXT_MAPPINGS[param]]) {
    252 				return false;
    253 			}
    254 			return itemOpenURL[OPENURL_CONTEXT_MAPPINGS[param]].map(val => encodeURIComponent(val));
    255 		} else if(ns === "http://www.zotero.org/namespaces/openSearch#") {
    256 			if(param === "openURL") {
    257 				var ctx = Zotero.OpenURL.createContextObject(item, "1.0");
    258 				return (ctx ? [ctx] : false);
    259 			} else if(param === "year") {
    260 				return (itemOpenURL["rft.date"] ? [itemOpenURL["rft.date"][0].substr(0, 4)] : false);
    261 			} else {
    262 				var result = item[param];
    263 				return (result ? [encodeURIComponent(result)] : false);
    264 			}
    265 		} else {
    266 			return false;
    267 		}
    268 	}
    269 	
    270 	/**
    271 	 * Theoretically implements nsISearchSubmission
    272 	 */
    273 	var LocateSubmission = function(uri, postData) {
    274 		this.uri = _ios.newURI(uri, null, null);
    275 		this.postData = postData;
    276 	}
    277 	
    278 	/**
    279 	 * @constructor
    280 	 * Constructs a new LocateEngine
    281 	 * @param {Object} [obj] The locate engine, in parsed form, as it was serialized to JSON
    282 	 */
    283 	var LocateEngine = function(obj) {
    284 		this.alias = this.name = "Untitled";
    285 		this.description = this._urlTemplate = this.icon = null;
    286 		this.hidden = false;
    287 		this._urlParams = [];
    288 		
    289 		if(obj) for(var prop in obj) this[prop] = obj[prop];
    290 		
    291 		// Queue deferred serialization whenever a property is modified
    292 		for (let prop of ["alias", "name", "description", "icon", "hidden"]) {
    293 			this.watch(prop, _watchLocateEngineProperties);
    294 		}
    295 	}
    296 	
    297 	LocateEngine.prototype = {
    298 		/**
    299 		 * Initializes an engine with a string and an iconURL to use if none is defined in the file
    300 		 */
    301 		"initWithXML":function(xmlStr, iconURL) {
    302 			const OPENSEARCH_NAMESPACES = [
    303 			  // These are the official namespaces
    304 			  "http://a9.com/-/spec/opensearch/1.1/",
    305 			  "http://a9.com/-/spec/opensearch/1.0/",
    306 			  // These were also in nsSearchService.js
    307 			  "http://a9.com/-/spec/opensearchdescription/1.1/",
    308 			  "http://a9.com/-/spec/opensearchdescription/1.0/"
    309 			];
    310 			
    311 			var parser = Components.classes["@mozilla.org/xmlextras/domparser;1"]
    312 					.createInstance(Components.interfaces.nsIDOMParser),
    313 				doc = parser.parseFromString(xmlStr, "application/xml"),
    314 				docEl = doc.documentElement,
    315 				ns = docEl.namespaceURI,
    316 				xns = {"s":doc.documentElement.namespaceURI,
    317 					"xmlns":"http://www.w3.org/2000/xmlns"};
    318 			if(OPENSEARCH_NAMESPACES.indexOf(ns) === -1) {
    319 				throw "Invalid namespace";
    320 			}
    321 			
    322 			// get simple attributes
    323 			this.alias = Zotero.Utilities.xpathText(docEl, 's:ShortName', xns);
    324 			this.name = Zotero.Utilities.xpathText(docEl, 's:LongName', xns);
    325 			if(!this.name) this.name = this.alias;
    326 			this.description = Zotero.Utilities.xpathText(docEl, 's:Description', xns);
    327 			
    328 			// get the URL template
    329 			this._urlTemplate = undefined;
    330 			var urlTags = Zotero.Utilities.xpath(docEl, 's:Url[@type="text/html"]', xns),
    331 				i = 0;
    332 			while(urlTags[i].hasAttribute("rel") && urlTags[i].getAttribute("rel") != "results") {
    333 				i++;
    334 				if(i == urlTags.length) throw "No Url tag found";
    335 			}
    336 			
    337 			// TODO: better error handling
    338 			var urlTag = urlTags[i];
    339 			this._urlTemplate = urlTag.getAttribute("template")
    340 			this._method = urlTag.getAttribute("method").toString().toUpperCase() === "POST" ? "POST" : "GET";
    341 			
    342 			// get namespaces
    343 			this._urlNamespaces = {};
    344 			var node = urlTag;
    345 			while(node && node.attributes) {
    346 				for(var i=0; i<node.attributes.length; i++) {
    347 					var attr = node.attributes[i];
    348 					if(attr.namespaceURI == "http://www.w3.org/2000/xmlns/") {
    349 						this._urlNamespaces[attr.localName] = attr.nodeValue;
    350 					}
    351 				}
    352 				node = node.parentNode;
    353 			}
    354 			
    355 			// get params
    356 			this._urlParams = [];
    357 			for(var param of Zotero.Utilities.xpath(urlTag, 's:Param', xns)) {
    358 				this._urlParams[param.getAttribute("name")] = param.getAttribute("value");
    359 			}
    360 			
    361 			// find the icon
    362 			this._iconSourceURI = iconURL;
    363 			for(var img of Zotero.Utilities.xpath(docEl, 's:Image', xns)) {
    364 				if((!img.hasAttribute("width") && !img.hasAttribute("height"))
    365 						|| (img.getAttribute("width") == "16" && img.getAttribute("height") == "16")) {
    366 					this._iconSourceURI = img.textContent;
    367 				}
    368 			}
    369 			
    370 			if(this._iconSourceURI) {
    371 				// begin fetching the icon if necesssary
    372 				this._updateIcon();
    373 			}
    374 			
    375 			// delete any old engine with the same name
    376 			var engine = Zotero.LocateManager.getEngineByName(this.name);
    377 			if(engine) Zotero.LocateManager.removeEngine(engine);
    378 			
    379 			// add and serialize the new engine
    380 			_locateEngines.push(this);
    381 			_serializeLocateEngines();
    382 		},
    383 		
    384 		"getItemSubmission":function(item, responseType) {
    385 			if(responseType && responseType !== "text/html") {
    386 				throw "LocateManager supports only responseType text/html";
    387 			}
    388 			
    389 			if (item.toJSON) {
    390 				item = item.toJSON();
    391 			}
    392 			
    393 			var itemAsOpenURL = Zotero.OpenURL.createContextObject(item, "1.0", true);
    394 			
    395 			// do substitutions
    396 			var me = this;
    397 			var abort = false;
    398 			var url = this._urlTemplate.replace(/{(?:([^}:]+):)?([^}:?]+)(\?)?}/g, function(all, nsPrefix, param, required) {
    399 				var result = _lookupParam(item, itemAsOpenURL, me, nsPrefix, param, required);
    400 				if(result) {
    401 					return result[0];
    402 				} else {
    403 					if(required) {	// if no param and it wasn't optional, return
    404 						return "";
    405 					} else {
    406 						abort = true;
    407 					}
    408 				}
    409 			});
    410 			if(abort) return null;
    411 			
    412 			// handle params
    413 			var paramsToAdd = [];
    414 			for(var param in this._urlParams) {
    415 				var m = this._urlParams[param].match(/^{(?:([^}:]+):)?([^}:?]+)(\?)?}$/);
    416 				if(!m) {
    417 					paramsToAdd.push(encodeURIComponent(param)+"="+encodeURIComponent(this._urlParams[param]));
    418 				} else {
    419 					var result = _lookupParam(item, itemAsOpenURL, me, m[1], m[2]);
    420 					if(result) {
    421 						paramsToAdd = paramsToAdd.concat(
    422 							result.map(val =>
    423 								encodeURIComponent(param) + "=" + encodeURIComponent(val))
    424 						);
    425 					} else if(m[3]) {	// if no param and it wasn't optional, return
    426 						return null;
    427 					}
    428 				}
    429 			}
    430 			
    431 			// attach params
    432 			if(paramsToAdd.length) {
    433 				if(this._method === "POST") {
    434 					var postData = paramsToAdd.join("&");
    435 				} else {
    436 					var postData = null;
    437 					if(url.indexOf("?") === -1) {
    438 						url += "?"+paramsToAdd.join("&");
    439 					} else {
    440 						url += "&"+paramsToAdd.join("&");
    441 					}
    442 				}
    443 			}
    444 			
    445 			return new LocateSubmission(url, postData);
    446 		},
    447 		
    448 		"_removeIcon":function() {
    449 			if(!this.icon) return;
    450 			var uri = _ios.newURI(this.icon, null, null);
    451 			var file = uri.QueryInterface(Components.interfaces.nsIFileURL).file;
    452 			if(file.exists()) file.remove(null);
    453 		},
    454 		
    455 		_updateIcon: async function () {
    456 			const iconExtensions = {
    457 				"image/png": "png",
    458 				"image/jpeg": "jpg",
    459 				"image/gif": "gif",
    460 				"image/vnd.microsoft.icon": "ico"
    461 			};
    462 			
    463 			if (!this._iconSourceURI.startsWith('http') && !this._iconSourceURI.startsWith('https')) {
    464 				return;
    465 			}
    466 			
    467 			var tmpPath = OS.Path.join(Zotero.getTempDirectory().path, Zotero.Utilities.randomString());
    468 			await Zotero.File.download(this._iconSourceURI, tmpPath);
    469 			
    470 			var sample = await Zotero.File.getSample(tmpPath);
    471 			var contentType = Zotero.MIME.getMIMETypeFromData(sample);
    472 			
    473 			// ensure there is an extension
    474 			var extension = iconExtensions[contentType.toLowerCase()];
    475 			if (!extension) {
    476 				throw new Error(`Invalid content type ${contentType} for icon for engine ${this.name}`);
    477 			}
    478 			
    479 			// Find a good place to put the icon file
    480 			var sanitizedAlias = this.name.replace(/[^\w _]/g, "");
    481 			var iconFile = OS.Path.join(_getLocateDirectory(), sanitizedAlias + "." + extension);
    482 			if (await OS.File.exists(iconFile)) {
    483 				for (let i = 0; await OS.File.exists(iconFile); i++) {
    484 					iconFile = OS.Path.join(
    485 						OS.Path.dirname(iconFile),
    486 						sanitizedAlias + "_" + i + "." + extension
    487 					);
    488 				}
    489 			}
    490 			
    491 			await OS.File.move(tmpPath, iconFile);
    492 			this.icon = OS.Path.toFileURI(iconFile);
    493 		}
    494 	}
    495 }