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 }