zotero-protocol-handler.js (42793B)
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 24 Based on nsChromeExtensionHandler example code by Ed Anuff at 25 http://kb.mozillazine.org/Dev_:_Extending_the_Chrome_Protocol 26 27 ***** END LICENSE BLOCK ***** 28 */ 29 30 const ZOTERO_SCHEME = "zotero"; 31 const ZOTERO_PROTOCOL_CID = Components.ID("{9BC3D762-9038-486A-9D70-C997AF848A7C}"); 32 const ZOTERO_PROTOCOL_CONTRACTID = "@mozilla.org/network/protocol;1?name=" + ZOTERO_SCHEME; 33 const ZOTERO_PROTOCOL_NAME = "Zotero Chrome Extension Protocol"; 34 35 const Cc = Components.classes; 36 const Ci = Components.interfaces; 37 const Cr = Components.results; 38 39 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); 40 41 // Dummy chrome URL used to obtain a valid chrome channel 42 // This one was chosen at random and should be able to be substituted 43 // for any other well known chrome URL in the browser installation 44 const DUMMY_CHROME_URL = "chrome://mozapps/content/xpinstall/xpinstallConfirm.xul"; 45 46 var Zotero = Components.classes["@zotero.org/Zotero;1"] 47 .getService(Components.interfaces.nsISupports) 48 .wrappedJSObject; 49 50 var ioService = Components.classes["@mozilla.org/network/io-service;1"] 51 .getService(Components.interfaces.nsIIOService); 52 53 function ZoteroProtocolHandler() { 54 this.wrappedJSObject = this; 55 this._principal = null; 56 this._extensions = {}; 57 58 59 /** 60 * zotero://data/library/collection/ABCD1234/items?sort=itemType&direction=desc 61 * zotero://data/groups/12345/collection/ABCD1234/items?sort=title&direction=asc 62 */ 63 var DataExtension = { 64 loadAsChrome: false, 65 66 newChannel: function (uri) { 67 return new AsyncChannel(uri, function* () { 68 this.contentType = 'text/plain'; 69 70 path = uri.spec.match(/zotero:\/\/[^/]+(.*)/)[1]; 71 72 try { 73 return Zotero.Utilities.Internal.getAsyncInputStream( 74 Zotero.API.Data.getGenerator(path) 75 ); 76 } 77 catch (e) { 78 if (e instanceof Zotero.Router.InvalidPathException) { 79 return "URL could not be parsed"; 80 } 81 } 82 }); 83 } 84 }; 85 86 87 /* 88 * Report generation extension for Zotero protocol 89 */ 90 var ReportExtension = { 91 loadAsChrome: false, 92 93 newChannel: function (uri) { 94 return new AsyncChannel(uri, function* () { 95 var userLibraryID = Zotero.Libraries.userLibraryID; 96 97 var path = uri.path; 98 if (!path) { 99 return 'Invalid URL'; 100 } 101 // Strip leading '/' 102 path = path.substr(1); 103 104 // Proxy CSS files 105 if (path.endsWith('.css')) { 106 var chromeURL = 'chrome://zotero/skin/report/' + path; 107 Zotero.debug(chromeURL); 108 var ios = Components.classes["@mozilla.org/network/io-service;1"] 109 .getService(Components.interfaces.nsIIOService); 110 let uri = ios.newURI(chromeURL, null, null); 111 var chromeReg = Components.classes["@mozilla.org/chrome/chrome-registry;1"] 112 .getService(Components.interfaces.nsIChromeRegistry); 113 return chromeReg.convertChromeURL(uri); 114 } 115 116 var params = { 117 objectType: 'item', 118 format: 'html', 119 sort: 'title' 120 }; 121 var router = new Zotero.Router(params); 122 123 // Items within a collection or search 124 router.add('library/:scopeObject/:scopeObjectKey/items', function () { 125 params.libraryID = userLibraryID; 126 }); 127 router.add('groups/:groupID/:scopeObject/:scopeObjectKey/items'); 128 129 // All items 130 router.add('library/items/:objectKey', function () { 131 params.libraryID = userLibraryID; 132 }); 133 router.add('groups/:groupID/items'); 134 135 // Old-style URLs 136 router.add('collection/:id/html/report.html', function () { 137 params.scopeObject = 'collections'; 138 var lkh = Zotero.Collections.parseLibraryKeyHash(params.id); 139 if (lkh) { 140 params.libraryID = lkh.libraryID || userLibraryID; 141 params.scopeObjectKey = lkh.key; 142 } 143 else { 144 params.scopeObjectID = params.id; 145 } 146 delete params.id; 147 }); 148 router.add('search/:id/html/report.html', function () { 149 params.scopeObject = 'searches'; 150 var lkh = Zotero.Searches.parseLibraryKeyHash(this.id); 151 if (lkh) { 152 params.libraryID = lkh.libraryID || userLibraryID; 153 params.scopeObjectKey = lkh.key; 154 } 155 else { 156 params.scopeObjectID = this.id; 157 } 158 delete params.id; 159 }); 160 router.add('items/:ids/html/report.html', function () { 161 var ids = this.ids.split('-'); 162 params.libraryID = ids[0].split('_')[0] || userLibraryID; 163 params.itemKey = ids.map(x => x.split('_')[1]); 164 delete params.ids; 165 }); 166 167 var parsed = router.run(path); 168 if (!parsed) { 169 return "URL could not be parsed"; 170 } 171 172 // TODO: support old URLs 173 // collection 174 // search 175 // items 176 // item 177 if (params.sort.indexOf('/') != -1) { 178 let parts = params.sort.split('/'); 179 params.sort = parts[0]; 180 params.direction = parts[1] == 'd' ? 'desc' : 'asc'; 181 } 182 183 try { 184 Zotero.API.parseParams(params); 185 var results = yield Zotero.API.getResultsFromParams(params); 186 } 187 catch (e) { 188 Zotero.debug(e, 1); 189 return e.toString(); 190 } 191 192 var mimeType, content = ''; 193 var items = []; 194 var itemsHash = {}; // key = itemID, val = position in |items| 195 var searchItemIDs = new Set(); // All selected items 196 var searchParentIDs = new Set(); // Parents of selected child items 197 var searchChildIDs = new Set() // Selected chlid items 198 199 var includeAllChildItems = Zotero.Prefs.get('report.includeAllChildItems'); 200 var combineChildItems = Zotero.Prefs.get('report.combineChildItems'); 201 202 var unhandledParents = {}; 203 for (var i=0; i<results.length; i++) { 204 // Don't add child items directly 205 // (instead mark their parents for inclusion below) 206 var parentItemID = results[i].parentItemID; 207 if (parentItemID) { 208 searchParentIDs.add(parentItemID); 209 searchChildIDs.add(results[i].id); 210 211 // Don't include all child items if any child 212 // items were selected 213 includeAllChildItems = false; 214 } 215 // If combining children or standalone note/attachment, add matching parents 216 else if (combineChildItems || !results[i].isRegularItem() 217 || results[i].numChildren() == 0) { 218 itemsHash[results[i].id] = [items.length]; 219 items.push(results[i].toJSON({ mode: 'full' })); 220 // Flag item as a search match 221 items[items.length - 1].reportSearchMatch = true; 222 } 223 else { 224 unhandledParents[i] = true; 225 } 226 searchItemIDs.add(results[i].id); 227 } 228 229 // If including all child items, add children of all matched 230 // parents to the child array 231 if (includeAllChildItems) { 232 for (let id of searchItemIDs) { 233 if (!searchChildIDs.has(id)) { 234 var children = []; 235 var item = yield Zotero.Items.getAsync(id); 236 if (!item.isRegularItem()) { 237 continue; 238 } 239 var func = function (ids) { 240 if (ids) { 241 for (var i=0; i<ids.length; i++) { 242 searchChildIDs.add(ids[i]); 243 } 244 } 245 }; 246 func(item.getNotes()); 247 func(item.getAttachments()); 248 } 249 } 250 } 251 // If not including all children, add matching parents, 252 // in case they don't have any matching children below 253 else { 254 for (var i in unhandledParents) { 255 itemsHash[results[i].id] = [items.length]; 256 items.push(results[i].toJSON({ mode: 'full' })); 257 // Flag item as a search match 258 items[items.length - 1].reportSearchMatch = true; 259 } 260 } 261 262 if (combineChildItems) { 263 // Add parents of matches if parents aren't matches themselves 264 for (let id of searchParentIDs) { 265 if (!searchItemIDs.has(id) && !itemsHash[id]) { 266 var item = yield Zotero.Items.getAsync(id); 267 itemsHash[id] = items.length; 268 items.push(item.toJSON({ mode: 'full' })); 269 } 270 } 271 272 // Add children to reportChildren property of parents 273 for (let id of searchChildIDs) { 274 let item = yield Zotero.Items.getAsync(id); 275 var parentID = item.parentID; 276 if (!items[itemsHash[parentID]].reportChildren) { 277 items[itemsHash[parentID]].reportChildren = { 278 notes: [], 279 attachments: [] 280 }; 281 } 282 if (item.isNote()) { 283 items[itemsHash[parentID]].reportChildren.notes.push(item.toJSON({ mode: 'full' })); 284 } 285 if (item.isAttachment()) { 286 items[itemsHash[parentID]].reportChildren.attachments.push(item.toJSON({ mode: 'full' })); 287 } 288 } 289 } 290 // If not combining children, add a parent/child pair 291 // for each matching child 292 else { 293 for (let id of searchChildIDs) { 294 var item = yield Zotero.Items.getAsync(id); 295 var parentID = item.parentID; 296 var parentItem = Zotero.Items.get(parentID); 297 298 if (!itemsHash[parentID]) { 299 // If parent is a search match and not yet added, 300 // add on its own 301 if (searchItemIDs.has(parentID)) { 302 itemsHash[parentID] = [items.length]; 303 items.push(parentItem.toJSON({ mode: 'full' })); 304 items[items.length - 1].reportSearchMatch = true; 305 } 306 else { 307 itemsHash[parentID] = []; 308 } 309 } 310 311 // Now add parent and child 312 itemsHash[parentID].push(items.length); 313 items.push(parentItem.toJSON({ mode: 'full' })); 314 if (item.isNote()) { 315 items[items.length - 1].reportChildren = { 316 notes: [item.toJSON({ mode: 'full' })], 317 attachments: [] 318 }; 319 } 320 else if (item.isAttachment()) { 321 items[items.length - 1].reportChildren = { 322 notes: [], 323 attachments: [item.toJSON({ mode: 'full' })] 324 }; 325 } 326 } 327 } 328 329 // Sort items 330 // TODO: restore multiple sort fields 331 var sorts = [{ 332 field: params.sort, 333 order: params.direction != 'desc' ? 1 : -1 334 }]; 335 336 337 var collation = Zotero.getLocaleCollation(); 338 var compareFunction = function(a, b) { 339 var index = 0; 340 341 // Multidimensional sort 342 do { 343 // In combineChildItems, use note or attachment as item 344 if (!combineChildItems) { 345 if (a.reportChildren) { 346 if (a.reportChildren.notes.length) { 347 a = a.reportChildren.notes[0]; 348 } 349 else { 350 a = a.reportChildren.attachments[0]; 351 } 352 } 353 354 if (b.reportChildren) { 355 if (b.reportChildren.notes.length) { 356 b = b.reportChildren.notes[0]; 357 } 358 else { 359 b = b.reportChildren.attachments[0]; 360 } 361 } 362 } 363 364 var valA, valB; 365 366 if (sorts[index].field == 'title') { 367 // For notes, use content for 'title' 368 if (a.itemType == 'note') { 369 valA = a.note; 370 } 371 else { 372 valA = a.title; 373 } 374 375 if (b.itemType == 'note') { 376 valB = b.note; 377 } 378 else { 379 valB = b.title; 380 } 381 382 valA = Zotero.Items.getSortTitle(valA); 383 valB = Zotero.Items.getSortTitle(valB); 384 } 385 else if (sorts[index].field == 'date') { 386 var itemA = Zotero.Items.getByLibraryAndKey(params.libraryID, a.key); 387 var itemB = Zotero.Items.getByLibraryAndKey(params.libraryID, b.key); 388 valA = itemA.getField('date', true, true); 389 valB = itemB.getField('date', true, true); 390 } 391 // TEMP: This is an ugly hack to make creator sorting 392 // slightly less broken. To do this right, real creator 393 // sorting needs to be abstracted from itemTreeView.js. 394 else if (sorts[index].field == 'firstCreator') { 395 var itemA = Zotero.Items.getByLibraryAndKey(params.libraryID, a.key); 396 var itemB = Zotero.Items.getByLibraryAndKey(params.libraryID, b.key); 397 valA = itemA.getField('firstCreator'); 398 valB = itemB.getField('firstCreator'); 399 } 400 else { 401 valA = a[sorts[index].field]; 402 valB = b[sorts[index].field]; 403 } 404 405 // Put empty values last 406 if (!valA && valB) { 407 var cmp = 1; 408 } 409 else if (valA && !valB) { 410 var cmp = -1; 411 } 412 else { 413 var cmp = collation.compareString(0, valA, valB); 414 } 415 416 var result = 0; 417 if (cmp != 0) { 418 result = cmp * sorts[index].order; 419 } 420 index++; 421 } 422 while (result == 0 && sorts[index]); 423 424 return result; 425 }; 426 427 items.sort(compareFunction); 428 for (var i in items) { 429 if (items[i].reportChildren) { 430 items[i].reportChildren.notes.sort(compareFunction); 431 items[i].reportChildren.attachments.sort(compareFunction); 432 } 433 } 434 435 // Pass off to the appropriate handler 436 switch (params.format) { 437 case 'rtf': 438 this.contentType = 'text/rtf'; 439 return ''; 440 441 case 'csv': 442 this.contentType = 'text/plain'; 443 return ''; 444 445 default: 446 this.contentType = 'text/html'; 447 return Zotero.Utilities.Internal.getAsyncInputStream( 448 Zotero.Report.HTML.listGenerator(items, combineChildItems), 449 function () { 450 return '<span style="color: red; font-weight: bold">Error generating report</span>'; 451 } 452 ); 453 } 454 }); 455 } 456 }; 457 458 /** 459 * Generate MIT SIMILE Timeline 460 * 461 * Query string key abbreviations: intervals = i 462 * dateType = t 463 * timelineDate = d 464 * 465 * interval abbreviations: day = d | month = m | year = y | decade = e | century = c | millennium = i 466 * dateType abbreviations: date = d | dateAdded = da | dateModified = dm 467 * timelineDate format: shortMonthName.day.year (year is positive for A.D. and negative for B.C.) 468 * 469 * Defaults: intervals = month, year, decade 470 * dateType = date 471 * timelineDate = today's date 472 */ 473 var TimelineExtension = { 474 loadAsChrome: true, 475 476 newChannel: function (uri) { 477 return new AsyncChannel(uri, function* () { 478 var userLibraryID = Zotero.Libraries.userLibraryID; 479 480 path = uri.spec.match(/zotero:\/\/[^/]+(.*)/)[1]; 481 if (!path) { 482 this.contentType = 'text/html'; 483 return 'Invalid URL'; 484 } 485 486 var params = {}; 487 var router = new Zotero.Router(params); 488 489 // HTML 490 router.add('library/:scopeObject/:scopeObjectKey', function () { 491 params.libraryID = userLibraryID; 492 params.controller = 'html'; 493 }); 494 router.add('groups/:groupID/:scopeObject/:scopeObjectKey', function () { 495 params.controller = 'html'; 496 }); 497 router.add('library', function () { 498 params.libraryID = userLibraryID; 499 params.controller = 'html'; 500 }); 501 router.add('groups/:groupID', function () { 502 params.controller = 'html'; 503 }); 504 505 // Data 506 router.add('data/library/:scopeObject/:scopeObjectKey', function () { 507 params.libraryID = userLibraryID; 508 params.controller = 'data'; 509 }); 510 router.add('data/groups/:groupID/:scopeObject/:scopeObjectKey', function () { 511 params.controller = 'data'; 512 }); 513 router.add('data/library', function () { 514 params.libraryID = userLibraryID; 515 params.controller = 'data'; 516 }); 517 router.add('data/groups/:groupID', function () { 518 params.controller = 'data'; 519 }); 520 521 // Old-style HTML URLs 522 router.add('collection/:id', function () { 523 params.controller = 'html'; 524 params.scopeObject = 'collections'; 525 var lkh = Zotero.Collections.parseLibraryKeyHash(params.id); 526 if (lkh) { 527 params.libraryID = lkh.libraryID || userLibraryID; 528 params.scopeObjectKey = lkh.key; 529 } 530 else { 531 params.scopeObjectID = params.id; 532 } 533 delete params.id; 534 }); 535 router.add('search/:id', function () { 536 params.controller = 'html'; 537 params.scopeObject = 'searches'; 538 var lkh = Zotero.Searches.parseLibraryKeyHash(params.id); 539 if (lkh) { 540 params.libraryID = lkh.libraryID || userLibraryID; 541 params.scopeObjectKey = lkh.key; 542 } 543 else { 544 params.scopeObjectID = params.id; 545 } 546 delete params.id; 547 }); 548 router.add('/', function () { 549 params.controller = 'html'; 550 params.libraryID = userLibraryID; 551 }); 552 553 var parsed = router.run(path); 554 if (!parsed) { 555 this.contentType = 'text/html'; 556 return "URL could not be parsed"; 557 } 558 if (params.groupID) { 559 params.libraryID = Zotero.Groups.getLibraryIDFromGroupID(params.groupID); 560 } 561 562 var intervals = params.i ? params.i : ''; 563 var timelineDate = params.d ? params.d : ''; 564 var dateType = params.t ? params.t : ''; 565 566 // Get the collection or search object 567 var collection, search; 568 switch (params.scopeObject) { 569 case 'collections': 570 if (params.scopeObjectKey) { 571 collection = yield Zotero.Collections.getByLibraryAndKeyAsync( 572 params.libraryID, params.scopeObjectKey 573 ); 574 } 575 else { 576 collection = yield Zotero.Collections.getAsync(params.scopeObjectID); 577 } 578 if (!collection) { 579 this.contentType = 'text/html'; 580 return 'Invalid collection ID or key'; 581 } 582 break; 583 584 case 'searches': 585 if (params.scopeObjectKey) { 586 var s = yield Zotero.Searches.getByLibraryAndKeyAsync( 587 params.libraryID, params.scopeObjectKey 588 ); 589 } 590 else { 591 var s = yield Zotero.Searches.getAsync(params.scopeObjectID); 592 } 593 if (!s) { 594 return 'Invalid search ID or key'; 595 } 596 597 // FIXME: Hack to exclude group libraries for now 598 var search = new Zotero.Search(); 599 search.setScope(s); 600 var groups = Zotero.Groups.getAll(); 601 for (let group of groups) { 602 search.addCondition('libraryID', 'isNot', group.libraryID); 603 } 604 break; 605 } 606 607 // 608 // Create XML file 609 // 610 if (params.controller == 'data') { 611 switch (params.scopeObject) { 612 case 'collections': 613 var results = collection.getChildItems(); 614 break; 615 616 case 'searches': 617 var ids = yield search.search(); 618 var results = yield Zotero.Items.getAsync(ids); 619 break; 620 621 default: 622 if (params.scopeObject) { 623 return "Invalid scope object '" + params.scopeObject + "'"; 624 } 625 626 let s = new Zotero.Search(); 627 s.addCondition('libraryID', 'is', params.libraryID); 628 s.addCondition('noChildren', 'true'); 629 var ids = yield s.search(); 630 var results = yield Zotero.Items.getAsync(ids); 631 } 632 633 var items = []; 634 // Only include parent items 635 for (let i=0; i<results.length; i++) { 636 if (!results[i].parentItemID) { 637 items.push(results[i]); 638 } 639 } 640 641 var dateTypes = { 642 d: 'date', 643 da: 'dateAdded', 644 dm: 'dateModified' 645 }; 646 647 //default dateType = date 648 if (!dateType || !dateTypes[dateType]) { 649 dateType = 'd'; 650 } 651 652 this.contentType = 'application/xml'; 653 return Zotero.Utilities.Internal.getAsyncInputStream( 654 Zotero.Timeline.generateXMLDetails(items, dateTypes[dateType]) 655 ); 656 } 657 658 // 659 // Generate main HTML page 660 // 661 content = Zotero.File.getContentsFromURL('chrome://zotero/skin/timeline/timeline.html'); 662 this.contentType = 'text/html'; 663 664 if(!timelineDate){ 665 timelineDate=Date(); 666 var dateParts=timelineDate.toString().split(' '); 667 timelineDate=dateParts[1]+'.'+dateParts[2]+'.'+dateParts[3]; 668 } 669 if (!intervals || intervals.length < 3) { 670 intervals += "mye".substr(intervals.length); 671 } 672 673 var theIntervals = { 674 d: 'Timeline.DateTime.DAY', 675 m: 'Timeline.DateTime.MONTH', 676 y: 'Timeline.DateTime.YEAR', 677 e: 'Timeline.DateTime.DECADE', 678 c: 'Timeline.DateTime.CENTURY', 679 i: 'Timeline.DateTime.MILLENNIUM' 680 }; 681 682 //sets the intervals of the timeline bands 683 var tempStr = '<body onload="onLoad('; 684 var a = (theIntervals[intervals[0]]) ? theIntervals[intervals[0]] : 'Timeline.DateTime.MONTH'; 685 var b = (theIntervals[intervals[1]]) ? theIntervals[intervals[1]] : 'Timeline.DateTime.YEAR'; 686 var c = (theIntervals[intervals[2]]) ? theIntervals[intervals[2]] : 'Timeline.DateTime.DECADE'; 687 content = content.replace(tempStr, tempStr + a + ',' + b + ',' + c + ',\'' + timelineDate + '\''); 688 689 tempStr = 'document.write("<title>'; 690 if (params.scopeObject == 'collections') { 691 content = content.replace(tempStr, tempStr + collection.name + ' - '); 692 } 693 else if (params.scopeObject == 'searches') { 694 content = content.replace(tempStr, tempStr + search.name + ' - '); 695 } 696 else { 697 content = content.replace(tempStr, tempStr + Zotero.getString('pane.collections.library') + ' - '); 698 } 699 700 tempStr = 'Timeline.loadXML("zotero://timeline/data/'; 701 var d = ''; 702 if (params.groupID) { 703 d += 'groups/' + params.groupID + '/'; 704 } 705 else { 706 d += 'library/'; 707 } 708 if (params.scopeObject) { 709 d += params.scopeObject + "/" + params.scopeObjectKey; 710 } 711 if (dateType) { 712 d += '?t=' + dateType; 713 } 714 return content.replace(tempStr, tempStr + d); 715 }); 716 } 717 }; 718 719 720 /* 721 zotero://attachment/[id]/ 722 */ 723 var AttachmentExtension = { 724 loadAsChrome: false, 725 726 newChannel: function (uri) { 727 var self = this; 728 729 return new AsyncChannel(uri, function* () { 730 try { 731 var errorMsg; 732 var [id, fileName] = uri.path.substr(1).split('/'); 733 734 if (parseInt(id) != id) { 735 // Proxy annotation icons 736 if (id.match(/^annotation.*\.(png|html|css|gif)$/)) { 737 var chromeURL = 'chrome://zotero/skin/' + id; 738 var ios = Components.classes["@mozilla.org/network/io-service;1"]. 739 getService(Components.interfaces.nsIIOService); 740 let uri = ios.newURI(chromeURL, null, null); 741 var chromeReg = Components.classes["@mozilla.org/chrome/chrome-registry;1"] 742 .getService(Components.interfaces.nsIChromeRegistry); 743 var fileURI = chromeReg.convertChromeURL(uri); 744 } 745 else { 746 return self._errorChannel("Attachment id not an integer"); 747 } 748 } 749 750 if (!fileURI) { 751 var item = yield Zotero.Items.getAsync(id); 752 if (!item) { 753 return self._errorChannel("Item not found"); 754 } 755 var path = yield item.getFilePathAsync(); 756 if (!path) { 757 return self._errorChannel("File not found"); 758 } 759 if (fileName) { 760 Components.utils.import("resource://gre/modules/osfile.jsm"); 761 path = OS.Path.join(OS.Path.dirname(path), fileName) 762 if (!(yield OS.File.exists(path))) { 763 return self._errorChannel("File not found"); 764 } 765 } 766 } 767 768 //set originalURI so that it seems like we're serving from zotero:// protocol 769 //this is necessary to allow url() links to work from within css files 770 //otherwise they try to link to files on the file:// protocol, which is not allowed 771 this.originalURI = uri; 772 773 return Zotero.File.pathToFile(path); 774 } 775 catch (e) { 776 Zotero.debug(e); 777 throw (e); 778 } 779 }); 780 }, 781 782 783 _errorChannel: function (msg) { 784 this.status = Components.results.NS_ERROR_FAILURE; 785 this.contentType = 'text/plain'; 786 return msg; 787 } 788 }; 789 790 791 /** 792 * zotero://select/[type]/0_ABCD1234 793 * zotero://select/[type]/1234 (not consistent across synced machines) 794 */ 795 var SelectExtension = { 796 noContent: true, 797 798 doAction: Zotero.Promise.coroutine(function* (uri) { 799 var userLibraryID = Zotero.Libraries.userLibraryID; 800 801 var path = uri.path; 802 if (!path) { 803 return 'Invalid URL'; 804 } 805 // Strip leading '/' 806 path = path.substr(1); 807 var mimeType, content = ''; 808 809 var params = { 810 objectType: 'item' 811 }; 812 var router = new Zotero.Router(params); 813 814 // Item within a collection or search 815 router.add('library/:scopeObject/:scopeObjectKey/items/:objectKey', function () { 816 params.libraryID = userLibraryID; 817 }); 818 router.add('groups/:groupID/:scopeObject/:scopeObjectKey/items/:objectKey'); 819 820 // All items 821 router.add('library/items/:objectKey', function () { 822 params.libraryID = userLibraryID; 823 }); 824 router.add('groups/:groupID/items/:objectKey'); 825 826 // Old-style URLs 827 router.add('items/:id', function () { 828 var lkh = Zotero.Items.parseLibraryKeyHash(params.id); 829 if (lkh) { 830 params.libraryID = lkh.libraryID || userLibraryID; 831 params.objectKey = lkh.key; 832 } 833 else { 834 params.objectID = params.id; 835 } 836 delete params.id; 837 }); 838 router.run(path); 839 840 Zotero.API.parseParams(params); 841 var results = yield Zotero.API.getResultsFromParams(params); 842 843 if (!results.length) { 844 var msg = "Items not found"; 845 Zotero.debug(msg, 2); 846 Components.utils.reportError(msg); 847 return; 848 } 849 850 var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] 851 .getService(Components.interfaces.nsIWindowMediator); 852 var win = wm.getMostRecentWindow("navigator:browser"); 853 854 // TODO: Currently only able to select one item 855 return win.ZoteroPane.selectItem(results[0].id); 856 }), 857 858 newChannel: function (uri) { 859 this.doAction(uri); 860 } 861 }; 862 863 /* 864 zotero://fullscreen 865 */ 866 var FullscreenExtension = { 867 loadAsChrome: false, 868 869 newChannel: function (uri) { 870 return new AsyncChannel(uri, function* () { 871 try { 872 var window = Components.classes["@mozilla.org/embedcomp/window-watcher;1"] 873 .getService(Components.interfaces.nsIWindowWatcher) 874 .openWindow(null, 'chrome://zotero/content/standalone/standalone.xul', '', 875 'chrome,centerscreen,resizable', null); 876 } 877 catch (e) { 878 Zotero.debug(e, 1); 879 throw e; 880 } 881 }); 882 } 883 }; 884 885 886 /* 887 zotero://debug/ 888 */ 889 var DebugExtension = { 890 loadAsChrome: false, 891 892 newChannel: function (uri) { 893 return new AsyncChannel(uri, function* () { 894 this.contentType = "text/plain"; 895 896 try { 897 return Zotero.Debug.get(); 898 } 899 catch (e) { 900 Zotero.debug(e, 1); 901 throw e; 902 } 903 }); 904 } 905 }; 906 907 var ConnectorChannel = function(uri, data) { 908 var secMan = Components.classes["@mozilla.org/scriptsecuritymanager;1"] 909 .getService(Components.interfaces.nsIScriptSecurityManager); 910 var ioService = Components.classes["@mozilla.org/network/io-service;1"] 911 .getService(Components.interfaces.nsIIOService); 912 913 this.name = uri; 914 this.URI = ioService.newURI(uri, "UTF-8", null); 915 this.owner = (secMan.getCodebasePrincipal || secMan.getSimpleCodebasePrincipal)(this.URI); 916 this._isPending = true; 917 918 var converter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"]. 919 createInstance(Components.interfaces.nsIScriptableUnicodeConverter); 920 converter.charset = "UTF-8"; 921 this._stream = converter.convertToInputStream(data); 922 this.contentLength = this._stream.available(); 923 } 924 925 ConnectorChannel.prototype.contentCharset = "UTF-8"; 926 ConnectorChannel.prototype.contentType = "text/html"; 927 ConnectorChannel.prototype.notificationCallbacks = null; 928 ConnectorChannel.prototype.securityInfo = null; 929 ConnectorChannel.prototype.status = 0; 930 ConnectorChannel.prototype.loadGroup = null; 931 ConnectorChannel.prototype.loadFlags = 393216; 932 933 ConnectorChannel.prototype.__defineGetter__("originalURI", function() { return this.URI }); 934 ConnectorChannel.prototype.__defineSetter__("originalURI", function() { }); 935 936 ConnectorChannel.prototype.asyncOpen = function(streamListener, context) { 937 if(this.loadGroup) this.loadGroup.addRequest(this, null); 938 streamListener.onStartRequest(this, context); 939 streamListener.onDataAvailable(this, context, this._stream, 0, this.contentLength); 940 streamListener.onStopRequest(this, context, this.status); 941 this._isPending = false; 942 if(this.loadGroup) this.loadGroup.removeRequest(this, null, 0); 943 } 944 945 ConnectorChannel.prototype.isPending = function() { 946 return this._isPending; 947 } 948 949 ConnectorChannel.prototype.cancel = function(status) { 950 this.status = status; 951 this._isPending = false; 952 if(this._stream) this._stream.close(); 953 } 954 955 ConnectorChannel.prototype.suspend = function() {} 956 957 ConnectorChannel.prototype.resume = function() {} 958 959 ConnectorChannel.prototype.open = function() { 960 return this._stream; 961 } 962 963 ConnectorChannel.prototype.QueryInterface = function(iid) { 964 if (!iid.equals(Components.interfaces.nsIChannel) && !iid.equals(Components.interfaces.nsIRequest) && 965 !iid.equals(Components.interfaces.nsISupports)) { 966 throw Components.results.NS_ERROR_NO_INTERFACE; 967 } 968 return this; 969 } 970 971 /** 972 * zotero://connector/ 973 * 974 * URI spoofing for transferring page data across boundaries 975 */ 976 var ConnectorExtension = new function() { 977 this.loadAsChrome = false; 978 979 this.newChannel = function(uri) { 980 var ioService = Components.classes["@mozilla.org/network/io-service;1"] 981 .getService(Components.interfaces.nsIIOService); 982 var secMan = Components.classes["@mozilla.org/scriptsecuritymanager;1"] 983 .getService(Components.interfaces.nsIScriptSecurityManager); 984 var Zotero = Components.classes["@zotero.org/Zotero;1"] 985 .getService(Components.interfaces.nsISupports) 986 .wrappedJSObject; 987 988 try { 989 var originalURI = uri.path; 990 originalURI = decodeURIComponent(originalURI.substr(originalURI.indexOf("/")+1)); 991 if(!Zotero.Server.Connector.Data[originalURI]) { 992 return null; 993 } else { 994 return new ConnectorChannel(originalURI, Zotero.Server.Connector.Data[originalURI]); 995 } 996 } catch(e) { 997 Zotero.debug(e); 998 throw e; 999 } 1000 } 1001 }; 1002 1003 1004 /** 1005 * Open a PDF at a given page (or try to) 1006 * 1007 * zotero://open-pdf/library/items/[itemKey]?page=[page] 1008 * zotero://open-pdf/groups/[groupID]/items/[itemKey]?page=[page] 1009 * 1010 * Also supports ZotFile format: 1011 * zotero://open-pdf/[libraryID]_[key]/[page] 1012 */ 1013 var OpenPDFExtension = { 1014 noContent: true, 1015 1016 doAction: async function (uri) { 1017 var userLibraryID = Zotero.Libraries.userLibraryID; 1018 1019 var uriPath = uri.path; 1020 if (!uriPath) { 1021 return 'Invalid URL'; 1022 } 1023 // Strip leading '/' 1024 uriPath = uriPath.substr(1); 1025 var mimeType, content = ''; 1026 1027 var params = { 1028 objectType: 'item' 1029 }; 1030 var router = new Zotero.Router(params); 1031 1032 // All items 1033 router.add('library/items/:objectKey', function () { 1034 params.libraryID = userLibraryID; 1035 }); 1036 router.add('groups/:groupID/items/:objectKey'); 1037 1038 // ZotFile URLs 1039 router.add(':id/:page', function () { 1040 var lkh = Zotero.Items.parseLibraryKeyHash(params.id); 1041 if (!lkh) { 1042 Zotero.warn(`Invalid URL ${url}`); 1043 return; 1044 } 1045 params.libraryID = lkh.libraryID || userLibraryID; 1046 params.objectKey = lkh.key; 1047 delete params.id; 1048 }); 1049 router.run(uriPath); 1050 1051 Zotero.API.parseParams(params); 1052 var results = await Zotero.API.getResultsFromParams(params); 1053 var page = params.page; 1054 if (parseInt(page) != page) { 1055 page = null; 1056 } 1057 1058 if (!results.length) { 1059 Zotero.warn(`No item found for ${uriPath}`); 1060 return; 1061 } 1062 1063 var item = results[0]; 1064 1065 if (!item.isFileAttachment()) { 1066 Zotero.warn(`Item for ${uriPath} is not a file attachment`); 1067 return; 1068 } 1069 1070 var path = await item.getFilePathAsync(); 1071 if (!path) { 1072 Zotero.warn(`${path} not found`); 1073 return; 1074 } 1075 1076 if (!path.toLowerCase().endsWith('.pdf') 1077 && Zotero.MIME.sniffForMIMEType(await Zotero.File.getSample(path)) != 'application/pdf') { 1078 Zotero.warn(`${path} is not a PDF`); 1079 return; 1080 } 1081 1082 // If no page number, just open normally 1083 if (!page) { 1084 let zp = Zotero.getActiveZoteroPane(); 1085 // TODO: Open pane if closed (macOS) 1086 if (zp) { 1087 zp.viewAttachment([item.id]); 1088 } 1089 return; 1090 } 1091 1092 try { 1093 var opened = Zotero.OpenPDF.openToPage(path, page); 1094 } 1095 catch (e) { 1096 Zotero.logError(e); 1097 } 1098 // If something went wrong, just open PDF without page 1099 if (!opened) { 1100 let zp = Zotero.getActiveZoteroPane(); 1101 // TODO: Open pane if closed (macOS) 1102 if (zp) { 1103 zp.viewAttachment([item.id]); 1104 } 1105 return; 1106 } 1107 Zotero.Notifier.trigger('open', 'file', item.id); 1108 }, 1109 1110 1111 newChannel: function (uri) { 1112 this.doAction(uri); 1113 } 1114 }; 1115 1116 this._extensions[ZOTERO_SCHEME + "://data"] = DataExtension; 1117 this._extensions[ZOTERO_SCHEME + "://report"] = ReportExtension; 1118 this._extensions[ZOTERO_SCHEME + "://timeline"] = TimelineExtension; 1119 this._extensions[ZOTERO_SCHEME + "://attachment"] = AttachmentExtension; 1120 this._extensions[ZOTERO_SCHEME + "://select"] = SelectExtension; 1121 this._extensions[ZOTERO_SCHEME + "://fullscreen"] = FullscreenExtension; 1122 this._extensions[ZOTERO_SCHEME + "://debug"] = DebugExtension; 1123 this._extensions[ZOTERO_SCHEME + "://connector"] = ConnectorExtension; 1124 this._extensions[ZOTERO_SCHEME + "://open-pdf"] = OpenPDFExtension; 1125 } 1126 1127 1128 /* 1129 * Implements nsIProtocolHandler 1130 */ 1131 ZoteroProtocolHandler.prototype = { 1132 scheme: ZOTERO_SCHEME, 1133 1134 defaultPort : -1, 1135 1136 protocolFlags : 1137 Components.interfaces.nsIProtocolHandler.URI_NORELATIVE | 1138 Components.interfaces.nsIProtocolHandler.URI_NOAUTH | 1139 // DEBUG: This should be URI_IS_LOCAL_FILE, and MUST be if any 1140 // extensions that modify data are added 1141 // - https://www.zotero.org/trac/ticket/1156 1142 // 1143 Components.interfaces.nsIProtocolHandler.URI_IS_LOCAL_FILE, 1144 //Components.interfaces.nsIProtocolHandler.URI_LOADABLE_BY_ANYONE, 1145 1146 allowPort : function(port, scheme) { 1147 return false; 1148 }, 1149 1150 getExtension: function (uri) { 1151 let uriString = uri; 1152 if (uri instanceof Components.interfaces.nsIURI) { 1153 uriString = uri.spec; 1154 } 1155 uriString = uriString.toLowerCase(); 1156 1157 for (let extSpec in this._extensions) { 1158 if (uriString.startsWith(extSpec)) { 1159 return this._extensions[extSpec]; 1160 } 1161 } 1162 1163 return false; 1164 }, 1165 1166 newURI : function(spec, charset, baseURI) { 1167 var newURL = Components.classes["@mozilla.org/network/standard-url;1"] 1168 .createInstance(Components.interfaces.nsIStandardURL); 1169 newURL.init(1, -1, spec, charset, baseURI); 1170 return newURL.QueryInterface(Components.interfaces.nsIURI); 1171 }, 1172 1173 newChannel : function(uri) { 1174 var ioService = Components.classes["@mozilla.org/network/io-service;1"] 1175 .getService(Components.interfaces.nsIIOService); 1176 1177 var chromeService = Components.classes["@mozilla.org/network/protocol;1?name=chrome"] 1178 .getService(Components.interfaces.nsIProtocolHandler); 1179 1180 var newChannel = null; 1181 1182 try { 1183 let ext = this.getExtension(uri); 1184 1185 if (!ext) { 1186 // Return cancelled channel for unknown paths 1187 // 1188 // These can be in the form zotero://example.com/... -- maybe for "//example.com" URLs? 1189 var chromeURI = chromeService.newURI(DUMMY_CHROME_URL, null, null); 1190 var extChannel = chromeService.newChannel(chromeURI); 1191 var chromeRequest = extChannel.QueryInterface(Components.interfaces.nsIRequest); 1192 chromeRequest.cancel(0x804b0002); // BINDING_ABORTED 1193 return extChannel; 1194 } 1195 1196 if (!this._principal) { 1197 if (ext.loadAsChrome) { 1198 var chromeURI = chromeService.newURI(DUMMY_CHROME_URL, null, null); 1199 var chromeChannel = chromeService.newChannel(chromeURI); 1200 1201 // Cache System Principal from chrome request 1202 // so proxied pages load with chrome privileges 1203 this._principal = chromeChannel.owner; 1204 1205 var chromeRequest = chromeChannel.QueryInterface(Components.interfaces.nsIRequest); 1206 chromeRequest.cancel(0x804b0002); // BINDING_ABORTED 1207 } 1208 } 1209 1210 var extChannel = ext.newChannel(uri); 1211 // Extension returned null, so cancel request 1212 if (!extChannel) { 1213 var chromeURI = chromeService.newURI(DUMMY_CHROME_URL, null, null); 1214 var extChannel = chromeService.newChannel(chromeURI); 1215 var chromeRequest = extChannel.QueryInterface(Components.interfaces.nsIRequest); 1216 chromeRequest.cancel(0x804b0002); // BINDING_ABORTED 1217 } 1218 1219 // Apply cached principal to extension channel 1220 if (this._principal) { 1221 extChannel.owner = this._principal; 1222 } 1223 1224 if(!extChannel.originalURI) extChannel.originalURI = uri; 1225 1226 return extChannel; 1227 } 1228 catch (e) { 1229 Components.utils.reportError(e); 1230 Zotero.debug(e, 1); 1231 throw Components.results.NS_ERROR_FAILURE; 1232 } 1233 1234 return newChannel; 1235 }, 1236 1237 contractID: ZOTERO_PROTOCOL_CONTRACTID, 1238 classDescription: ZOTERO_PROTOCOL_NAME, 1239 classID: ZOTERO_PROTOCOL_CID, 1240 QueryInterface: XPCOMUtils.generateQI([Components.interfaces.nsISupports, 1241 Components.interfaces.nsIProtocolHandler]) 1242 }; 1243 1244 1245 /** 1246 * nsIChannel implementation that takes a promise-yielding generator that returns a 1247 * string, nsIAsyncInputStream, or file 1248 */ 1249 function AsyncChannel(uri, gen) { 1250 this._generator = gen; 1251 this._isPending = true; 1252 1253 // nsIRequest 1254 this.name = uri; 1255 this.loadFlags = 0; 1256 this.loadGroup = null; 1257 this.status = 0; 1258 1259 // nsIChannel 1260 this.contentLength = -1; 1261 this.contentType = "text/html"; 1262 this.contentCharset = "utf-8"; 1263 this.URI = uri; 1264 this.originalURI = uri; 1265 this.owner = null; 1266 this.notificationCallbacks = null; 1267 this.securityInfo = null; 1268 } 1269 1270 AsyncChannel.prototype = { 1271 asyncOpen: Zotero.Promise.coroutine(function* (streamListener, context) { 1272 if (this.loadGroup) this.loadGroup.addRequest(this, null); 1273 1274 var channel = this; 1275 1276 var resolve; 1277 var reject; 1278 var promise = new Zotero.Promise(function () { 1279 resolve = arguments[0]; 1280 reject = arguments[1]; 1281 }); 1282 1283 var listenerWrapper = { 1284 onStartRequest: function (request, context) { 1285 //Zotero.debug("Starting request"); 1286 streamListener.onStartRequest(channel, context); 1287 }, 1288 onDataAvailable: function (request, context, inputStream, offset, count) { 1289 //Zotero.debug("onDataAvailable"); 1290 streamListener.onDataAvailable(channel, context, inputStream, offset, count); 1291 }, 1292 onStopRequest: function (request, context, status) { 1293 //Zotero.debug("Stopping request"); 1294 streamListener.onStopRequest(channel, context, status); 1295 channel._isPending = false; 1296 if (status == 0) { 1297 resolve(); 1298 } 1299 else { 1300 reject(new Error("AsyncChannel request failed with status " + status)); 1301 } 1302 } 1303 }; 1304 1305 //Zotero.debug("AsyncChannel's asyncOpen called"); 1306 var t = new Date; 1307 1308 // Proxy requests to other zotero:// URIs 1309 let uri2 = this.URI.clone(); 1310 if (uri2.path.startsWith('/proxy/')) { 1311 let re = new RegExp(uri2.scheme + '://' + uri2.host + '/proxy/([^/]+)(.*)'); 1312 let matches = uri2.spec.match(re); 1313 uri2.spec = uri2.scheme + '://' + matches[1] + '/' + (matches[2] ? matches[2] : ''); 1314 var data = Zotero.File.getContentsFromURL(uri2.spec); 1315 } 1316 try { 1317 if (!data) { 1318 data = yield Zotero.spawn(channel._generator, channel) 1319 } 1320 if (typeof data == 'string') { 1321 //Zotero.debug("AsyncChannel: Got string from generator"); 1322 1323 listenerWrapper.onStartRequest(this, context); 1324 1325 let converter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"] 1326 .createInstance(Components.interfaces.nsIScriptableUnicodeConverter); 1327 converter.charset = "UTF-8"; 1328 let inputStream = converter.convertToInputStream(data); 1329 listenerWrapper.onDataAvailable(this, context, inputStream, 0, inputStream.available()); 1330 1331 listenerWrapper.onStopRequest(this, context, this.status); 1332 } 1333 // If an async input stream is given, pass the data asynchronously to the stream listener 1334 else if (data instanceof Ci.nsIAsyncInputStream) { 1335 //Zotero.debug("AsyncChannel: Got input stream from generator"); 1336 1337 var pump = Cc["@mozilla.org/network/input-stream-pump;1"].createInstance(Ci.nsIInputStreamPump); 1338 pump.init(data, -1, -1, 0, 0, true); 1339 pump.asyncRead(listenerWrapper, context); 1340 } 1341 else if (data instanceof Ci.nsIFile || data instanceof Ci.nsIURI) { 1342 if (data instanceof Ci.nsIFile) { 1343 //Zotero.debug("AsyncChannel: Got file from generator"); 1344 data = ioService.newFileURI(data); 1345 } 1346 else { 1347 //Zotero.debug("AsyncChannel: Got URI from generator"); 1348 } 1349 1350 let uri = data; 1351 uri.QueryInterface(Ci.nsIURL); 1352 this.contentType = Zotero.MIME.getMIMETypeFromExtension(uri.fileExtension); 1353 if (!this.contentType) { 1354 let sample = yield Zotero.File.getSample(data); 1355 this.contentType = Zotero.MIME.getMIMETypeFromData(sample); 1356 } 1357 1358 Components.utils.import("resource://gre/modules/NetUtil.jsm"); 1359 NetUtil.asyncFetch(data, function (inputStream, status) { 1360 if (!Components.isSuccessCode(status)) { 1361 reject(); 1362 return; 1363 } 1364 1365 listenerWrapper.onStartRequest(channel, context); 1366 try { 1367 listenerWrapper.onDataAvailable(channel, context, inputStream, 0, inputStream.available()); 1368 } 1369 catch (e) { 1370 reject(e); 1371 } 1372 listenerWrapper.onStopRequest(channel, context, status); 1373 }); 1374 } 1375 else if (data === undefined) { 1376 this.cancel(0x804b0002); // BINDING_ABORTED 1377 } 1378 else { 1379 throw new Error("Invalid return type (" + typeof data + ") from generator passed to AsyncChannel"); 1380 } 1381 1382 if (this._isPending) { 1383 //Zotero.debug("AsyncChannel request succeeded in " + (new Date - t) + " ms"); 1384 channel._isPending = false; 1385 } 1386 1387 return promise; 1388 } catch (e) { 1389 Zotero.debug(e, 1); 1390 if (channel._isPending) { 1391 streamListener.onStopRequest(channel, context, Components.results.NS_ERROR_FAILURE); 1392 channel._isPending = false; 1393 } 1394 throw e; 1395 } finally { 1396 if (channel.loadGroup) channel.loadGroup.removeRequest(channel, null, 0); 1397 } 1398 }), 1399 1400 // nsIRequest 1401 isPending: function () { 1402 return this._isPending; 1403 }, 1404 1405 cancel: function (status) { 1406 Zotero.debug("Cancelling"); 1407 this.status = status; 1408 this._isPending = false; 1409 }, 1410 1411 resume: function () { 1412 Zotero.debug("Resuming"); 1413 }, 1414 1415 suspend: function () { 1416 Zotero.debug("Suspending"); 1417 }, 1418 1419 // nsIWritablePropertyBag 1420 setProperty: function (prop, val) { 1421 this[prop] = val; 1422 }, 1423 1424 1425 deleteProperty: function (prop) { 1426 delete this[prop]; 1427 }, 1428 1429 1430 QueryInterface: function (iid) { 1431 if (iid.equals(Components.interfaces.nsISupports) 1432 || iid.equals(Components.interfaces.nsIRequest) 1433 || iid.equals(Components.interfaces.nsIChannel) 1434 // pdf.js wants this 1435 || iid.equals(Components.interfaces.nsIWritablePropertyBag)) { 1436 return this; 1437 } 1438 throw Components.results.NS_ERROR_NO_INTERFACE; 1439 } 1440 }; 1441 1442 1443 var NSGetFactory = XPCOMUtils.generateNSGetFactory([ZoteroProtocolHandler]);