itemTreeView.js (98982B)
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 //////////////////////////////////////////////////////////////////////////////// 27 /// 28 /// ItemTreeView 29 /// -- handles the link between an individual tree and the data layer 30 /// -- displays only items (no collections, no hierarchy) 31 /// 32 //////////////////////////////////////////////////////////////////////////////// 33 34 /* 35 * Constructor for the ItemTreeView object 36 */ 37 Zotero.ItemTreeView = function (collectionTreeRow) { 38 Zotero.LibraryTreeView.apply(this); 39 40 this.wrappedJSObject = this; 41 this.rowCount = 0; 42 this.collectionTreeRow = collectionTreeRow; 43 collectionTreeRow.view.itemTreeView = this; 44 45 this._skipKeypress = false; 46 47 this._ownerDocument = null; 48 this._needsSort = false; 49 this._introText = null; 50 51 this._cellTextCache = {}; 52 this._itemImages = {}; 53 54 this._refreshPromise = Zotero.Promise.resolve(); 55 56 this._unregisterID = Zotero.Notifier.registerObserver( 57 this, 58 ['item', 'collection-item', 'item-tag', 'share-items', 'bucket', 'feedItem', 'search'], 59 'itemTreeView', 60 50 61 ); 62 } 63 64 Zotero.ItemTreeView.prototype = Object.create(Zotero.LibraryTreeView.prototype); 65 Zotero.ItemTreeView.prototype.type = 'item'; 66 Zotero.ItemTreeView.prototype.regularOnly = false; 67 Zotero.ItemTreeView.prototype.expandAll = false; 68 Zotero.ItemTreeView.prototype.collapseAll = false; 69 70 Object.defineProperty(Zotero.ItemTreeView.prototype, 'window', { 71 get: function () { 72 return this._ownerDocument.defaultView; 73 }, 74 enumerable: true 75 }); 76 77 /** 78 * Called by the tree itself 79 */ 80 Zotero.ItemTreeView.prototype.setTree = async function (treebox) { 81 try { 82 if (this._treebox) { 83 if (this._needsSort) { 84 this.sort(); 85 } 86 return; 87 } 88 89 var start = Date.now(); 90 91 Zotero.debug("Setting tree for " + this.collectionTreeRow.id + " items view " + this.id); 92 93 if (!treebox) { 94 Zotero.debug("Treebox not passed in setTree()", 2); 95 return; 96 } 97 this._treebox = treebox; 98 99 if (!this._ownerDocument) { 100 try { 101 this._ownerDocument = treebox.treeBody.ownerDocument; 102 } 103 catch (e) {} 104 105 if (!this._ownerDocument) { 106 Zotero.debug("No owner document in setTree()", 2); 107 return; 108 } 109 } 110 111 this.setSortColumn(); 112 113 if (this.window.ZoteroPane) { 114 this.window.ZoteroPane.setItemsPaneMessage(Zotero.getString('pane.items.loading')); 115 } 116 117 if (Zotero.locked) { 118 Zotero.debug("Zotero is locked -- not loading items tree", 2); 119 120 if (this.window.ZoteroPane) { 121 this.window.ZoteroPane.clearItemsPaneMessage(); 122 } 123 return; 124 } 125 126 // Don't expand to show search matches in My Publications 127 var skipExpandMatchParents = this.collectionTreeRow.isPublications(); 128 129 await this.refresh(skipExpandMatchParents); 130 if (!this._treebox.treeBody) { 131 return; 132 } 133 134 // Expand all parent items in the view, regardless of search matches. We do this here instead 135 // of refresh so that it doesn't get reverted after item changes. 136 if (this.expandAll) { 137 var t = new Date(); 138 for (let i = 0; i < this._rows.length; i++) { 139 if (this.isContainer(i) && !this.isContainerOpen(i)) { 140 this.toggleOpenState(i, true); 141 } 142 } 143 Zotero.debug(`Opened all parent items in ${new Date() - t} ms`); 144 } 145 this._refreshItemRowMap(); 146 147 // Add a keypress listener for expand/collapse 148 var tree = this._getTreeElement(); 149 var self = this; 150 var coloredTagsRE = new RegExp("^[1-" + Zotero.Tags.MAX_COLORED_TAGS + "]{1}$"); 151 var listener = function(event) { 152 if (self._skipKeyPress) { 153 self._skipKeyPress = false; 154 return; 155 } 156 157 // Handle arrow keys specially on multiple selection, since 158 // otherwise the tree just applies it to the last-selected row 159 if (event.keyCode == event.DOM_VK_RIGHT || event.keyCode == event.DOM_VK_LEFT) { 160 if (self._treebox.view.selection.count > 1) { 161 switch (event.keyCode) { 162 case event.DOM_VK_RIGHT: 163 self.expandSelectedRows(); 164 break; 165 166 case event.DOM_VK_LEFT: 167 self.collapseSelectedRows(); 168 break; 169 } 170 171 event.preventDefault(); 172 } 173 return; 174 } 175 176 var key = String.fromCharCode(event.which); 177 if (key == '+' && !(event.ctrlKey || event.altKey || event.metaKey)) { 178 self.expandAllRows(); 179 event.preventDefault(); 180 return; 181 } 182 else if (key == '-' && !(event.shiftKey || event.ctrlKey || event.altKey || event.metaKey)) { 183 self.collapseAllRows(); 184 event.preventDefault(); 185 return; 186 } 187 188 // Ignore other non-character keypresses 189 if (!event.charCode || event.shiftKey || event.ctrlKey || 190 event.altKey || event.metaKey) { 191 return; 192 } 193 194 event.preventDefault(); 195 event.stopPropagation(); 196 197 Zotero.spawn(function* () { 198 if (coloredTagsRE.test(key)) { 199 let libraryID = self.collectionTreeRow.ref.libraryID; 200 let position = parseInt(key) - 1; 201 let colorData = Zotero.Tags.getColorByPosition(libraryID, position); 202 // If a color isn't assigned to this number or any 203 // other numbers, allow key navigation 204 if (!colorData) { 205 return !Zotero.Tags.getColors(libraryID).size; 206 } 207 208 var items = self.getSelectedItems(); 209 yield Zotero.Tags.toggleItemsListTags(libraryID, items, colorData.name); 210 return; 211 } 212 213 // We have to disable key navigation on the tree in order to 214 // keep it from acting on the 1-9 keys used for colored tags. 215 // To allow navigation with other keys, we temporarily enable 216 // key navigation and recreate the keyboard event. Since 217 // that will trigger this listener again, we set a flag to 218 // ignore the event, and then clear the flag above when the 219 // event comes in. I see no way this could go wrong... 220 tree.disableKeyNavigation = false; 221 self._skipKeyPress = true; 222 var nsIDWU = Components.interfaces.nsIDOMWindowUtils; 223 var domWindowUtils = event.originalTarget.ownerDocument.defaultView 224 .QueryInterface(Components.interfaces.nsIInterfaceRequestor) 225 .getInterface(nsIDWU); 226 var modifiers = 0; 227 if (event.altKey) { 228 modifiers |= nsIDWU.MODIFIER_ALT; 229 } 230 if (event.ctrlKey) { 231 modifiers |= nsIDWU.MODIFIER_CONTROL; 232 } 233 if (event.shiftKey) { 234 modifiers |= nsIDWU.MODIFIER_SHIFT; 235 } 236 if (event.metaKey) { 237 modifiers |= nsIDWU.MODIFIER_META; 238 } 239 domWindowUtils.sendKeyEvent( 240 'keypress', 241 event.keyCode, 242 event.charCode, 243 modifiers 244 ); 245 tree.disableKeyNavigation = true; 246 }) 247 .catch(function (e) { 248 Zotero.logError(e); 249 }) 250 }.bind(this); 251 // Store listener so we can call removeEventListener() in ItemTreeView.unregister() 252 this.listener = listener; 253 tree.addEventListener('keypress', listener); 254 255 // This seems to be the only way to prevent Enter/Return 256 // from toggle row open/close. The event is handled by 257 // handleKeyPress() in zoteroPane.js. 258 tree._handleEnter = function () {}; 259 260 this._updateIntroText(); 261 262 if (this.collectionTreeRow && this.collectionTreeRow.itemToSelect) { 263 var item = this.collectionTreeRow.itemToSelect; 264 await this.selectItem(item['id'], item['expand']); 265 this.collectionTreeRow.itemToSelect = null; 266 } 267 268 Zotero.debug("Set tree for items view " + this.id + " in " + (Date.now() - start) + " ms"); 269 270 this._initialized = true; 271 await this.runListeners('load'); 272 } 273 catch (e) { 274 Zotero.debug(e, 1); 275 Components.utils.reportError(e); 276 if (this.onError) { 277 this.onError(e); 278 } 279 throw e; 280 } 281 } 282 283 284 Zotero.ItemTreeView.prototype.setSortColumn = function() { 285 var dir, col, currentCol, currentDir; 286 287 for (let i=0, len=this._treebox.columns.count; i<len; i++) { 288 let column = this._treebox.columns.getColumnAt(i); 289 if (column.element.getAttribute('sortActive')) { 290 currentCol = column; 291 currentDir = column.element.getAttribute('sortDirection'); 292 column.element.removeAttribute('sortActive'); 293 column.element.removeAttribute('sortDirection'); 294 break; 295 } 296 } 297 298 let colID = Zotero.Prefs.get('itemTree.sortColumnID'); 299 // Restore previous sort setting (feed -> non-feed) 300 if (! this.collectionTreeRow.isFeed() && colID) { 301 col = this._treebox.columns.getNamedColumn(colID); 302 dir = Zotero.Prefs.get('itemTree.sortDirection'); 303 Zotero.Prefs.clear('itemTree.sortColumnID'); 304 Zotero.Prefs.clear('itemTree.sortDirection'); 305 // No previous sort setting stored, so store it (non-feed -> feed) 306 } else if (this.collectionTreeRow.isFeed() && !colID && currentCol) { 307 Zotero.Prefs.set('itemTree.sortColumnID', currentCol.id); 308 Zotero.Prefs.set('itemTree.sortDirection', currentDir); 309 // Retain current sort setting (non-feed -> non-feed) 310 } else { 311 col = currentCol; 312 dir = currentDir; 313 } 314 if (col) { 315 col.element.setAttribute('sortActive', true); 316 col.element.setAttribute('sortDirection', dir); 317 } 318 } 319 320 321 /** 322 * Reload the rows from the data access methods 323 * (doesn't call the tree.invalidate methods, etc.) 324 */ 325 Zotero.ItemTreeView.prototype.refresh = Zotero.serial(Zotero.Promise.coroutine(function* (skipExpandMatchParents) { 326 Zotero.debug('Refreshing items list for ' + this.id); 327 328 // DEBUG: necessary? 329 try { 330 this._treebox.columns.count 331 } 332 // If treebox isn't ready, skip refresh 333 catch (e) { 334 return false; 335 } 336 337 var resolve, reject; 338 this._refreshPromise = new Zotero.Promise(function () { 339 resolve = arguments[0]; 340 reject = arguments[1]; 341 }); 342 343 try { 344 Zotero.CollectionTreeCache.clear(); 345 // Get the full set of items we want to show 346 let newSearchItems = yield this.collectionTreeRow.getItems(); 347 // Remove notes and attachments if necessary 348 if (this.regularOnly) { 349 newSearchItems = newSearchItems.filter(item => item.isRegularItem()); 350 } 351 let newSearchItemIDs = new Set(newSearchItems.map(item => item.id)); 352 // Find the items that aren't yet in the tree 353 let itemsToAdd = newSearchItems.filter(item => this._rowMap[item.id] === undefined); 354 // Find the parents of search matches 355 let newSearchParentIDs = new Set( 356 this.regularOnly 357 ? [] 358 : newSearchItems.filter(item => !!item.parentItemID).map(item => item.parentItemID) 359 ); 360 newSearchItems = new Set(newSearchItems); 361 362 if (!this.selection.selectEventsSuppressed) { 363 var unsuppress = this.selection.selectEventsSuppressed = true; 364 this._treebox.beginUpdateBatch(); 365 } 366 var savedSelection = this.getSelectedItems(true); 367 368 var oldCount = this.rowCount; 369 var newCellTextCache = {}; 370 var newSearchMode = this.collectionTreeRow.isSearchMode(); 371 var newRows = []; 372 var allItemIDs = new Set(); 373 var addedItemIDs = new Set(); 374 375 // Copy old rows to new array, omitting top-level items not in the new set and their children 376 // 377 // This doesn't add new child items to open parents or remove child items that no longer exist, 378 // which is done by toggling all open containers below. 379 var skipChildren; 380 for (let i = 0; i < this._rows.length; i++) { 381 let row = this._rows[i]; 382 // Top-level items 383 if (row.level == 0) { 384 let isSearchParent = newSearchParentIDs.has(row.ref.id); 385 // If not showing children or no children match the search, close 386 if (this.regularOnly || !isSearchParent) { 387 row.isOpen = false; 388 skipChildren = true; 389 } 390 else { 391 skipChildren = false; 392 } 393 // Skip items that don't match the search and don't have children that do 394 if (!newSearchItems.has(row.ref) && !isSearchParent) { 395 continue; 396 } 397 } 398 // Child items 399 else if (skipChildren) { 400 continue; 401 } 402 newRows.push(row); 403 allItemIDs.add(row.ref.id); 404 } 405 406 // Add new items 407 for (let i = 0; i < itemsToAdd.length; i++) { 408 let item = itemsToAdd[i]; 409 410 // If child item matches search and parent hasn't yet been added, add parent 411 let parentItemID = item.parentItemID; 412 if (parentItemID) { 413 if (allItemIDs.has(parentItemID)) { 414 continue; 415 } 416 item = Zotero.Items.get(parentItemID); 417 } 418 // Parent item may have already been added from child 419 else if (allItemIDs.has(item.id)) { 420 continue; 421 } 422 423 // Add new top-level items 424 let row = new Zotero.ItemTreeRow(item, 0, false); 425 newRows.push(row); 426 allItemIDs.add(item.id); 427 addedItemIDs.add(item.id); 428 } 429 430 this._rows = newRows; 431 this.rowCount = this._rows.length; 432 this._refreshItemRowMap(); 433 // Sort only the new items 434 // 435 // This still results in a lot of extra work (e.g., when clearing a quick search, we have to 436 // re-sort all items that didn't match the search), so as a further optimization we could keep 437 // a sorted list of items for a given column configuration and restore items from that. 438 this.sort([...addedItemIDs]); 439 440 var diff = this.rowCount - oldCount; 441 if (diff != 0) { 442 this._treebox.rowCountChanged(0, diff); 443 } 444 445 // Toggle all open containers closed and open to refresh child items 446 // 447 // This could be avoided by making sure that items in notify() that aren't present are always 448 // added. 449 var t = new Date(); 450 for (let i = 0; i < this._rows.length; i++) { 451 if (this.isContainer(i) && this.isContainerOpen(i)) { 452 this.toggleOpenState(i, true); 453 this.toggleOpenState(i, true); 454 } 455 } 456 Zotero.debug(`Refreshed open parents in ${new Date() - t} ms`); 457 458 this._refreshItemRowMap(); 459 460 this._searchMode = newSearchMode; 461 this._searchItemIDs = newSearchItemIDs; // items matching the search 462 this._cellTextCache = {}; 463 464 this.rememberSelection(savedSelection); 465 if (!skipExpandMatchParents) { 466 this.expandMatchParents(newSearchParentIDs); 467 } 468 if (unsuppress) { 469 this._treebox.endUpdateBatch(); 470 this.selection.selectEventsSuppressed = false; 471 } 472 473 // Clear My Publications intro text on a refresh with items 474 if (this.collectionTreeRow.isPublications() && this.rowCount) { 475 this.window.ZoteroPane.clearItemsPaneMessage(); 476 } 477 478 yield this.runListeners('refresh'); 479 480 setTimeout(function () { 481 resolve(); 482 }); 483 } 484 catch (e) { 485 setTimeout(function () { 486 reject(e); 487 }); 488 throw e; 489 } 490 })); 491 492 493 /* 494 * Called by Zotero.Notifier on any changes to items in the data layer 495 */ 496 Zotero.ItemTreeView.prototype.notify = Zotero.Promise.coroutine(function* (action, type, ids, extraData) 497 { 498 Zotero.debug("Yielding for refresh promise"); // TEMP 499 yield this._refreshPromise; 500 501 if (!this._treebox || !this._treebox.treeBody) { 502 Zotero.debug("Treebox didn't exist in itemTreeView.notify()"); 503 return; 504 } 505 506 if (!this._rowMap) { 507 Zotero.debug("Item row map didn't exist in itemTreeView.notify()"); 508 return; 509 } 510 511 if (type == 'search' && action == 'modify') { 512 // TODO: Only refresh on condition change (not currently available in extraData) 513 yield this.refresh(); 514 return; 515 } 516 517 // Clear item type icon and tag colors when a tag is added to or removed from an item 518 if (type == 'item-tag') { 519 // TODO: Only update if colored tag changed? 520 ids.map(val => val.split("-")[0]).forEach(function (val) { 521 delete this._itemImages[val]; 522 }.bind(this)); 523 return; 524 } 525 526 var collectionTreeRow = this.collectionTreeRow; 527 528 if (collectionTreeRow.isFeed() && action == 'modify') { 529 for (let i=0; i<ids.length; i++) { 530 this._treebox.invalidateRow(this._rowMap[ids[i]]); 531 } 532 } 533 534 var madeChanges = false; 535 var refreshed = false; 536 var sort = false; 537 538 var savedSelection = this.getSelectedItems(true); 539 var previousFirstSelectedRow = this._rowMap[ 540 // 'collection-item' ids are in the form <collectionID>-<itemID> 541 // 'item' events are just integers 542 type == 'collection-item' ? ids[0].split('-')[1] : ids[0] 543 ]; 544 545 // If there's not at least one new item to be selected, get a scroll position to restore later 546 var scrollPosition = false; 547 if (action != 'add' || ids.every(id => extraData[id] && extraData[id].skipSelect)) { 548 scrollPosition = this._saveScrollPosition(); 549 } 550 551 // Redraw the tree (for tag color and progress changes) 552 if (action == 'redraw') { 553 // Redraw specific rows 554 if (type == 'item' && ids.length) { 555 // Redraw specific cells 556 if (extraData && extraData.column) { 557 var col = this._treebox.columns.getNamedColumn( 558 'zotero-items-column-' + extraData.column 559 ); 560 for (let id of ids) { 561 if (extraData.column == 'title') { 562 delete this._itemImages[id]; 563 } 564 this._treebox.invalidateCell(this._rowMap[id], col); 565 } 566 } 567 else { 568 for (let id of ids) { 569 delete this._itemImages[id]; 570 this._treebox.invalidateRow(this._rowMap[id]); 571 } 572 } 573 } 574 // Redraw the whole tree 575 else { 576 this._itemImages = {}; 577 this._treebox.invalidate(); 578 } 579 return; 580 } 581 582 if (action == 'refresh') { 583 if (type == 'share-items') { 584 if (collectionTreeRow.isShare()) { 585 yield this.refresh(); 586 refreshed = true; 587 } 588 } 589 else if (type == 'bucket') { 590 if (collectionTreeRow.isBucket()) { 591 yield this.refresh(); 592 refreshed = true; 593 } 594 } 595 // If refreshing a single item, clear caches and then unselect and reselect row 596 else if (savedSelection.length == 1 && savedSelection[0] == ids[0]) { 597 let row = this._rowMap[ids[0]]; 598 delete this._cellTextCache[row]; 599 600 this.selection.clearSelection(); 601 this.rememberSelection(savedSelection); 602 } 603 else { 604 this._cellTextCache = {}; 605 } 606 607 // For a refresh on an item in the trash, check if the item still belongs 608 if (type == 'item' && collectionTreeRow.isTrash()) { 609 let rows = []; 610 for (let id of ids) { 611 let row = this.getRowIndexByID(id); 612 if (row === false) continue; 613 let item = Zotero.Items.get(id); 614 if (!item.deleted && !item.numChildren()) { 615 rows.push(row); 616 } 617 } 618 this._removeRows(rows); 619 } 620 621 return; 622 } 623 624 if (collectionTreeRow.isShare()) { 625 return; 626 } 627 628 // See if we're in the active window 629 var zp = Zotero.getActiveZoteroPane(); 630 var activeWindow = zp && zp.itemsView == this; 631 632 var quickSearch = this._ownerDocument.getElementById('zotero-tb-search'); 633 var hasQuickSearch = quickSearch && quickSearch.value != ''; 634 635 // 'collection-item' ids are in the form collectionID-itemID 636 if (type == 'collection-item') { 637 if (!collectionTreeRow.isCollection()) { 638 return; 639 } 640 641 var splitIDs = []; 642 for (let id of ids) { 643 var split = id.split('-'); 644 // Skip if not an item in this collection 645 if (split[0] != collectionTreeRow.ref.id) { 646 continue; 647 } 648 splitIDs.push(split[1]); 649 } 650 ids = splitIDs; 651 652 // Select the last item even if there are no changes (e.g. if the tag 653 // selector is open and already refreshed the pane) 654 /*if (splitIDs.length > 0 && (action == 'add' || action == 'modify')) { 655 var selectItem = splitIDs[splitIDs.length - 1]; 656 }*/ 657 } 658 659 this.selection.selectEventsSuppressed = true; 660 this._treebox.beginUpdateBatch(); 661 662 if ((action == 'remove' && !collectionTreeRow.isLibrary(true)) 663 || action == 'delete' || action == 'trash' 664 || (action == 'removeDuplicatesMaster' && collectionTreeRow.isDuplicates())) { 665 // Since a remove involves shifting of rows, we have to do it in order, 666 // so sort the ids by row 667 var rows = []; 668 let push = action == 'delete' || action == 'trash' || action == 'removeDuplicatesMaster'; 669 for (var i=0, len=ids.length; i<len; i++) { 670 if (!push) { 671 push = !collectionTreeRow.ref.hasItem(ids[i]); 672 } 673 // Row might already be gone (e.g. if this is a child and 674 // 'modify' was sent to parent) 675 let row = this._rowMap[ids[i]]; 676 if (push && row !== undefined) { 677 // Don't remove child items from collections, because it's handled by 'modify' 678 if (action == 'remove' && this.getParentIndex(row) != -1) { 679 continue; 680 } 681 rows.push(row); 682 683 // Remove child items of removed parents 684 if (this.isContainer(row) && this.isContainerOpen(row)) { 685 while (++row < this.rowCount && this.getLevel(row) > 0) { 686 rows.push(row); 687 } 688 } 689 } 690 } 691 692 if (rows.length > 0) { 693 this._removeRows(rows); 694 madeChanges = true; 695 } 696 } 697 else if (type == 'item' && action == 'modify') 698 { 699 // Clear row caches 700 var items = Zotero.Items.get(ids); 701 for (let i=0; i<items.length; i++) { 702 let id = items[i].id; 703 delete this._itemImages[id]; 704 delete this._cellTextCache[id]; 705 } 706 707 // If saved search, publications, or trash, just re-run search 708 if (collectionTreeRow.isSearch() 709 || collectionTreeRow.isPublications() 710 || collectionTreeRow.isTrash() 711 || hasQuickSearch) { 712 let skipExpandMatchParents = collectionTreeRow.isPublications(); 713 yield this.refresh(skipExpandMatchParents); 714 refreshed = true; 715 madeChanges = true; 716 // Don't bother re-sorting in trash, since it's probably just a modification of a parent 717 // item that's about to be deleted 718 if (!collectionTreeRow.isTrash()) { 719 sort = true; 720 } 721 } 722 else if (collectionTreeRow.isFeed()) { 723 this.window.ZoteroPane.updateReadLabel(); 724 } 725 // If not a search, process modifications manually 726 else { 727 var items = Zotero.Items.get(ids); 728 729 for (let i = 0; i < items.length; i++) { 730 let item = items[i]; 731 let id = item.id; 732 733 let row = this._rowMap[id]; 734 735 // Deleted items get a modify that we have to ignore when 736 // not viewing the trash 737 if (item.deleted) { 738 continue; 739 } 740 741 // Item already exists in this view 742 if (row !== undefined) { 743 let parentItemID = this.getRow(row).ref.parentItemID; 744 let parentIndex = this.getParentIndex(row); 745 746 // Top-level item 747 if (this.isContainer(row)) { 748 // If Unfiled Items and itm was added to a collection, remove from view 749 if (collectionTreeRow.isUnfiled() && item.getCollections().length) { 750 this._removeRow(row); 751 } 752 // Otherwise just resort 753 else { 754 sort = id; 755 } 756 } 757 // If item moved from top-level to under another item, remove the old row. 758 else if (parentIndex == -1 && parentItemID) { 759 this._removeRow(row); 760 } 761 // If moved from under another item to top level, remove old row and add new one 762 else if (parentIndex != -1 && !parentItemID) { 763 this._removeRow(row); 764 765 let beforeRow = this.rowCount; 766 this._addRow(new Zotero.ItemTreeRow(item, 0, false), beforeRow); 767 768 sort = id; 769 } 770 // If item was moved from one parent to another, remove from old parent 771 else if (parentItemID && parentIndex != -1 && this._rowMap[parentItemID] != parentIndex) { 772 this._removeRow(row); 773 } 774 // If not moved from under one item to another, just resort the row, 775 // which also invalidates it and refreshes it 776 else { 777 sort = id; 778 } 779 780 madeChanges = true; 781 } 782 // Otherwise, for a top-level item in a library root or a collection 783 // containing the item, the item has to be added 784 else if (item.isTopLevelItem()) { 785 // Root view 786 let add = collectionTreeRow.isLibrary(true) 787 && collectionTreeRow.ref.libraryID == item.libraryID; 788 // Collection containing item 789 if (!add && collectionTreeRow.isCollection()) { 790 add = item.inCollection(collectionTreeRow.ref.id); 791 } 792 if (add) { 793 //most likely, the note or attachment's parent was removed. 794 let beforeRow = this.rowCount; 795 this._addRow(new Zotero.ItemTreeRow(item, 0, false), beforeRow); 796 madeChanges = true; 797 sort = id; 798 } 799 } 800 } 801 802 if (sort && ids.length != 1) { 803 sort = true; 804 } 805 } 806 } 807 else if(type == 'item' && action == 'add') 808 { 809 let items = Zotero.Items.get(ids); 810 811 // In some modes, just re-run search 812 if (collectionTreeRow.isSearch() 813 || collectionTreeRow.isPublications() 814 || collectionTreeRow.isTrash() 815 || collectionTreeRow.isUnfiled() 816 || hasQuickSearch) { 817 if (hasQuickSearch) { 818 // For item adds, clear the quick search, unless all the new items have 819 // skipSelect or are child items 820 if (activeWindow && type == 'item') { 821 let clear = false; 822 for (let i=0; i<items.length; i++) { 823 if (!extraData[items[i].id].skipSelect && items[i].isTopLevelItem()) { 824 clear = true; 825 break; 826 } 827 } 828 if (clear) { 829 quickSearch.value = ''; 830 collectionTreeRow.setSearch(''); 831 } 832 } 833 } 834 yield this.refresh(); 835 refreshed = true; 836 madeChanges = true; 837 sort = true; 838 } 839 // Otherwise process new items manually 840 else { 841 for (let i=0; i<items.length; i++) { 842 let item = items[i]; 843 // if the item belongs in this collection 844 if (((collectionTreeRow.isLibrary(true) 845 && collectionTreeRow.ref.libraryID == item.libraryID) 846 || (collectionTreeRow.isCollection() && item.inCollection(collectionTreeRow.ref.id))) 847 // if we haven't already added it to our hash map 848 && this._rowMap[item.id] == null 849 // Regular item or standalone note/attachment 850 && item.isTopLevelItem()) { 851 let beforeRow = this.rowCount; 852 this._addRow(new Zotero.ItemTreeRow(item, 0, false), beforeRow); 853 madeChanges = true; 854 } 855 } 856 if (madeChanges) { 857 sort = (items.length == 1) ? items[0].id : true; 858 } 859 } 860 } 861 862 var reselect = false; 863 if(madeChanges) 864 { 865 // If we made individual changes, we have to clear the cache 866 if (!refreshed) { 867 Zotero.CollectionTreeCache.clear(); 868 } 869 870 var singleSelect = false; 871 // If adding a single top-level item and this is the active window, select it 872 if (action == 'add' && activeWindow) { 873 if (ids.length == 1) { 874 singleSelect = ids[0]; 875 } 876 // If there's only one parent item in the set of added items, 877 // mark that for selection in the UI 878 // 879 // Only bother checking for single parent item if 1-5 total items, 880 // since a translator is unlikely to save more than 4 child items 881 else if (ids.length <= 5) { 882 var items = Zotero.Items.get(ids); 883 if (items) { 884 var found = false; 885 for (let item of items) { 886 // Check for note and attachment type, since it's quicker 887 // than checking for parent item 888 if (item.itemTypeID == 1 || item.itemTypeID == 14) { 889 continue; 890 } 891 892 // We already found a top-level item, so cancel the 893 // single selection 894 if (found) { 895 singleSelect = false; 896 break; 897 } 898 found = true; 899 singleSelect = item.id; 900 } 901 } 902 } 903 } 904 905 if (sort) { 906 this.sort(typeof sort == 'number' ? [sort] : false); 907 } 908 else { 909 this._refreshItemRowMap(); 910 } 911 912 if (singleSelect) { 913 if (!extraData[singleSelect] || !extraData[singleSelect].skipSelect) { 914 // Reset to Info tab 915 this._ownerDocument.getElementById('zotero-view-tabbox').selectedIndex = 0; 916 yield this.selectItem(singleSelect); 917 reselect = true; 918 } 919 } 920 // If single item is selected and was modified 921 else if (action == 'modify' && ids.length == 1 && 922 savedSelection.length == 1 && savedSelection[0] == ids[0]) { 923 if (activeWindow) { 924 yield this.selectItem(ids[0]); 925 reselect = true; 926 } 927 else { 928 this.rememberSelection(savedSelection); 929 reselect = true; 930 } 931 } 932 // On removal of a selected row, select item at previous position 933 else if (savedSelection.length) { 934 if ((action == 'remove' || action == 'trash' || action == 'delete') 935 && savedSelection.some(id => this.getRowIndexByID(id) === false)) { 936 // In duplicates view, select the next set on delete 937 if (collectionTreeRow.isDuplicates()) { 938 if (this._rows[previousFirstSelectedRow]) { 939 // Mirror ZoteroPane.onTreeMouseDown behavior 940 var itemID = this._rows[previousFirstSelectedRow].ref.id; 941 var setItemIDs = collectionTreeRow.ref.getSetItemsByItemID(itemID); 942 this.selectItems(setItemIDs); 943 reselect = true; 944 } 945 } 946 else { 947 // If this was a child item and the next item at this 948 // position is a top-level item, move selection one row 949 // up to select a sibling or parent 950 if (ids.length == 1 && previousFirstSelectedRow > 0) { 951 let previousItem = Zotero.Items.get(ids[0]); 952 if (previousItem && !previousItem.isTopLevelItem()) { 953 if (this._rows[previousFirstSelectedRow] 954 && this.getLevel(previousFirstSelectedRow) == 0) { 955 previousFirstSelectedRow--; 956 } 957 } 958 } 959 960 if (previousFirstSelectedRow !== undefined && this._rows[previousFirstSelectedRow]) { 961 this.selection.select(previousFirstSelectedRow); 962 reselect = true; 963 } 964 // If no item at previous position, select last item in list 965 else if (this._rows[this._rows.length - 1]) { 966 this.selection.select(this._rows.length - 1); 967 reselect = true; 968 } 969 } 970 } 971 else { 972 this.rememberSelection(savedSelection); 973 reselect = true; 974 } 975 } 976 977 this._rememberScrollPosition(scrollPosition); 978 } 979 // For special case in which an item needs to be selected without changes 980 // necessarily having been made 981 // ('collection-item' add with tag selector open) 982 /*else if (selectItem) { 983 yield this.selectItem(selectItem); 984 }*/ 985 986 this._updateIntroText(); 987 988 this._treebox.endUpdateBatch(); 989 990 // If we made changes to the selection (including reselecting the same item, which will register as 991 // a selection when selectEventsSuppressed is set to false), wait for a select event on the tree 992 // view (e.g., as triggered by itemsView.runListeners('select') in ZoteroPane::itemSelected()) 993 // before returning. This guarantees that changes are reflected in the middle and right-hand panes 994 // before returning from the save transaction. 995 // 996 // If no onselect handler is set on the tree element, as is the case in the Advanced Search window, 997 // the select listeners never get called, so don't wait. 998 let selectPromise; 999 var tree = this._getTreeElement(); 1000 var hasOnSelectHandler = tree.getAttribute('onselect') != ''; 1001 if (reselect && hasOnSelectHandler) { 1002 selectPromise = this.waitForSelect(); 1003 this.selection.selectEventsSuppressed = false; 1004 Zotero.debug("Yielding for select promise"); // TEMP 1005 return selectPromise; 1006 } 1007 else { 1008 this.selection.selectEventsSuppressed = false; 1009 } 1010 }); 1011 1012 1013 Zotero.ItemTreeView.prototype.unregister = async function() { 1014 Zotero.Notifier.unregisterObserver(this._unregisterID); 1015 1016 if (this.collectionTreeRow.onUnload) { 1017 await this.collectionTreeRow.onUnload(); 1018 } 1019 1020 if (this.listener) { 1021 if (!this._treebox.treeBody) { 1022 Zotero.debug("No more tree body in Zotero.ItemTreeView::unregister()"); 1023 this.listener = null; 1024 return; 1025 } 1026 let tree = this._getTreeElement(); 1027 tree.removeEventListener('keypress', this.listener, false); 1028 this.listener = null; 1029 } 1030 }; 1031 1032 //////////////////////////////////////////////////////////////////////////////// 1033 /// 1034 /// nsITreeView functions 1035 /// 1036 //////////////////////////////////////////////////////////////////////////////// 1037 1038 Zotero.ItemTreeView.prototype.getCellText = function (row, column) 1039 { 1040 var obj = this.getRow(row); 1041 var itemID = obj.id; 1042 1043 // If value is available, retrieve synchronously 1044 if (this._cellTextCache[itemID] && this._cellTextCache[itemID][column.id] !== undefined) { 1045 return this._cellTextCache[itemID][column.id]; 1046 } 1047 1048 if (!this._cellTextCache[itemID]) { 1049 this._cellTextCache[itemID] = {} 1050 } 1051 1052 var val; 1053 1054 // Image only 1055 if (column.id === "zotero-items-column-hasAttachment") { 1056 return; 1057 } 1058 else if(column.id == "zotero-items-column-itemType") 1059 { 1060 val = Zotero.ItemTypes.getLocalizedString(obj.ref.itemTypeID); 1061 } 1062 // Year column is just date field truncated 1063 else if (column.id == "zotero-items-column-year") { 1064 val = obj.getField('date', true).substr(0, 4) 1065 } 1066 else if (column.id === "zotero-items-column-numNotes") { 1067 val = obj.numNotes(); 1068 if (!val) { 1069 val = ''; 1070 } 1071 } 1072 else { 1073 var col = column.id.substring(20); 1074 1075 if (col == 'title') { 1076 val = obj.ref.getDisplayTitle(); 1077 } 1078 else { 1079 val = obj.getField(col); 1080 } 1081 } 1082 1083 switch (column.id) { 1084 // Format dates as short dates in proper locale order and locale time 1085 // (e.g. "4/4/07 14:27:23") 1086 case 'zotero-items-column-dateAdded': 1087 case 'zotero-items-column-dateModified': 1088 case 'zotero-items-column-accessDate': 1089 case 'zotero-items-column-date': 1090 if (column.id == 'zotero-items-column-date' && !this.collectionTreeRow.isFeed()) { 1091 break; 1092 } 1093 if (val) { 1094 let date = Zotero.Date.sqlToDate(val, true); 1095 if (date) { 1096 // If no time, interpret as local, not UTC 1097 if (Zotero.Date.isSQLDate(val)) { 1098 date = Zotero.Date.sqlToDate(val); 1099 val = date.toLocaleDateString(); 1100 } 1101 else { 1102 val = date.toLocaleString(); 1103 } 1104 } 1105 else { 1106 val = ''; 1107 } 1108 } 1109 } 1110 1111 return this._cellTextCache[itemID][column.id] = val; 1112 } 1113 1114 Zotero.ItemTreeView.prototype.getImageSrc = function(row, col) 1115 { 1116 if(col.id == 'zotero-items-column-title') 1117 { 1118 // Get item type icon and tag swatches 1119 var item = this.getRow(row).ref; 1120 var itemID = item.id; 1121 if (this._itemImages[itemID]) { 1122 return this._itemImages[itemID]; 1123 } 1124 item.getImageSrcWithTags() 1125 .then(function (uriWithTags) { 1126 this._itemImages[itemID] = uriWithTags; 1127 this._treebox.invalidateCell(row, col); 1128 }.bind(this)); 1129 return item.getImageSrc(); 1130 } 1131 else if (col.id == 'zotero-items-column-hasAttachment') { 1132 if (this.collectionTreeRow.isTrash()) return false; 1133 1134 var treerow = this.getRow(row); 1135 var item = treerow.ref; 1136 1137 if ((!this.isContainer(row) || !this.isContainerOpen(row)) 1138 && Zotero.Sync.Storage.getItemDownloadImageNumber(item)) { 1139 return ''; 1140 } 1141 1142 var itemID = item.id; 1143 let suffix = Zotero.hiDPISuffix; 1144 1145 if (treerow.level === 0) { 1146 if (item.isRegularItem()) { 1147 let state = item.getBestAttachmentStateCached(); 1148 if (state !== null) { 1149 switch (state) { 1150 case 1: 1151 return `chrome://zotero/skin/bullet_blue${suffix}.png`; 1152 1153 case -1: 1154 return `chrome://zotero/skin/bullet_blue_empty${suffix}.png`; 1155 1156 default: 1157 return ""; 1158 } 1159 } 1160 1161 item.getBestAttachmentState() 1162 // Refresh cell when promise is fulfilled 1163 .then(function (state) { 1164 this._treebox.invalidateCell(row, col); 1165 }.bind(this)) 1166 .done(); 1167 } 1168 } 1169 1170 if (item.isFileAttachment()) { 1171 let exists = item.fileExistsCached(); 1172 if (exists !== null) { 1173 return exists 1174 ? `chrome://zotero/skin/bullet_blue${suffix}.png` 1175 : `chrome://zotero/skin/bullet_blue_empty${suffix}.png`; 1176 } 1177 1178 item.fileExists() 1179 // Refresh cell when promise is fulfilled 1180 .then(function (exists) { 1181 this._treebox.invalidateCell(row, col); 1182 }.bind(this)); 1183 } 1184 } 1185 1186 return ""; 1187 } 1188 1189 Zotero.ItemTreeView.prototype.isContainer = function(row) 1190 { 1191 return this.getRow(row).ref.isRegularItem(); 1192 } 1193 1194 Zotero.ItemTreeView.prototype.isContainerEmpty = function(row) 1195 { 1196 if (this.regularOnly) { 1197 return true; 1198 } 1199 1200 var item = this.getRow(row).ref; 1201 if (!item.isRegularItem()) { 1202 return false; 1203 } 1204 var includeTrashed = this.collectionTreeRow.isTrash(); 1205 return item.numNotes(includeTrashed) === 0 && item.numAttachments(includeTrashed) == 0; 1206 } 1207 1208 // Gets the index of the row's container, or -1 if none (top-level) 1209 Zotero.ItemTreeView.prototype.getParentIndex = function(row) 1210 { 1211 if (row==-1) 1212 { 1213 return -1; 1214 } 1215 var thisLevel = this.getLevel(row); 1216 if(thisLevel == 0) return -1; 1217 for(var i = row - 1; i >= 0; i--) 1218 if(this.getLevel(i) < thisLevel) 1219 return i; 1220 return -1; 1221 } 1222 1223 Zotero.ItemTreeView.prototype.hasNextSibling = function(row,afterIndex) 1224 { 1225 var thisLevel = this.getLevel(row); 1226 for(var i = afterIndex + 1; i < this.rowCount; i++) 1227 { 1228 var nextLevel = this.getLevel(i); 1229 if(nextLevel == thisLevel) return true; 1230 else if(nextLevel < thisLevel) return false; 1231 } 1232 } 1233 1234 Zotero.ItemTreeView.prototype.toggleOpenState = function (row, skipRowMapRefresh) { 1235 // Shouldn't happen but does if an item is dragged over a closed 1236 // container until it opens and then released, since the container 1237 // is no longer in the same place when the spring-load closes 1238 if (!this.isContainer(row)) { 1239 return; 1240 } 1241 1242 if (this.isContainerOpen(row)) { 1243 return this._closeContainer(row, skipRowMapRefresh); 1244 } 1245 1246 var count = 0; 1247 var level = this.getLevel(row); 1248 1249 // 1250 // Open 1251 // 1252 var item = this.getRow(row).ref; 1253 1254 //Get children 1255 var includeTrashed = this.collectionTreeRow.isTrash(); 1256 var attachments = item.getAttachments(includeTrashed); 1257 var notes = item.getNotes(includeTrashed); 1258 1259 var newRows; 1260 if (attachments.length && notes.length) { 1261 newRows = notes.concat(attachments); 1262 } 1263 else if (attachments.length) { 1264 newRows = attachments; 1265 } 1266 else if (notes.length) { 1267 newRows = notes; 1268 } 1269 1270 if (newRows) { 1271 newRows = Zotero.Items.get(newRows); 1272 1273 for (let i = 0; i < newRows.length; i++) { 1274 count++; 1275 this._addRow( 1276 new Zotero.ItemTreeRow(newRows[i], level + 1, false), 1277 row + i + 1, 1278 true 1279 ); 1280 } 1281 } 1282 1283 this._rows[row].isOpen = true; 1284 1285 if (count == 0) { 1286 return; 1287 } 1288 1289 this._treebox.invalidateRow(row); 1290 1291 if (!skipRowMapRefresh) { 1292 Zotero.debug('Refreshing item row map'); 1293 this._refreshItemRowMap(); 1294 } 1295 } 1296 1297 1298 Zotero.ItemTreeView.prototype._closeContainer = function (row, skipRowMapRefresh) { 1299 // isContainer == false shouldn't happen but does if an item is dragged over a closed 1300 // container until it opens and then released, since the container is no longer in the same 1301 // place when the spring-load closes 1302 if (!this.isContainer(row)) return; 1303 if (!this.isContainerOpen(row)) return; 1304 1305 var count = 0; 1306 var level = this.getLevel(row); 1307 1308 // Remove child rows 1309 while ((row + 1 < this._rows.length) && (this.getLevel(row + 1) > level)) { 1310 // Skip the map update here and just refresh the whole map below, 1311 // since we might be removing multiple rows 1312 this._removeRow(row + 1, true); 1313 count--; 1314 } 1315 1316 this._rows[row].isOpen = false; 1317 1318 if (count == 0) { 1319 return; 1320 } 1321 1322 this._treebox.invalidateRow(row); 1323 1324 if (!skipRowMapRefresh) { 1325 Zotero.debug('Refreshing item row map'); 1326 this._refreshItemRowMap(); 1327 } 1328 } 1329 1330 1331 Zotero.ItemTreeView.prototype.isSorted = function() 1332 { 1333 // We sort by the first column if none selected, so return true 1334 return true; 1335 } 1336 1337 Zotero.ItemTreeView.prototype.cycleHeader = Zotero.Promise.coroutine(function* (column) { 1338 if (this.collectionTreeRow.isFeed()) { 1339 return; 1340 } 1341 if (column.id == 'zotero-items-column-hasAttachment') { 1342 Zotero.debug("Caching best attachment states"); 1343 if (!this._cachedBestAttachmentStates) { 1344 let t = new Date(); 1345 for (let i = 0; i < this._rows.length; i++) { 1346 let item = this.getRow(i).ref; 1347 if (item.isRegularItem()) { 1348 yield item.getBestAttachmentState(); 1349 } 1350 } 1351 Zotero.debug("Cached best attachment states in " + (new Date - t) + " ms"); 1352 this._cachedBestAttachmentStates = true; 1353 } 1354 } 1355 for(var i=0, len=this._treebox.columns.count; i<len; i++) 1356 { 1357 col = this._treebox.columns.getColumnAt(i); 1358 if(column != col) 1359 { 1360 col.element.removeAttribute('sortActive'); 1361 col.element.removeAttribute('sortDirection'); 1362 } 1363 else 1364 { 1365 // If not yet selected, start with ascending 1366 if (!col.element.getAttribute('sortActive')) { 1367 col.element.setAttribute('sortDirection', 'ascending'); 1368 } 1369 else { 1370 col.element.setAttribute('sortDirection', col.element.getAttribute('sortDirection') == 'descending' ? 'ascending' : 'descending'); 1371 } 1372 col.element.setAttribute('sortActive', true); 1373 } 1374 } 1375 1376 this.selection.selectEventsSuppressed = true; 1377 var savedSelection = this.getSelectedItems(true); 1378 if (savedSelection.length == 1) { 1379 var pos = this._rowMap[savedSelection[0]] - this._treebox.getFirstVisibleRow(); 1380 } 1381 this.sort(); 1382 this.rememberSelection(savedSelection); 1383 // If single row was selected, try to keep it in the same place 1384 if (savedSelection.length == 1) { 1385 var newRow = this._rowMap[savedSelection[0]]; 1386 // Calculate the last row that would give us a full view 1387 var fullTop = Math.max(0, this._rows.length - this._treebox.getPageLength()); 1388 // Calculate the row that would give us the same position 1389 var consistentTop = Math.max(0, newRow - pos); 1390 this._treebox.scrollToRow(Math.min(fullTop, consistentTop)); 1391 } 1392 this._treebox.invalidate(); 1393 this.selection.selectEventsSuppressed = false; 1394 }); 1395 1396 /* 1397 * Sort the items by the currently sorted column. 1398 */ 1399 Zotero.ItemTreeView.prototype.sort = function (itemIDs) { 1400 var t = new Date; 1401 1402 // If Zotero pane is hidden, mark tree for sorting later in setTree() 1403 if (!this._treebox.columns) { 1404 this._needsSort = true; 1405 return; 1406 } 1407 this._needsSort = false; 1408 1409 // For child items, just close and reopen parents 1410 if (itemIDs) { 1411 let parentItemIDs = new Set(); 1412 let skipped = []; 1413 for (let itemID of itemIDs) { 1414 let row = this._rowMap[itemID]; 1415 let item = this.getRow(row).ref; 1416 let parentItemID = item.parentItemID; 1417 if (!parentItemID) { 1418 skipped.push(itemID); 1419 continue; 1420 } 1421 parentItemIDs.add(parentItemID); 1422 } 1423 1424 let parentRows = [...parentItemIDs].map(itemID => this._rowMap[itemID]); 1425 parentRows.sort(); 1426 1427 for (let i = parentRows.length - 1; i >= 0; i--) { 1428 let row = parentRows[i]; 1429 this._closeContainer(row, true); 1430 this.toggleOpenState(row, true); 1431 } 1432 this._refreshItemRowMap(); 1433 1434 let numSorted = itemIDs.length - skipped.length; 1435 if (numSorted) { 1436 Zotero.debug(`Sorted ${numSorted} child items by parent toggle`); 1437 } 1438 if (!skipped.length) { 1439 return; 1440 } 1441 itemIDs = skipped; 1442 if (numSorted) { 1443 Zotero.debug(`${itemIDs.length} items left to sort`); 1444 } 1445 } 1446 1447 var primaryField = this.getSortField(); 1448 var sortFields = this.getSortFields(); 1449 var dir = this.getSortDirection(); 1450 var order = dir == 'descending' ? -1 : 1; 1451 var collation = Zotero.getLocaleCollation(); 1452 var sortCreatorAsString = Zotero.Prefs.get('sortCreatorAsString'); 1453 1454 Zotero.debug(`Sorting items list by ${sortFields.join(", ")} ${dir} ` 1455 + (itemIDs && itemIDs.length 1456 ? `for ${itemIDs.length} ` + Zotero.Utilities.pluralize(itemIDs.length, ['item', 'items']) 1457 : "")); 1458 1459 // Set whether rows with empty values should be displayed last, 1460 // which may be different for primary and secondary sorting. 1461 var emptyFirst = {}; 1462 switch (primaryField) { 1463 case 'title': 1464 emptyFirst.title = true; 1465 break; 1466 1467 // When sorting by title we want empty titles at the top, but if not 1468 // sorting by title, empty titles should sort to the bottom so that new 1469 // empty items don't get sorted to the middle of the items list. 1470 default: 1471 emptyFirst.title = false; 1472 } 1473 1474 // Cache primary values while sorting, since base-field-mapped getField() 1475 // calls are relatively expensive 1476 var cache = {}; 1477 sortFields.forEach(x => cache[x] = {}) 1478 1479 // Get the display field for a row (which might be a placeholder title) 1480 function getField(field, row) { 1481 var item = row.ref; 1482 1483 switch (field) { 1484 case 'title': 1485 return Zotero.Items.getSortTitle(item.getDisplayTitle()); 1486 1487 case 'hasAttachment': 1488 if (item.isFileAttachment()) { 1489 var state = item.fileExistsCached() ? 1 : -1; 1490 } 1491 else if (item.isRegularItem()) { 1492 var state = item.getBestAttachmentStateCached(); 1493 } 1494 else { 1495 return 0; 1496 } 1497 // Make sort order present, missing, empty when ascending 1498 if (state === 1) { 1499 state = 2; 1500 } 1501 else if (state === -1) { 1502 state = 1; 1503 } 1504 return state; 1505 1506 case 'numNotes': 1507 return row.numNotes(false, true) || 0; 1508 1509 // Use unformatted part of date strings (YYYY-MM-DD) for sorting 1510 case 'date': 1511 var val = row.ref.getField('date', true, true); 1512 if (val) { 1513 val = val.substr(0, 10); 1514 if (val.indexOf('0000') == 0) { 1515 val = ""; 1516 } 1517 } 1518 return val; 1519 1520 case 'year': 1521 var val = row.ref.getField('date', true, true); 1522 if (val) { 1523 val = val.substr(0, 4); 1524 if (val == '0000') { 1525 val = ""; 1526 } 1527 } 1528 return val; 1529 1530 default: 1531 return row.ref.getField(field, false, true); 1532 } 1533 } 1534 1535 var includeTrashed = this.collectionTreeRow.isTrash(); 1536 1537 function fieldCompare(a, b, sortField) { 1538 var aItemID = a.id; 1539 var bItemID = b.id; 1540 var fieldA = cache[sortField][aItemID]; 1541 var fieldB = cache[sortField][bItemID]; 1542 1543 switch (sortField) { 1544 case 'firstCreator': 1545 return creatorSort(a, b); 1546 1547 case 'itemType': 1548 var typeA = Zotero.ItemTypes.getLocalizedString(a.ref.itemTypeID); 1549 var typeB = Zotero.ItemTypes.getLocalizedString(b.ref.itemTypeID); 1550 return (typeA > typeB) ? 1 : (typeA < typeB) ? -1 : 0; 1551 1552 default: 1553 if (fieldA === undefined) { 1554 cache[sortField][aItemID] = fieldA = getField(sortField, a); 1555 } 1556 1557 if (fieldB === undefined) { 1558 cache[sortField][bItemID] = fieldB = getField(sortField, b); 1559 } 1560 1561 // Display rows with empty values last 1562 if (!emptyFirst[sortField]) { 1563 if(fieldA === '' && fieldB !== '') return 1; 1564 if(fieldA !== '' && fieldB === '') return -1; 1565 } 1566 1567 if (sortField == 'hasAttachment') { 1568 return fieldB - fieldA; 1569 } 1570 1571 return collation.compareString(1, fieldA, fieldB); 1572 } 1573 } 1574 1575 var rowSort = function (a, b) { 1576 for (let i = 0; i < sortFields.length; i++) { 1577 let cmp = fieldCompare(a, b, sortFields[i]); 1578 if (cmp !== 0) { 1579 return cmp; 1580 } 1581 } 1582 return 0; 1583 }; 1584 1585 var creatorSortCache = {}; 1586 1587 // Regexp to extract the whole string up to an optional "and" or "et al." 1588 var andEtAlRegExp = new RegExp( 1589 // Extract the beginning of the string in non-greedy mode 1590 "^.+?" 1591 // up to either the end of the string, "et al." at the end of string 1592 + "(?=(?: " + Zotero.getString('general.etAl').replace('.', '\.') + ")?$" 1593 // or ' and ' 1594 + "| " + Zotero.getString('general.and') + " " 1595 + ")" 1596 ); 1597 1598 function creatorSort(a, b) { 1599 var itemA = a.ref; 1600 var itemB = b.ref; 1601 // 1602 // Try sorting by the first name in the firstCreator field, since we already have it 1603 // 1604 // For sortCreatorAsString mode, just use the whole string 1605 // 1606 var aItemID = a.id, 1607 bItemID = b.id, 1608 fieldA = creatorSortCache[aItemID], 1609 fieldB = creatorSortCache[bItemID]; 1610 var prop = sortCreatorAsString ? 'firstCreator' : 'sortCreator'; 1611 var sortStringA = itemA[prop]; 1612 var sortStringB = itemB[prop]; 1613 if (fieldA === undefined) { 1614 let firstCreator = Zotero.Items.getSortTitle(sortStringA); 1615 if (sortCreatorAsString) { 1616 var fieldA = firstCreator; 1617 } 1618 else { 1619 var matches = andEtAlRegExp.exec(firstCreator); 1620 var fieldA = matches ? matches[0] : ''; 1621 } 1622 creatorSortCache[aItemID] = fieldA; 1623 } 1624 if (fieldB === undefined) { 1625 let firstCreator = Zotero.Items.getSortTitle(sortStringB); 1626 if (sortCreatorAsString) { 1627 var fieldB = firstCreator; 1628 } 1629 else { 1630 var matches = andEtAlRegExp.exec(firstCreator); 1631 var fieldB = matches ? matches[0] : ''; 1632 } 1633 creatorSortCache[bItemID] = fieldB; 1634 } 1635 1636 if (fieldA === "" && fieldB === "") { 1637 return 0; 1638 } 1639 1640 // Display rows with empty values last 1641 if (fieldA === '' && fieldB !== '') return 1; 1642 if (fieldA !== '' && fieldB === '') return -1; 1643 1644 return collation.compareString(1, fieldA, fieldB); 1645 } 1646 1647 // Need to close all containers before sorting 1648 if (!this.selection.selectEventsSuppressed) { 1649 var unsuppress = this.selection.selectEventsSuppressed = true; 1650 this._treebox.beginUpdateBatch(); 1651 } 1652 var savedSelection = this.getSelectedItems(true); 1653 var openItemIDs = this._saveOpenState(true); 1654 1655 // Sort specific items 1656 if (itemIDs) { 1657 let idsToSort = new Set(itemIDs); 1658 this._rows.sort((a, b) => { 1659 // Don't re-sort existing items. This assumes a stable sort(), which is the case in Firefox 1660 // but not Chrome/v8. 1661 if (!idsToSort.has(a.ref.id) && !idsToSort.has(b.ref.id)) return 0; 1662 return rowSort(a, b) * order; 1663 }); 1664 } 1665 // Full sort 1666 else { 1667 this._rows.sort((a, b) => rowSort(a, b) * order); 1668 } 1669 1670 this._refreshItemRowMap(); 1671 1672 this.rememberOpenState(openItemIDs); 1673 this.rememberSelection(savedSelection); 1674 1675 if (unsuppress) { 1676 this._treebox.endUpdateBatch(); 1677 this.selection.selectEventsSuppressed = false; 1678 } 1679 1680 this._treebox.invalidate(); 1681 1682 var numSorted = itemIDs ? itemIDs.length : this._rows.length; 1683 Zotero.debug(`Sorted ${numSorted} ${Zotero.Utilities.pluralize(numSorted, ['item', 'items'])} ` 1684 + `in ${new Date - t} ms`); 1685 }; 1686 1687 1688 /** 1689 * Show intro text in middle pane for some views when no items 1690 */ 1691 Zotero.ItemTreeView.prototype._updateIntroText = function() { 1692 if (!this.window.ZoteroPane) { 1693 return; 1694 } 1695 1696 if (this.collectionTreeRow && !this.rowCount) { 1697 let doc = this._ownerDocument; 1698 let ns = 'http://www.w3.org/1999/xhtml' 1699 let div; 1700 1701 // My Library and no groups 1702 if (this.collectionTreeRow.isLibrary() && !Zotero.Groups.getAll().length) { 1703 div = doc.createElementNS(ns, 'div'); 1704 let p = doc.createElementNS(ns, 'p'); 1705 let html = Zotero.getString( 1706 'pane.items.intro.text1', 1707 [ 1708 Zotero.clientName 1709 ] 1710 ); 1711 // Encode special chars, which shouldn't exist 1712 html = Zotero.Utilities.htmlSpecialChars(html); 1713 html = `<b>${html}</b>`; 1714 p.innerHTML = html; 1715 div.appendChild(p); 1716 1717 p = doc.createElementNS(ns, 'p'); 1718 html = Zotero.getString( 1719 'pane.items.intro.text2', 1720 [ 1721 Zotero.getString('connector.name', Zotero.clientName), 1722 Zotero.clientName 1723 ] 1724 ); 1725 // Encode special chars, which shouldn't exist 1726 html = Zotero.Utilities.htmlSpecialChars(html); 1727 html = html.replace( 1728 /\[([^\]]+)](.+)\[([^\]]+)]/, 1729 `<span class="text-link" data-href="${ZOTERO_CONFIG.QUICK_START_URL}">$1</span>` 1730 + '$2' 1731 + `<span class="text-link" data-href="${ZOTERO_CONFIG.CONNECTORS_URL}">$3</span>` 1732 ); 1733 p.innerHTML = html; 1734 div.appendChild(p); 1735 1736 p = doc.createElementNS(ns, 'p'); 1737 html = Zotero.getString('pane.items.intro.text3', [Zotero.clientName]); 1738 // Encode special chars, which shouldn't exist 1739 html = Zotero.Utilities.htmlSpecialChars(html); 1740 html = html.replace( 1741 /\[([^\]]+)]/, 1742 '<span class="text-link" ' 1743 + `onclick="Zotero.Utilities.Internal.openPreferences('zotero-prefpane-sync')">$1</span>` 1744 ); 1745 p.innerHTML = html; 1746 div.appendChild(p); 1747 1748 // Activate text links 1749 for (let span of div.getElementsByTagName('span')) { 1750 if (span.classList.contains('text-link') && !span.hasAttribute('onclick')) { 1751 span.onclick = function () { 1752 doc.defaultView.ZoteroPane.loadURI(this.getAttribute('data-href')); 1753 }; 1754 } 1755 } 1756 1757 div.setAttribute('allowdrop', true); 1758 } 1759 // My Publications 1760 else if (this.collectionTreeRow.isPublications()) { 1761 div = doc.createElementNS(ns, 'div'); 1762 div.className = 'publications'; 1763 let p = doc.createElementNS(ns, 'p'); 1764 p.textContent = Zotero.getString('publications.intro.text1', ZOTERO_CONFIG.DOMAIN_NAME); 1765 div.appendChild(p); 1766 1767 p = doc.createElementNS(ns, 'p'); 1768 p.textContent = Zotero.getString('publications.intro.text2'); 1769 div.appendChild(p); 1770 1771 p = doc.createElementNS(ns, 'p'); 1772 let html = Zotero.getString('publications.intro.text3'); 1773 // Convert <b> tags to placeholders 1774 html = html.replace('<b>', ':b:').replace('</b>', ':/b:'); 1775 // Encode any other special chars, which shouldn't exist 1776 html = Zotero.Utilities.htmlSpecialChars(html); 1777 // Restore bold text 1778 html = html.replace(':b:', '<strong>').replace(':/b:', '</strong>'); 1779 p.innerHTML = html; // AMO note: markup from hard-coded strings and filtered above 1780 div.appendChild(p); 1781 } 1782 if (div) { 1783 this._introText = true; 1784 doc.defaultView.ZoteroPane_Local.setItemsPaneMessage(div); 1785 return; 1786 } 1787 this._introText = null; 1788 } 1789 1790 if (this._introText || this._introText === null) { 1791 this.window.ZoteroPane.clearItemsPaneMessage(); 1792 this._introText = false; 1793 } 1794 }; 1795 1796 1797 //////////////////////////////////////////////////////////////////////////////// 1798 /// 1799 /// Additional functions for managing data in the tree 1800 /// 1801 //////////////////////////////////////////////////////////////////////////////// 1802 1803 1804 /* 1805 * Select an item 1806 */ 1807 Zotero.ItemTreeView.prototype.selectItem = Zotero.Promise.coroutine(function* (id, expand, noRecurse) { 1808 // If no row map, we're probably in the process of switching collections, 1809 // so store the item to select on the item group for later 1810 if (!this._rowMap) { 1811 if (this.collectionTreeRow) { 1812 this.collectionTreeRow.itemToSelect = { id: id, expand: expand }; 1813 Zotero.debug("_rowMap not yet set; not selecting item"); 1814 return false; 1815 } 1816 1817 Zotero.debug('Item group not found and no row map in ItemTreeView.selectItem() -- discarding select', 2); 1818 return false; 1819 } 1820 1821 var row = this._rowMap[id]; 1822 1823 // Get the row of the parent, if there is one 1824 var parentRow = null; 1825 var item = yield Zotero.Items.getAsync(id); 1826 1827 // Can't select a deleted item if we're not in the trash 1828 if (item.deleted && !this.collectionTreeRow.isTrash()) { 1829 return false; 1830 } 1831 1832 var parent = item.parentItemID; 1833 if (parent && this._rowMap[parent] != undefined) { 1834 parentRow = this._rowMap[parent]; 1835 } 1836 1837 var selected = this.getSelectedItems(true); 1838 if (selected.length == 1 && selected[0] == id) { 1839 Zotero.debug("Item " + id + " is already selected"); 1840 this.betterEnsureRowIsVisible(row, parentRow); 1841 return true; 1842 } 1843 1844 // If row with id not visible, check to see if it's hidden under a parent 1845 if(row == undefined) 1846 { 1847 if (!parent || parentRow === null) { 1848 // No parent -- it's not here 1849 1850 // Clear the quick search and tag selection and try again (once) 1851 if (!noRecurse && this.window.ZoteroPane) { 1852 let cleared1 = yield this.window.ZoteroPane.clearQuicksearch(); 1853 let cleared2 = this.window.ZoteroPane.clearTagSelection(); 1854 if (cleared1 || cleared2) { 1855 return this.selectItem(id, expand, true); 1856 } 1857 } 1858 1859 Zotero.debug("Could not find row for item; not selecting item"); 1860 return false; 1861 } 1862 1863 // If parent is already open and we haven't found the item, the child 1864 // hasn't yet been added to the view, so close parent to allow refresh 1865 this._closeContainer(parentRow); 1866 1867 // Open the parent 1868 this.toggleOpenState(parentRow); 1869 row = this._rowMap[id]; 1870 } 1871 1872 // this.selection.select() triggers the <tree>'s 'onselect' attribute, which calls 1873 // ZoteroPane.itemSelected(), which calls ZoteroItemPane.viewItem(), which refreshes the 1874 // itembox. But since the 'onselect' doesn't handle promises, itemSelected() isn't waited for 1875 // here, which means that 'yield selectItem(itemID)' continues before the itembox has been 1876 // refreshed. To get around this, we wait for a select event that's triggered by 1877 // itemSelected() when it's done. 1878 let promise; 1879 try { 1880 if (this.selection.selectEventsSuppressed) { 1881 this.selection.select(row); 1882 } 1883 else { 1884 promise = this.waitForSelect(); 1885 this.selection.select(row); 1886 } 1887 1888 // If |expand|, open row if container 1889 if (expand && this.isContainer(row) && !this.isContainerOpen(row)) { 1890 this.toggleOpenState(row); 1891 } 1892 this.selection.select(row); 1893 } 1894 // Ignore NS_ERROR_UNEXPECTED from nsITreeSelection::select(), apparently when the tree 1895 // disappears before it's called (though I can't reproduce it): 1896 // 1897 // https://forums.zotero.org/discussion/comment/297039/#Comment_297039 1898 catch (e) { 1899 Zotero.logError(e); 1900 } 1901 1902 if (promise) { 1903 yield promise; 1904 } 1905 1906 this.betterEnsureRowIsVisible(row, parentRow); 1907 1908 return true; 1909 }); 1910 1911 1912 /** 1913 * Select multiple top-level items 1914 * 1915 * @param {Integer[]} ids An array of itemIDs 1916 */ 1917 Zotero.ItemTreeView.prototype.selectItems = function(ids) { 1918 if (ids.length == 0) { 1919 return; 1920 } 1921 1922 var rows = []; 1923 for (let id of ids) { 1924 if(this._rowMap[id] !== undefined) rows.push(this._rowMap[id]); 1925 } 1926 rows.sort(function (a, b) { 1927 return a - b; 1928 }); 1929 1930 this.selection.clearSelection(); 1931 1932 this.selection.selectEventsSuppressed = true; 1933 1934 var lastStart = 0; 1935 for (var i = 0, len = rows.length; i < len; i++) { 1936 if (i == len - 1 || rows[i + 1] != rows[i] + 1) { 1937 this.selection.rangedSelect(rows[lastStart], rows[i], true); 1938 lastStart = i + 1; 1939 } 1940 } 1941 1942 this.selection.selectEventsSuppressed = false; 1943 // TODO: This could probably be improved to try to focus more of the selected rows 1944 this.betterEnsureRowIsVisible(rows[0]); 1945 } 1946 1947 1948 Zotero.ItemTreeView.prototype.betterEnsureRowIsVisible = function (row, parentRow = null) { 1949 // We aim for a row 5 below the target row, since ensureRowIsVisible() does 1950 // the bare minimum to get the row in view 1951 for (let v = row + 5; v >= row; v--) { 1952 if (this._rows[v]) { 1953 this._treebox.ensureRowIsVisible(v); 1954 if (this._treebox.getFirstVisibleRow() <= row) { 1955 break; 1956 } 1957 } 1958 } 1959 1960 // If the parent row isn't in view and we have enough room, make parent visible 1961 if (parentRow !== null && this._treebox.getFirstVisibleRow() > parentRow) { 1962 if ((row - parentRow) < this._treebox.getPageLength()) { 1963 this._treebox.ensureRowIsVisible(parentRow); 1964 } 1965 } 1966 }; 1967 1968 1969 /* 1970 * Return an array of Item objects for selected items 1971 * 1972 * If asIDs is true, return an array of itemIDs instead 1973 */ 1974 Zotero.ItemTreeView.prototype.getSelectedItems = function(asIDs) 1975 { 1976 var items = [], start = {}, end = {}; 1977 for (var i=0, len = this.selection.getRangeCount(); i<len; i++) 1978 { 1979 this.selection.getRangeAt(i,start,end); 1980 for (var j=start.value; j<=end.value; j++) { 1981 let row = this.getRow(j); 1982 if (!row) { 1983 Zotero.logError(`Row ${j} not found`); 1984 continue; 1985 } 1986 items.push(asIDs ? row.id : row.ref); 1987 } 1988 } 1989 return items; 1990 } 1991 1992 1993 /** 1994 * Delete the selection 1995 * 1996 * @param {Boolean} [force=false] Delete item even if removing from a collection 1997 */ 1998 Zotero.ItemTreeView.prototype.deleteSelection = Zotero.Promise.coroutine(function* (force) 1999 { 2000 if (arguments.length > 1) { 2001 throw ("deleteSelection() no longer takes two parameters"); 2002 } 2003 2004 if (this.selection.count == 0) { 2005 return; 2006 } 2007 2008 //this._treebox.beginUpdateBatch(); 2009 2010 // Collapse open items 2011 for (var i=0; i<this.rowCount; i++) { 2012 if (this.selection.isSelected(i) && this.isContainer(i)) { 2013 this._closeContainer(i, true); 2014 } 2015 } 2016 this._refreshItemRowMap(); 2017 2018 // Create an array of selected items 2019 var ids = []; 2020 var start = {}; 2021 var end = {}; 2022 for (var i=0, len=this.selection.getRangeCount(); i<len; i++) 2023 { 2024 this.selection.getRangeAt(i,start,end); 2025 for (var j=start.value; j<=end.value; j++) 2026 ids.push(this.getRow(j).id); 2027 } 2028 2029 var collectionTreeRow = this.collectionTreeRow; 2030 2031 if (collectionTreeRow.isBucket()) { 2032 collectionTreeRow.ref.deleteItems(ids); 2033 } 2034 if (collectionTreeRow.isTrash()) { 2035 yield Zotero.Items.erase(ids); 2036 } 2037 else if (collectionTreeRow.isLibrary(true) || force) { 2038 yield Zotero.Items.trashTx(ids); 2039 } 2040 else if (collectionTreeRow.isCollection()) { 2041 yield Zotero.DB.executeTransaction(function* () { 2042 yield collectionTreeRow.ref.removeItems(ids); 2043 }); 2044 } 2045 else if (collectionTreeRow.isPublications()) { 2046 yield Zotero.Items.removeFromPublications(ids.map(id => Zotero.Items.get(id))); 2047 } 2048 2049 //this._treebox.endUpdateBatch(); 2050 }); 2051 2052 2053 /* 2054 * Set the search/tags filter on the view 2055 */ 2056 Zotero.ItemTreeView.prototype.setFilter = Zotero.Promise.coroutine(function* (type, data) { 2057 if (!this._treebox || !this._treebox.treeBody) { 2058 Components.utils.reportError("Treebox didn't exist in itemTreeView.setFilter()"); 2059 return; 2060 } 2061 2062 this.selection.selectEventsSuppressed = true; 2063 //this._treebox.beginUpdateBatch(); 2064 2065 switch (type) { 2066 case 'search': 2067 this.collectionTreeRow.setSearch(data); 2068 break; 2069 case 'tags': 2070 this.collectionTreeRow.setTags(data); 2071 break; 2072 default: 2073 throw ('Invalid filter type in setFilter'); 2074 } 2075 var oldCount = this.rowCount; 2076 yield this.refresh(); 2077 2078 //this._treebox.endUpdateBatch(); 2079 this.selection.selectEventsSuppressed = false; 2080 }); 2081 2082 2083 /* 2084 * Create map of item ids to row indexes 2085 */ 2086 Zotero.ItemTreeView.prototype._refreshItemRowMap = function() 2087 { 2088 var rowMap = {}; 2089 for (var i=0, len=this.rowCount; i<len; i++) { 2090 let row = this.getRow(i); 2091 let id = row.ref.id; 2092 if (rowMap[id] !== undefined) { 2093 Zotero.debug(`WARNING: Item row ${rowMap[id]} already found for item ${id} at ${i}`, 2); 2094 Zotero.debug(new Error().stack, 2); 2095 } 2096 rowMap[id] = i; 2097 } 2098 this._rowMap = rowMap; 2099 } 2100 2101 2102 Zotero.ItemTreeView.prototype.saveSelection = function () { 2103 Zotero.debug("Zotero.ItemTreeView::saveSelection() is deprecated -- use getSelectedItems(true)"); 2104 return this.getSelectedItems(true); 2105 } 2106 2107 2108 /* 2109 * Sets the selection based on saved selection ids 2110 */ 2111 Zotero.ItemTreeView.prototype.rememberSelection = function (selection) { 2112 if (!selection.length) { 2113 return; 2114 } 2115 2116 this.selection.clearSelection(); 2117 2118 if (!this.selection.selectEventsSuppressed) { 2119 var unsuppress = this.selection.selectEventsSuppressed = true; 2120 this._treebox.beginUpdateBatch(); 2121 } 2122 2123 try { 2124 for (let i = 0; i < selection.length; i++) { 2125 if (this._rowMap[selection[i]] != null) { 2126 this.selection.toggleSelect(this._rowMap[selection[i]]); 2127 } 2128 // Try the parent 2129 else { 2130 var item = Zotero.Items.get(selection[i]); 2131 if (!item) { 2132 continue; 2133 } 2134 2135 var parent = item.parentItemID; 2136 if (!parent) { 2137 continue; 2138 } 2139 2140 if (this._rowMap[parent] != null) { 2141 this._closeContainer(this._rowMap[parent]); 2142 this.toggleOpenState(this._rowMap[parent]); 2143 this.selection.toggleSelect(this._rowMap[selection[i]]); 2144 } 2145 } 2146 } 2147 } 2148 // Ignore NS_ERROR_UNEXPECTED from nsITreeSelection::toggleSelect(), apparently when the tree 2149 // disappears before it's called (though I can't reproduce it): 2150 // 2151 // https://forums.zotero.org/discussion/69226/papers-become-invisible-in-the-middle-pane 2152 catch (e) { 2153 Zotero.logError(e); 2154 } 2155 2156 if (unsuppress) { 2157 this._treebox.endUpdateBatch(); 2158 this.selection.selectEventsSuppressed = false; 2159 } 2160 } 2161 2162 2163 Zotero.ItemTreeView.prototype.selectSearchMatches = function () { 2164 if (this._searchMode) { 2165 this.rememberSelection(Array.from(this._searchItemIDs)); 2166 } 2167 else { 2168 this.selection.clearSelection(); 2169 } 2170 } 2171 2172 2173 Zotero.ItemTreeView.prototype._saveOpenState = function (close) { 2174 var itemIDs = []; 2175 if (close) { 2176 if (!this.selection.selectEventsSuppressed) { 2177 var unsuppress = this.selection.selectEventsSuppressed = true; 2178 this._treebox.beginUpdateBatch(); 2179 } 2180 } 2181 for (var i=0; i<this._rows.length; i++) { 2182 if (this.isContainer(i) && this.isContainerOpen(i)) { 2183 itemIDs.push(this.getRow(i).ref.id); 2184 if (close) { 2185 this._closeContainer(i, true); 2186 } 2187 } 2188 } 2189 if (close) { 2190 this._refreshItemRowMap(); 2191 if (unsuppress) { 2192 this._treebox.endUpdateBatch(); 2193 this.selection.selectEventsSuppressed = false; 2194 } 2195 } 2196 return itemIDs; 2197 } 2198 2199 2200 Zotero.ItemTreeView.prototype.rememberOpenState = function (itemIDs) { 2201 var rowsToOpen = []; 2202 for (let id of itemIDs) { 2203 var row = this._rowMap[id]; 2204 // Item may not still exist 2205 if (row == undefined) { 2206 continue; 2207 } 2208 rowsToOpen.push(row); 2209 } 2210 rowsToOpen.sort(function (a, b) { 2211 return a - b; 2212 }); 2213 2214 if (!this.selection.selectEventsSuppressed) { 2215 var unsuppress = this.selection.selectEventsSuppressed = true; 2216 this._treebox.beginUpdateBatch(); 2217 } 2218 // Reopen from bottom up 2219 for (var i=rowsToOpen.length-1; i>=0; i--) { 2220 this.toggleOpenState(rowsToOpen[i], true); 2221 } 2222 this._refreshItemRowMap(); 2223 if (unsuppress) { 2224 this._treebox.endUpdateBatch(); 2225 this.selection.selectEventsSuppressed = false; 2226 } 2227 } 2228 2229 2230 Zotero.ItemTreeView.prototype.expandMatchParents = function (searchParentIDs) { 2231 var t = new Date(); 2232 var time = 0; 2233 // Expand parents of child matches 2234 if (!this._searchMode) { 2235 return; 2236 } 2237 2238 if (!this.selection.selectEventsSuppressed) { 2239 var unsuppress = this.selection.selectEventsSuppressed = true; 2240 this._treebox.beginUpdateBatch(); 2241 } 2242 for (var i=0; i<this.rowCount; i++) { 2243 var id = this.getRow(i).ref.id; 2244 if (searchParentIDs.has(id) && this.isContainer(i) && !this.isContainerOpen(i)) { 2245 var t2 = new Date(); 2246 this.toggleOpenState(i, true); 2247 time += (new Date() - t2); 2248 } 2249 } 2250 this._refreshItemRowMap(); 2251 if (unsuppress) { 2252 this._treebox.endUpdateBatch(); 2253 this.selection.selectEventsSuppressed = false; 2254 } 2255 } 2256 2257 2258 Zotero.ItemTreeView.prototype.expandAllRows = function () { 2259 var unsuppress = this.selection.selectEventsSuppressed = true; 2260 this._treebox.beginUpdateBatch(); 2261 for (var i=0; i<this.rowCount; i++) { 2262 if (this.isContainer(i) && !this.isContainerOpen(i)) { 2263 this.toggleOpenState(i, true); 2264 } 2265 } 2266 this._refreshItemRowMap(); 2267 this._treebox.endUpdateBatch(); 2268 this.selection.selectEventsSuppressed = false; 2269 } 2270 2271 2272 Zotero.ItemTreeView.prototype.collapseAllRows = function () { 2273 var unsuppress = this.selection.selectEventsSuppressed = true; 2274 this._treebox.beginUpdateBatch(); 2275 for (var i=0; i<this.rowCount; i++) { 2276 if (this.isContainer(i)) { 2277 this._closeContainer(i, true); 2278 } 2279 } 2280 this._refreshItemRowMap(); 2281 this._treebox.endUpdateBatch(); 2282 this.selection.selectEventsSuppressed = false; 2283 }; 2284 2285 2286 Zotero.ItemTreeView.prototype.expandSelectedRows = function () { 2287 var start = {}, end = {}; 2288 this.selection.selectEventsSuppressed = true; 2289 this._treebox.beginUpdateBatch(); 2290 for (var i = 0, len = this.selection.getRangeCount(); i<len; i++) { 2291 this.selection.getRangeAt(i, start, end); 2292 for (var j = start.value; j <= end.value; j++) { 2293 if (this.isContainer(j) && !this.isContainerOpen(j)) { 2294 this.toggleOpenState(j, true); 2295 } 2296 } 2297 } 2298 this._refreshItemRowMap(); 2299 this._treebox.endUpdateBatch(); 2300 this.selection.selectEventsSuppressed = false; 2301 } 2302 2303 2304 Zotero.ItemTreeView.prototype.collapseSelectedRows = function () { 2305 var start = {}, end = {}; 2306 this.selection.selectEventsSuppressed = true; 2307 this._treebox.beginUpdateBatch(); 2308 for (var i = 0, len = this.selection.getRangeCount(); i<len; i++) { 2309 this.selection.getRangeAt(i, start, end); 2310 for (var j = start.value; j <= end.value; j++) { 2311 if (this.isContainer(j)) { 2312 this._closeContainer(j, true); 2313 } 2314 } 2315 } 2316 this._refreshItemRowMap(); 2317 this._treebox.endUpdateBatch(); 2318 this.selection.selectEventsSuppressed = false; 2319 } 2320 2321 2322 Zotero.ItemTreeView.prototype.getVisibleFields = function() { 2323 var columns = []; 2324 for (var i=0, len=this._treebox.columns.count; i<len; i++) { 2325 var col = this._treebox.columns.getColumnAt(i); 2326 if (col.element.getAttribute('hidden') != 'true') { 2327 columns.push(col.id.substring(20)); 2328 } 2329 } 2330 return columns; 2331 } 2332 2333 2334 /** 2335 * Returns an array of items of visible items in current sort order 2336 * 2337 * @param {Boolean} asIDs - Return itemIDs 2338 * @return {Zotero.Item[]|Integer[]} - An array of Zotero.Item objects or itemIDs 2339 */ 2340 Zotero.ItemTreeView.prototype.getSortedItems = function(asIDs) { 2341 return this._rows.map(row => asIDs ? row.ref.id : row.ref); 2342 } 2343 2344 2345 Zotero.ItemTreeView.prototype.getSortField = function() { 2346 if (this.collectionTreeRow.isFeed()) { 2347 return 'id'; 2348 } 2349 var column = this._treebox.columns.getSortedColumn(); 2350 if (!column) { 2351 column = this._treebox.columns.getFirstColumn(); 2352 } 2353 // zotero-items-column-_________ 2354 return column.id.substring(20); 2355 } 2356 2357 2358 Zotero.ItemTreeView.prototype.getSortFields = function () { 2359 var fields = [this.getSortField()]; 2360 var secondaryField = this.getSecondarySortField(); 2361 if (secondaryField) { 2362 fields.push(secondaryField); 2363 } 2364 try { 2365 var fallbackFields = Zotero.Prefs.get('fallbackSort') 2366 .split(',') 2367 .map((x) => x.trim()) 2368 .filter((x) => x !== ''); 2369 } 2370 catch (e) { 2371 Zotero.debug(e, 1); 2372 Components.utils.reportError(e); 2373 // This should match the default value for the fallbackSort pref 2374 var fallbackFields = ['firstCreator', 'date', 'title', 'dateAdded']; 2375 } 2376 fields = Zotero.Utilities.arrayUnique(fields.concat(fallbackFields)); 2377 2378 // If date appears after year, remove it, unless it's the explicit secondary sort 2379 var yearPos = fields.indexOf('year'); 2380 if (yearPos != -1) { 2381 let datePos = fields.indexOf('date'); 2382 if (datePos > yearPos && secondaryField != 'date') { 2383 fields.splice(datePos, 1); 2384 } 2385 } 2386 2387 return fields; 2388 } 2389 2390 2391 /* 2392 * Returns 'ascending' or 'descending' 2393 */ 2394 Zotero.ItemTreeView.prototype.getSortDirection = function() { 2395 if (this.collectionTreeRow.isFeed()) { 2396 return Zotero.Prefs.get('feeds.sortAscending') ? 'ascending' : 'descending'; 2397 } 2398 var column = this._treebox.columns.getSortedColumn(); 2399 if (!column) { 2400 return 'ascending'; 2401 } 2402 return column.element.getAttribute('sortDirection'); 2403 } 2404 2405 2406 Zotero.ItemTreeView.prototype.getSecondarySortField = function () { 2407 var primaryField = this.getSortField(); 2408 var secondaryField = Zotero.Prefs.get('secondarySort.' + primaryField); 2409 if (!secondaryField || secondaryField == primaryField) { 2410 return false; 2411 } 2412 return secondaryField; 2413 } 2414 2415 2416 Zotero.ItemTreeView.prototype.setSecondarySortField = function (secondaryField) { 2417 var primaryField = this.getSortField(); 2418 var currentSecondaryField = this.getSecondarySortField(); 2419 var sortFields = this.getSortFields(); 2420 2421 if (primaryField == secondaryField) { 2422 return false; 2423 } 2424 2425 if (currentSecondaryField) { 2426 // If same as the current explicit secondary sort, ignore 2427 if (currentSecondaryField == secondaryField) { 2428 return false; 2429 } 2430 2431 // If not, but same as first implicit sort, remove current explicit sort 2432 if (sortFields[2] && sortFields[2] == secondaryField) { 2433 Zotero.Prefs.clear('secondarySort.' + primaryField); 2434 return true; 2435 } 2436 } 2437 // If same as current implicit secondary sort, ignore 2438 else if (sortFields[1] && sortFields[1] == secondaryField) { 2439 return false; 2440 } 2441 2442 Zotero.Prefs.set('secondarySort.' + primaryField, secondaryField); 2443 return true; 2444 } 2445 2446 2447 /** 2448 * Build the More Columns and Secondary Sort submenus while the popup is opening 2449 */ 2450 Zotero.ItemTreeView.prototype.onColumnPickerShowing = function (event) { 2451 var menupopup = event.originalTarget; 2452 2453 var ns = 'http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'; 2454 var prefix = 'zotero-column-header-'; 2455 var doc = menupopup.ownerDocument; 2456 2457 var anonid = menupopup.getAttribute('anonid'); 2458 if (anonid.indexOf(prefix) == 0) { 2459 return; 2460 } 2461 2462 var lastChild = menupopup.lastChild; 2463 2464 try { 2465 // More Columns menu 2466 let id = prefix + 'more-menu'; 2467 2468 let moreMenu = doc.createElementNS(ns, 'menu'); 2469 moreMenu.setAttribute('label', Zotero.getString('pane.items.columnChooser.moreColumns')); 2470 moreMenu.setAttribute('anonid', id); 2471 2472 let moreMenuPopup = doc.createElementNS(ns, 'menupopup'); 2473 moreMenuPopup.setAttribute('anonid', id + '-popup'); 2474 2475 let treecols = menupopup.parentNode.parentNode; 2476 let subs = Array.from(treecols.getElementsByAttribute('submenu', 'true')) 2477 .map(x => x.getAttribute('label')); 2478 2479 var moreItems = []; 2480 2481 for (let i=0; i<menupopup.childNodes.length; i++) { 2482 let elem = menupopup.childNodes[i]; 2483 if (elem.localName == 'menuseparator') { 2484 break; 2485 } 2486 if (elem.localName == 'menuitem' && subs.indexOf(elem.getAttribute('label')) != -1) { 2487 moreItems.push(elem); 2488 } 2489 } 2490 2491 // Disable certain fields for feeds 2492 let labels = Array.from(treecols.getElementsByAttribute('disabled-in', '*')) 2493 .filter(e => e.getAttribute('disabled-in').split(' ').indexOf(this.collectionTreeRow.type) != -1) 2494 .map(e => e.getAttribute('label')); 2495 for (let i = 0; i < menupopup.childNodes.length; i++) { 2496 let elem = menupopup.childNodes[i]; 2497 elem.setAttribute('disabled', labels.indexOf(elem.getAttribute('label')) != -1); 2498 } 2499 2500 // Sort fields and move to submenu 2501 var collation = Zotero.getLocaleCollation(); 2502 moreItems.sort(function (a, b) { 2503 return collation.compareString(1, a.getAttribute('label'), b.getAttribute('label')); 2504 }); 2505 moreItems.forEach(function (elem) { 2506 moreMenuPopup.appendChild(menupopup.removeChild(elem)); 2507 }); 2508 2509 moreMenu.appendChild(moreMenuPopup); 2510 menupopup.insertBefore(moreMenu, lastChild); 2511 } 2512 catch (e) { 2513 Components.utils.reportError(e); 2514 Zotero.debug(e, 1); 2515 } 2516 2517 // 2518 // Secondary Sort menu 2519 // 2520 if (!this.collectionTreeRow.isFeed()) { 2521 try { 2522 let id = prefix + 'sort-menu'; 2523 let primaryField = this.getSortField(); 2524 let sortFields = this.getSortFields(); 2525 let secondaryField = false; 2526 if (sortFields[1]) { 2527 secondaryField = sortFields[1]; 2528 } 2529 2530 // Get localized names from treecols, since the names are currently done via .dtd 2531 let treecols = menupopup.parentNode.parentNode; 2532 let primaryFieldLabel = treecols.getElementsByAttribute('id', 2533 'zotero-items-column-' + primaryField)[0].getAttribute('label'); 2534 2535 let sortMenu = doc.createElementNS(ns, 'menu'); 2536 sortMenu.setAttribute('label', 2537 Zotero.getString('pane.items.columnChooser.secondarySort', primaryFieldLabel)); 2538 sortMenu.setAttribute('anonid', id); 2539 2540 let sortMenuPopup = doc.createElementNS(ns, 'menupopup'); 2541 sortMenuPopup.setAttribute('anonid', id + '-popup'); 2542 2543 // Generate menuitems 2544 let sortOptions = [ 2545 'title', 2546 'firstCreator', 2547 'itemType', 2548 'date', 2549 'year', 2550 'publisher', 2551 'publicationTitle', 2552 'dateAdded', 2553 'dateModified' 2554 ]; 2555 for (let i=0; i<sortOptions.length; i++) { 2556 let field = sortOptions[i]; 2557 // Hide current primary field, and don't show Year for Date, since it would be a no-op 2558 if (field == primaryField || (primaryField == 'date' && field == 'year')) { 2559 continue; 2560 } 2561 let label = treecols.getElementsByAttribute('id', 2562 'zotero-items-column-' + field)[0].getAttribute('label'); 2563 2564 let sortMenuItem = doc.createElementNS(ns, 'menuitem'); 2565 sortMenuItem.setAttribute('fieldName', field); 2566 sortMenuItem.setAttribute('label', label); 2567 sortMenuItem.setAttribute('type', 'checkbox'); 2568 if (field == secondaryField) { 2569 sortMenuItem.setAttribute('checked', 'true'); 2570 } 2571 sortMenuItem.setAttribute('oncommand', 2572 'var view = ZoteroPane.itemsView; ' 2573 + 'if (view.setSecondarySortField(this.getAttribute("fieldName"))) { view.sort(); }'); 2574 sortMenuPopup.appendChild(sortMenuItem); 2575 } 2576 2577 sortMenu.appendChild(sortMenuPopup); 2578 menupopup.insertBefore(sortMenu, lastChild); 2579 } 2580 catch (e) { 2581 Components.utils.reportError(e); 2582 Zotero.debug(e, 1); 2583 } 2584 } 2585 2586 sep = doc.createElementNS(ns, 'menuseparator'); 2587 sep.setAttribute('anonid', prefix + 'sep'); 2588 menupopup.insertBefore(sep, lastChild); 2589 } 2590 2591 2592 Zotero.ItemTreeView.prototype.onColumnPickerHidden = function (event) { 2593 var menupopup = event.originalTarget; 2594 var prefix = 'zotero-column-header-'; 2595 2596 for (let i=0; i<menupopup.childNodes.length; i++) { 2597 let elem = menupopup.childNodes[i]; 2598 if (elem.getAttribute('anonid').indexOf(prefix) == 0) { 2599 try { 2600 menupopup.removeChild(elem); 2601 } 2602 catch (e) { 2603 Zotero.debug(e, 1); 2604 } 2605 i--; 2606 } 2607 } 2608 } 2609 2610 2611 Zotero.ItemTreeView.prototype._getTreeElement = function () { 2612 return this._treebox.treeBody && this._treebox.treeBody.parentNode; 2613 } 2614 2615 2616 //////////////////////////////////////////////////////////////////////////////// 2617 /// 2618 /// Command Controller: 2619 /// for Select All, etc. 2620 /// 2621 //////////////////////////////////////////////////////////////////////////////// 2622 2623 Zotero.ItemTreeCommandController = function(tree) 2624 { 2625 this.tree = tree; 2626 } 2627 2628 Zotero.ItemTreeCommandController.prototype.supportsCommand = function(cmd) 2629 { 2630 return (cmd == 'cmd_selectAll'); 2631 } 2632 2633 Zotero.ItemTreeCommandController.prototype.isCommandEnabled = function(cmd) 2634 { 2635 return (cmd == 'cmd_selectAll'); 2636 } 2637 2638 Zotero.ItemTreeCommandController.prototype.doCommand = function (cmd) { 2639 if (cmd == 'cmd_selectAll') { 2640 if (this.tree.view.wrappedJSObject.collectionTreeRow.isSearchMode()) { 2641 this.tree.view.wrappedJSObject.selectSearchMatches(); 2642 } 2643 else { 2644 this.tree.view.selection.selectAll(); 2645 } 2646 } 2647 } 2648 2649 Zotero.ItemTreeCommandController.prototype.onEvent = function(evt) 2650 { 2651 2652 } 2653 2654 //////////////////////////////////////////////////////////////////////////////// 2655 /// 2656 /// Drag-and-drop functions 2657 /// 2658 //////////////////////////////////////////////////////////////////////////////// 2659 2660 /** 2661 * Start a drag using HTML 5 Drag and Drop 2662 */ 2663 Zotero.ItemTreeView.prototype.onDragStart = function (event) { 2664 // See note in LibraryTreeView::_setDropEffect() 2665 if (Zotero.isWin || Zotero.isLinux) { 2666 event.dataTransfer.effectAllowed = 'copyMove'; 2667 } 2668 2669 var itemIDs = this.getSelectedItems(true); 2670 event.dataTransfer.setData("zotero/item", itemIDs); 2671 // dataTransfer.mozSourceNode doesn't seem to be properly set anymore (tested in 50), so store 2672 // event target separately 2673 if (!event.dataTransfer.mozSourceNode) { 2674 Zotero.debug("mozSourceNode not set -- storing source node"); 2675 Zotero.DragDrop.currentSourceNode = event.target; 2676 } 2677 2678 var items = Zotero.Items.get(itemIDs); 2679 2680 // If at least one file is a non-web-link attachment and can be found, 2681 // enable dragging to file system 2682 var files = items 2683 .filter(item => item.isAttachment()) 2684 .map(item => item.getFilePath()) 2685 .filter(path => path); 2686 2687 if (files.length) { 2688 // Advanced multi-file drag (with unique filenames, which otherwise happen automatically on 2689 // Windows but not Linux) and auxiliary snapshot file copying on macOS 2690 let dataProvider; 2691 if (Zotero.isMac) { 2692 dataProvider = new Zotero.ItemTreeView.fileDragDataProvider(itemIDs); 2693 } 2694 2695 for (let i = 0; i < files.length; i++) { 2696 let file = Zotero.File.pathToFile(files[i]); 2697 2698 if (dataProvider) { 2699 Zotero.debug("Adding application/x-moz-file-promise"); 2700 event.dataTransfer.mozSetDataAt("application/x-moz-file-promise", dataProvider, i); 2701 } 2702 2703 // Allow dragging to filesystem on Linux and Windows 2704 let uri; 2705 if (!Zotero.isMac) { 2706 Zotero.debug("Adding text/x-moz-url " + i); 2707 let fph = Components.classes["@mozilla.org/network/protocol;1?name=file"] 2708 .createInstance(Components.interfaces.nsIFileProtocolHandler); 2709 uri = fph.getURLSpecFromFile(file); 2710 event.dataTransfer.mozSetDataAt("text/x-moz-url", uri + '\n' + file.leafName, i); 2711 } 2712 2713 // Allow dragging to web targets (e.g., Gmail) 2714 Zotero.debug("Adding application/x-moz-file " + i); 2715 event.dataTransfer.mozSetDataAt("application/x-moz-file", file, i); 2716 2717 if (Zotero.isWin) { 2718 event.dataTransfer.mozSetDataAt("application/x-moz-file-promise-url", uri, i); 2719 } 2720 else if (Zotero.isLinux) { 2721 // Don't create a symlink for an unmodified drag 2722 event.dataTransfer.effectAllowed = 'copy'; 2723 } 2724 } 2725 } 2726 2727 // Get Quick Copy format for current URL (set via /ping from connector) 2728 var format = Zotero.QuickCopy.getFormatFromURL(Zotero.QuickCopy.lastActiveURL); 2729 2730 Zotero.debug("Dragging with format " + format); 2731 2732 var exportCallback = function(obj, worked) { 2733 if (!worked) { 2734 Zotero.log(Zotero.getString("fileInterface.exportError"), 'warning'); 2735 return; 2736 } 2737 2738 var text = obj.string.replace(/\r\n/g, "\n"); 2739 event.dataTransfer.setData("text/plain", text); 2740 } 2741 2742 format = Zotero.QuickCopy.unserializeSetting(format); 2743 try { 2744 if (format.mode == 'export') { 2745 Zotero.QuickCopy.getContentFromItems(items, format, exportCallback); 2746 } 2747 else if (format.mode == 'bibliography') { 2748 var content = Zotero.QuickCopy.getContentFromItems(items, format, null, event.shiftKey); 2749 if (content) { 2750 if (content.html) { 2751 event.dataTransfer.setData("text/html", content.html); 2752 } 2753 event.dataTransfer.setData("text/plain", content.text); 2754 } 2755 } 2756 else { 2757 Components.utils.reportError("Invalid Quick Copy mode"); 2758 } 2759 } 2760 catch (e) { 2761 Zotero.debug(e); 2762 Components.utils.reportError(e + " with '" + format.id + "'"); 2763 } 2764 }; 2765 2766 Zotero.ItemTreeView.prototype.onDragEnd = function (event) { 2767 setTimeout(function () { 2768 Zotero.DragDrop.currentDragSource = null; 2769 }); 2770 } 2771 2772 2773 // Implements nsIFlavorDataProvider for dragging attachment files to OS 2774 // 2775 // Not used on Windows in Firefox 3 or higher 2776 Zotero.ItemTreeView.fileDragDataProvider = function (itemIDs) { 2777 this._itemIDs = itemIDs; 2778 }; 2779 2780 Zotero.ItemTreeView.fileDragDataProvider.prototype = { 2781 QueryInterface : function(iid) { 2782 if (iid.equals(Components.interfaces.nsIFlavorDataProvider) || 2783 iid.equals(Components.interfaces.nsISupports)) { 2784 return this; 2785 } 2786 throw Components.results.NS_NOINTERFACE; 2787 }, 2788 2789 getFlavorData : function(transferable, flavor, data, dataLen) { 2790 Zotero.debug("Getting flavor data for " + flavor); 2791 if (flavor == "application/x-moz-file-promise") { 2792 // On platforms other than OS X, the only directory we know of here 2793 // is the system temp directory, and we pass the nsIFile of the file 2794 // copied there in data.value below 2795 var useTemp = !Zotero.isMac; 2796 2797 // Get the destination directory 2798 var dirPrimitive = {}; 2799 var dataSize = {}; 2800 transferable.getTransferData("application/x-moz-file-promise-dir", dirPrimitive, dataSize); 2801 var destDir = dirPrimitive.value.QueryInterface(Components.interfaces.nsILocalFile); 2802 2803 var draggedItems = Zotero.Items.get(this._itemIDs); 2804 var items = []; 2805 2806 // Make sure files exist 2807 var notFoundNames = []; 2808 for (var i=0; i<draggedItems.length; i++) { 2809 // TODO create URL? 2810 if (!draggedItems[i].isAttachment() || 2811 draggedItems[i].getAttachmentLinkMode() == Zotero.Attachments.LINK_MODE_LINKED_URL) { 2812 continue; 2813 } 2814 2815 if (draggedItems[i].getFile()) { 2816 items.push(draggedItems[i]); 2817 } 2818 else { 2819 notFoundNames.push(draggedItems[i].getField('title')); 2820 } 2821 } 2822 2823 // If using the temp directory, create a directory to store multiple 2824 // files, since we can (it seems) only pass one nsIFile in data.value 2825 if (useTemp && items.length > 1) { 2826 var tmpDirName = 'Zotero Dragged Files'; 2827 destDir.append(tmpDirName); 2828 if (destDir.exists()) { 2829 destDir.remove(true); 2830 } 2831 destDir.create(Components.interfaces.nsIFile.DIRECTORY_TYPE, 0o755); 2832 } 2833 2834 var copiedFiles = []; 2835 var existingItems = []; 2836 var existingFileNames = []; 2837 2838 for (var i=0; i<items.length; i++) { 2839 // TODO create URL? 2840 if (!items[i].isAttachment() || 2841 items[i].attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) { 2842 continue; 2843 } 2844 2845 var file = items[i].getFile(); 2846 2847 // Determine if we need to copy multiple files for this item 2848 // (web page snapshots) 2849 if (items[i].attachmentLinkMode != Zotero.Attachments.LINK_MODE_LINKED_FILE) { 2850 var parentDir = file.parent; 2851 var files = parentDir.directoryEntries; 2852 var numFiles = 0; 2853 while (files.hasMoreElements()) { 2854 var f = files.getNext(); 2855 f.QueryInterface(Components.interfaces.nsILocalFile); 2856 if (f.leafName.indexOf('.') != 0) { 2857 numFiles++; 2858 } 2859 } 2860 } 2861 2862 // Create folder if multiple files 2863 if (numFiles > 1) { 2864 var dirName = Zotero.Attachments.getFileBaseNameFromItem(items[i]); 2865 try { 2866 if (useTemp) { 2867 var copiedFile = destDir.clone(); 2868 copiedFile.append(dirName); 2869 if (copiedFile.exists()) { 2870 // If item directory already exists in the temp dir, 2871 // delete it 2872 if (items.length == 1) { 2873 copiedFile.remove(true); 2874 } 2875 // If item directory exists in the container 2876 // directory, it's a duplicate, so give this one 2877 // a different name 2878 else { 2879 copiedFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0o644); 2880 var newName = copiedFile.leafName; 2881 copiedFile.remove(null); 2882 } 2883 } 2884 } 2885 2886 parentDir.copyToFollowingLinks(destDir, newName ? newName : dirName); 2887 2888 // Store nsIFile 2889 if (useTemp) { 2890 copiedFiles.push(copiedFile); 2891 } 2892 } 2893 catch (e) { 2894 if (e.name == 'NS_ERROR_FILE_ALREADY_EXISTS') { 2895 // Keep track of items that already existed 2896 existingItems.push(items[i].id); 2897 existingFileNames.push(dirName); 2898 } 2899 else { 2900 throw (e); 2901 } 2902 } 2903 } 2904 // Otherwise just copy 2905 else { 2906 try { 2907 if (useTemp) { 2908 var copiedFile = destDir.clone(); 2909 copiedFile.append(file.leafName); 2910 if (copiedFile.exists()) { 2911 // If file exists in the temp directory, 2912 // delete it 2913 if (items.length == 1) { 2914 copiedFile.remove(true); 2915 } 2916 // If file exists in the container directory, 2917 // it's a duplicate, so give this one a different 2918 // name 2919 else { 2920 copiedFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0o644); 2921 var newName = copiedFile.leafName; 2922 copiedFile.remove(null); 2923 } 2924 } 2925 } 2926 2927 file.copyToFollowingLinks(destDir, newName ? newName : null); 2928 2929 // Store nsIFile 2930 if (useTemp) { 2931 copiedFiles.push(copiedFile); 2932 } 2933 } 2934 catch (e) { 2935 if (e.name == 'NS_ERROR_FILE_ALREADY_EXISTS') { 2936 existingItems.push(items[i].id); 2937 existingFileNames.push(items[i].getFile().leafName); 2938 } 2939 else { 2940 throw (e); 2941 } 2942 } 2943 } 2944 } 2945 2946 // Files passed via data.value will be automatically moved 2947 // from the temp directory to the destination directory 2948 if (useTemp && copiedFiles.length) { 2949 if (items.length > 1) { 2950 data.value = destDir.QueryInterface(Components.interfaces.nsISupports); 2951 } 2952 else { 2953 data.value = copiedFiles[0].QueryInterface(Components.interfaces.nsISupports); 2954 } 2955 dataLen.value = 4; 2956 } 2957 2958 if (notFoundNames.length || existingItems.length) { 2959 var promptService = Components.classes["@mozilla.org/embedcomp/prompt-service;1"] 2960 .getService(Components.interfaces.nsIPromptService); 2961 } 2962 2963 // Display alert if files were not found 2964 if (notFoundNames.length > 0) { 2965 // On platforms that use a temporary directory, an alert here 2966 // would interrupt the dragging process, so we just log a 2967 // warning to the console 2968 if (useTemp) { 2969 for (let name of notFoundNames) { 2970 var msg = "Attachment file for dragged item '" + name + "' not found"; 2971 Zotero.log(msg, 'warning', 2972 'chrome://zotero/content/xpcom/itemTreeView.js'); 2973 } 2974 } 2975 else { 2976 promptService.alert(null, Zotero.getString('general.warning'), 2977 Zotero.getString('dragAndDrop.filesNotFound') + "\n\n" 2978 + notFoundNames.join("\n")); 2979 } 2980 } 2981 2982 // Display alert if existing files were skipped 2983 if (existingItems.length > 0) { 2984 promptService.alert(null, Zotero.getString('general.warning'), 2985 Zotero.getString('dragAndDrop.existingFiles') + "\n\n" 2986 + existingFileNames.join("\n")); 2987 } 2988 } 2989 } 2990 } 2991 2992 2993 /** 2994 * Called by treechildren.onDragOver() before setting the dropEffect, 2995 * which is checked in libraryTreeView.canDrop() 2996 */ 2997 Zotero.ItemTreeView.prototype.canDropCheck = function (row, orient, dataTransfer) { 2998 //Zotero.debug("Row is " + row + "; orient is " + orient); 2999 3000 var dragData = Zotero.DragDrop.getDataFromDataTransfer(dataTransfer); 3001 if (!dragData) { 3002 Zotero.debug("No drag data"); 3003 return false; 3004 } 3005 var dataType = dragData.dataType; 3006 var data = dragData.data; 3007 3008 var collectionTreeRow = this.collectionTreeRow; 3009 3010 if (row != -1 && orient == 0) { 3011 var rowItem = this.getRow(row).ref; // the item we are dragging over 3012 } 3013 3014 if (dataType == 'zotero/item') { 3015 let items = Zotero.Items.get(data); 3016 3017 // Directly on a row 3018 if (rowItem) { 3019 var canDrop = false; 3020 3021 for (let item of items) { 3022 // If any regular items, disallow drop 3023 if (item.isRegularItem()) { 3024 return false; 3025 } 3026 3027 // Disallow cross-library child drag 3028 if (item.libraryID != collectionTreeRow.ref.libraryID) { 3029 return false; 3030 } 3031 3032 // Only allow dragging of notes and attachments 3033 // that aren't already children of the item 3034 if (item.parentItemID != rowItem.id) { 3035 canDrop = true; 3036 } 3037 } 3038 return canDrop; 3039 } 3040 3041 // In library, allow children to be dragged out of parent 3042 else if (collectionTreeRow.isLibrary(true) || collectionTreeRow.isCollection()) { 3043 for (let item of items) { 3044 // Don't allow drag if any top-level items 3045 if (item.isTopLevelItem()) { 3046 return false; 3047 } 3048 3049 // Don't allow web attachments to be dragged out of parents, 3050 // but do allow PDFs for now so they can be recognized 3051 if (item.isWebAttachment() && item.attachmentContentType != 'application/pdf') { 3052 return false; 3053 } 3054 3055 // Don't allow children to be dragged within their own parents 3056 var parentItemID = item.parentItemID; 3057 var parentIndex = this._rowMap[parentItemID]; 3058 if (row != -1 && this.getLevel(row) > 0) { 3059 if (this.getRow(this.getParentIndex(row)).ref.id == parentItemID) { 3060 return false; 3061 } 3062 } 3063 // Including immediately after the parent 3064 if (orient == 1) { 3065 if (row == parentIndex) { 3066 return false; 3067 } 3068 } 3069 // And immediately before the next parent 3070 if (orient == -1) { 3071 var nextParentIndex = null; 3072 for (var i = parentIndex + 1; i < this.rowCount; i++) { 3073 if (this.getLevel(i) == 0) { 3074 nextParentIndex = i; 3075 break; 3076 } 3077 } 3078 if (row === nextParentIndex) { 3079 return false; 3080 } 3081 } 3082 3083 // Disallow cross-library child drag 3084 if (item.libraryID != collectionTreeRow.ref.libraryID) { 3085 return false; 3086 } 3087 } 3088 return true; 3089 } 3090 return false; 3091 } 3092 else if (dataType == "text/x-moz-url" || dataType == 'application/x-moz-file') { 3093 // Disallow direct drop on a non-regular item (e.g. note) 3094 if (rowItem) { 3095 if (!rowItem.isRegularItem()) { 3096 return false; 3097 } 3098 } 3099 // Don't allow drop into searches or publications 3100 else if (collectionTreeRow.isSearch() || collectionTreeRow.isPublications()) { 3101 return false; 3102 } 3103 3104 return true; 3105 } 3106 3107 return false; 3108 }; 3109 3110 /* 3111 * Called when something's been dropped on or next to a row 3112 */ 3113 Zotero.ItemTreeView.prototype.drop = Zotero.Promise.coroutine(function* (row, orient, dataTransfer) { 3114 if (!this.canDrop(row, orient, dataTransfer)) { 3115 return false; 3116 } 3117 3118 var dragData = Zotero.DragDrop.getDataFromDataTransfer(dataTransfer); 3119 if (!dragData) { 3120 Zotero.debug("No drag data"); 3121 return false; 3122 } 3123 var dropEffect = dragData.dropEffect; 3124 var dataType = dragData.dataType; 3125 var data = dragData.data; 3126 var sourceCollectionTreeRow = Zotero.DragDrop.getDragSource(dataTransfer); 3127 var collectionTreeRow = this.collectionTreeRow; 3128 var targetLibraryID = collectionTreeRow.ref.libraryID; 3129 3130 if (dataType == 'zotero/item') { 3131 var ids = data; 3132 var items = Zotero.Items.get(ids); 3133 if (items.length < 1) { 3134 return; 3135 } 3136 3137 // TEMP: This is always false for now, since cross-library drag 3138 // is disallowed in canDropCheck() 3139 // 3140 // TODO: support items coming from different sources? 3141 if (items[0].libraryID == targetLibraryID) { 3142 var sameLibrary = true; 3143 } 3144 else { 3145 var sameLibrary = false; 3146 } 3147 3148 var toMove = []; 3149 3150 // Dropped directly on a row 3151 if (orient == 0) { 3152 // Set drop target as the parent item for dragged items 3153 // 3154 // canDrop() limits this to child items 3155 var rowItem = this.getRow(row).ref; // the item we are dragging over 3156 yield Zotero.DB.executeTransaction(function* () { 3157 for (let i=0; i<items.length; i++) { 3158 let item = items[i]; 3159 item.parentID = rowItem.id; 3160 yield item.save(); 3161 } 3162 }); 3163 } 3164 3165 // Dropped outside of a row 3166 else 3167 { 3168 // Remove from parent and make top-level 3169 if (collectionTreeRow.isLibrary(true)) { 3170 yield Zotero.DB.executeTransaction(function* () { 3171 for (let i=0; i<items.length; i++) { 3172 let item = items[i]; 3173 if (!item.isRegularItem()) { 3174 item.parentID = false; 3175 yield item.save() 3176 } 3177 } 3178 }); 3179 } 3180 // Add to collection 3181 else 3182 { 3183 yield Zotero.DB.executeTransaction(function* () { 3184 for (let i=0; i<items.length; i++) { 3185 let item = items[i]; 3186 var source = item.isRegularItem() ? false : item.parentItemID; 3187 // Top-level item 3188 if (source) { 3189 item.parentID = false; 3190 item.addToCollection(collectionTreeRow.ref.id); 3191 yield item.save(); 3192 } 3193 else { 3194 item.addToCollection(collectionTreeRow.ref.id); 3195 yield item.save(); 3196 } 3197 toMove.push(item.id); 3198 } 3199 }); 3200 } 3201 } 3202 3203 // If moving, remove items from source collection 3204 if (dropEffect == 'move' && toMove.length) { 3205 if (!sameLibrary) { 3206 throw new Error("Cannot move items between libraries"); 3207 } 3208 if (!sourceCollectionTreeRow || !sourceCollectionTreeRow.isCollection()) { 3209 throw new Error("Drag source must be a collection"); 3210 } 3211 if (collectionTreeRow.id != sourceCollectionTreeRow.id) { 3212 yield Zotero.DB.executeTransaction(function* () { 3213 yield collectionTreeRow.ref.removeItems(toMove); 3214 }.bind(this)); 3215 } 3216 } 3217 } 3218 else if (dataType == 'text/x-moz-url' || dataType == 'application/x-moz-file') { 3219 // Disallow drop into read-only libraries 3220 if (!collectionTreeRow.editable) { 3221 let win = Services.wm.getMostRecentWindow("navigator:browser"); 3222 win.ZoteroPane.displayCannotEditLibraryMessage(); 3223 return; 3224 } 3225 3226 var targetLibraryID = collectionTreeRow.ref.libraryID; 3227 3228 var parentItemID = false; 3229 var parentCollectionID = false; 3230 3231 if (orient == 0) { 3232 let treerow = this.getRow(row); 3233 parentItemID = treerow.ref.id 3234 } 3235 else if (collectionTreeRow.isCollection()) { 3236 var parentCollectionID = collectionTreeRow.ref.id; 3237 } 3238 3239 let addedItems = []; 3240 var notifierQueue = new Zotero.Notifier.Queue; 3241 try { 3242 // If there's a single file being added to a parent, automatic renaming is enabled, 3243 // and there are no other non-HTML attachments, we'll rename the file as long as it's 3244 // an allowed type. The dragged data could be a URL, so we don't yet know the file type. 3245 // This should be kept in sync with ZoteroPane.addAttachmentFromDialog(). 3246 let renameIfAllowedType = false; 3247 let parentItem; 3248 if (parentItemID && data.length == 1 && Zotero.Prefs.get('autoRenameFiles')) { 3249 parentItem = Zotero.Items.get(parentItemID); 3250 if (!parentItem.numNonHTMLFileAttachments()) { 3251 renameIfAllowedType = true; 3252 } 3253 } 3254 3255 for (var i=0; i<data.length; i++) { 3256 var file = data[i]; 3257 3258 if (dataType == 'text/x-moz-url') { 3259 var url = data[i]; 3260 if (url.indexOf('file:///') == 0) { 3261 let win = Services.wm.getMostRecentWindow("navigator:browser"); 3262 // If dragging currently loaded page, only convert to 3263 // file if not an HTML document 3264 if (win.content.location.href != url || 3265 win.content.document.contentType != 'text/html') { 3266 var nsIFPH = Components.classes["@mozilla.org/network/protocol;1?name=file"] 3267 .getService(Components.interfaces.nsIFileProtocolHandler); 3268 try { 3269 var file = nsIFPH.getFileFromURLSpec(url); 3270 } 3271 catch (e) { 3272 Zotero.debug(e); 3273 } 3274 } 3275 } 3276 3277 // Still string, so remote URL 3278 if (typeof file == 'string') { 3279 let item; 3280 if (parentItemID) { 3281 if (!collectionTreeRow.filesEditable) { 3282 let win = Services.wm.getMostRecentWindow("navigator:browser"); 3283 win.ZoteroPane.displayCannotEditLibraryFilesMessage(); 3284 return; 3285 } 3286 item = yield Zotero.Attachments.importFromURL({ 3287 libraryID: targetLibraryID, 3288 url, 3289 renameIfAllowedType, 3290 parentItemID, 3291 saveOptions: { 3292 notifierQueue 3293 } 3294 }); 3295 } 3296 else { 3297 let win = Services.wm.getMostRecentWindow("navigator:browser"); 3298 item = yield win.ZoteroPane.addItemFromURL(url, 'temporaryPDFHack'); // TODO: don't do this 3299 } 3300 if (item) { 3301 addedItems.push(item); 3302 } 3303 continue; 3304 } 3305 3306 // Otherwise file, so fall through 3307 } 3308 3309 file = file.path; 3310 3311 // Rename file if it's an allowed type 3312 let fileBaseName = false; 3313 if (renameIfAllowedType) { 3314 fileBaseName = yield Zotero.Attachments.getRenamedFileBaseNameIfAllowedType( 3315 parentItem, file 3316 ); 3317 } 3318 3319 let item; 3320 if (dropEffect == 'link') { 3321 // Rename linked file, with unique suffix if necessary 3322 try { 3323 if (fileBaseName) { 3324 let ext = Zotero.File.getExtension(file); 3325 let newName = yield Zotero.File.rename( 3326 file, 3327 fileBaseName + (ext ? '.' + ext : ''), 3328 { 3329 unique: true 3330 } 3331 ); 3332 // Update path in case the name was changed to be unique 3333 file = OS.Path.join(OS.Path.dirname(file), newName); 3334 } 3335 } 3336 catch (e) { 3337 Zotero.logError(e); 3338 } 3339 3340 item = yield Zotero.Attachments.linkFromFile({ 3341 file, 3342 parentItemID, 3343 collections: parentCollectionID ? [parentCollectionID] : undefined, 3344 saveOptions: { 3345 notifierQueue 3346 } 3347 }); 3348 } 3349 else { 3350 if (file.endsWith(".lnk")) { 3351 let win = Services.wm.getMostRecentWindow("navigator:browser"); 3352 win.ZoteroPane.displayCannotAddShortcutMessage(file); 3353 continue; 3354 } 3355 3356 item = yield Zotero.Attachments.importFromFile({ 3357 file, 3358 fileBaseName, 3359 libraryID: targetLibraryID, 3360 parentItemID, 3361 collections: parentCollectionID ? [parentCollectionID] : undefined, 3362 saveOptions: { 3363 notifierQueue 3364 } 3365 }); 3366 // If moving, delete original file 3367 if (dragData.dropEffect == 'move') { 3368 try { 3369 yield OS.File.remove(file); 3370 } 3371 catch (e) { 3372 Zotero.logError("Error deleting original file " + file + " after drag"); 3373 } 3374 } 3375 } 3376 3377 if (item) { 3378 addedItems.push(item); 3379 } 3380 } 3381 } 3382 finally { 3383 yield Zotero.Notifier.commit(notifierQueue); 3384 } 3385 3386 // Automatically retrieve metadata for PDFs 3387 if (!parentItemID) { 3388 Zotero.RecognizePDF.autoRecognizeItems(addedItems); 3389 } 3390 } 3391 }); 3392 3393 3394 //////////////////////////////////////////////////////////////////////////////// 3395 /// 3396 /// Functions for nsITreeView that we have to stub out. 3397 /// 3398 //////////////////////////////////////////////////////////////////////////////// 3399 3400 Zotero.ItemTreeView.prototype.isSeparator = function(row) { return false; } 3401 Zotero.ItemTreeView.prototype.isSelectable = function (row, col) { return true; } 3402 Zotero.ItemTreeView.prototype.getRowProperties = function(row, prop) {} 3403 Zotero.ItemTreeView.prototype.getColumnProperties = function(col, prop) {} 3404 Zotero.ItemTreeView.prototype.getCellProperties = function(row, col, prop) { 3405 var treeRow = this.getRow(row); 3406 var itemID = treeRow.ref.id; 3407 3408 var props = []; 3409 3410 // Mark items not matching search as context rows, displayed in gray 3411 if (this._searchMode && !this._searchItemIDs.has(itemID)) { 3412 props.push("contextRow"); 3413 } 3414 3415 // Mark hasAttachment column, which needs special image handling 3416 if (col.id == 'zotero-items-column-hasAttachment') { 3417 props.push("hasAttachment"); 3418 3419 // Don't show pie for open parent items, since we show it for the 3420 // child item 3421 if (!this.isContainer(row) || !this.isContainerOpen(row)) { 3422 var num = Zotero.Sync.Storage.getItemDownloadImageNumber(treeRow.ref); 3423 //var num = Math.round(new Date().getTime() % 10000 / 10000 * 64); 3424 if (num !== false) props.push("pie", "pie" + num); 3425 } 3426 } 3427 3428 // Style unread items in feeds 3429 if (treeRow.ref.isFeedItem && !treeRow.ref.isRead) props.push('unread'); 3430 3431 return props.join(" "); 3432 } 3433 3434 Zotero.ItemTreeRow = function(ref, level, isOpen) 3435 { 3436 this.ref = ref; //the item associated with this 3437 this.level = level; 3438 this.isOpen = isOpen; 3439 this.id = ref.id; 3440 } 3441 3442 Zotero.ItemTreeRow.prototype.getField = function(field, unformatted) 3443 { 3444 return this.ref.getField(field, unformatted, true); 3445 } 3446 3447 Zotero.ItemTreeRow.prototype.numNotes = function() { 3448 if (this.ref.isNote()) { 3449 return 0; 3450 } 3451 if (this.ref.isAttachment()) { 3452 return this.ref.getNote() !== '' ? 1 : 0; 3453 } 3454 return this.ref.numNotes(false, true) || 0; 3455 }