/* ***** BEGIN LICENSE BLOCK ***** Copyright © 2009 Center for History and New Media George Mason University, Fairfax, Virginia, USA http://zotero.org This file is part of Zotero. Zotero is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Zotero is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with Zotero. If not, see . ***** END LICENSE BLOCK ***** */ //////////////////////////////////////////////////////////////////////////////// /// /// ItemTreeView /// -- handles the link between an individual tree and the data layer /// -- displays only items (no collections, no hierarchy) /// //////////////////////////////////////////////////////////////////////////////// /* * Constructor for the ItemTreeView object */ Zotero.ItemTreeView = function (collectionTreeRow, sourcesOnly) { Zotero.LibraryTreeView.apply(this); this.wrappedJSObject = this; this.rowCount = 0; this.collectionTreeRow = collectionTreeRow; this._skipKeypress = false; this._sourcesOnly = sourcesOnly; this._ownerDocument = null; this._needsSort = false; this._cellTextCache = {}; this._itemImages = {}; this._refreshPromise = Zotero.Promise.resolve(); this._unregisterID = Zotero.Notifier.registerObserver( this, ['item', 'collection-item', 'item-tag', 'share-items', 'bucket', 'feedItem', 'search'], 'itemTreeView', 50 ); } Zotero.ItemTreeView.prototype = Object.create(Zotero.LibraryTreeView.prototype); Zotero.ItemTreeView.prototype.type = 'item'; /** * Called by the tree itself */ Zotero.ItemTreeView.prototype.setTree = Zotero.Promise.coroutine(function* (treebox) { try { Zotero.debug("Setting tree for " + this.collectionTreeRow.id + " items view " + this.id); var start = Date.now(); // Try to set the window document if not yet set if (treebox && !this._ownerDocument) { try { this._ownerDocument = treebox.treeBody.ownerDocument; } catch (e) {} } if (this._treebox) { if (this._needsSort) { this.sort(); } return; } if (!treebox) { Zotero.debug("Treebox not passed in setTree()", 2); return; } if (!this._ownerDocument) { Zotero.debug("No owner document in setTree()", 2); return; } this._treebox = treebox; this.setSortColumn(); if (this._ownerDocument.defaultView.ZoteroPane_Local) { this._ownerDocument.defaultView.ZoteroPane_Local.setItemsPaneMessage(Zotero.getString('pane.items.loading')); } if (Zotero.locked) { Zotero.debug("Zotero is locked -- not loading items tree", 2); if (this._ownerDocument.defaultView.ZoteroPane_Local) { this._ownerDocument.defaultView.ZoteroPane_Local.clearItemsPaneMessage(); } return; } yield this.refresh(); if (!this._treebox.treeBody) { return; } // Add a keypress listener for expand/collapse var tree = this._treebox.treeBody.parentNode; var self = this; var coloredTagsRE = new RegExp("^[1-" + Zotero.Tags.MAX_COLORED_TAGS + "]{1}$"); var listener = function(event) { if (self._skipKeyPress) { self._skipKeyPress = false; return; } // Focus note editor when Tab is pressed on a selected note if (event.keyCode == event.DOM_VK_TAB) { let items = this.getSelectedItems(); if (items.length == 1 && items[0].isNote()) { let noteEditor = this._ownerDocument.getElementById('zotero-note-editor'); if (noteEditor) { noteEditor.focus(); event.preventDefault(); } } return; } // Handle arrow keys specially on multiple selection, since // otherwise the tree just applies it to the last-selected row if (event.keyCode == 39 || event.keyCode == 37) { if (self._treebox.view.selection.count > 1) { switch (event.keyCode) { case 39: self.expandSelectedRows(); break; case 37: self.collapseSelectedRows(); break; } event.preventDefault(); } return; } var key = String.fromCharCode(event.which); if (key == '+' && !(event.ctrlKey || event.altKey || event.metaKey)) { self.expandAllRows(); event.preventDefault(); return; } else if (key == '-' && !(event.shiftKey || event.ctrlKey || event.altKey || event.metaKey)) { self.collapseAllRows(); event.preventDefault(); return; } // Ignore other non-character keypresses if (!event.charCode || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) { return; } event.preventDefault(); Zotero.spawn(function* () { if (coloredTagsRE.test(key)) { let libraryID = self.collectionTreeRow.ref.libraryID; let position = parseInt(key) - 1; let colorData = Zotero.Tags.getColorByPosition(libraryID, position); // If a color isn't assigned to this number or any // other numbers, allow key navigation if (!colorData) { return !Zotero.Tags.getColors(libraryID).size; } var items = self.getSelectedItems(); yield Zotero.Tags.toggleItemsListTags(libraryID, items, colorData.name); return; } // We have to disable key navigation on the tree in order to // keep it from acting on the 1-9 keys used for colored tags. // To allow navigation with other keys, we temporarily enable // key navigation and recreate the keyboard event. Since // that will trigger this listener again, we set a flag to // ignore the event, and then clear the flag above when the // event comes in. I see no way this could go wrong... tree.disableKeyNavigation = false; self._skipKeyPress = true; var nsIDWU = Components.interfaces.nsIDOMWindowUtils; var domWindowUtils = event.originalTarget.ownerDocument.defaultView .QueryInterface(Components.interfaces.nsIInterfaceRequestor) .getInterface(nsIDWU); var modifiers = 0; if (event.altKey) { modifiers |= nsIDWU.MODIFIER_ALT; } if (event.ctrlKey) { modifiers |= nsIDWU.MODIFIER_CONTROL; } if (event.shiftKey) { modifiers |= nsIDWU.MODIFIER_SHIFT; } if (event.metaKey) { modifiers |= nsIDWU.MODIFIER_META; } domWindowUtils.sendKeyEvent( 'keypress', event.keyCode, event.charCode, modifiers ); tree.disableKeyNavigation = true; }) .catch(function (e) { Zotero.logError(e); }) }.bind(this); // Store listener so we can call removeEventListener() in ItemTreeView.unregister() this.listener = listener; tree.addEventListener('keypress', listener); // This seems to be the only way to prevent Enter/Return // from toggle row open/close. The event is handled by // handleKeyPress() in zoteroPane.js. tree._handleEnter = function () {}; this.sort(); this.expandMatchParents(); if (this._ownerDocument.defaultView.ZoteroPane_Local) { // For My Publications, show intro text in middle pane if no items if (this.collectionTreeRow && this.collectionTreeRow.isPublications() && !this.rowCount) { let doc = this._ownerDocument; let ns = 'http://www.w3.org/1999/xhtml' let div = doc.createElementNS(ns, 'div'); let p = doc.createElementNS(ns, 'p'); p.textContent = Zotero.getString('publications.intro.text1', ZOTERO_CONFIG.DOMAIN_NAME); div.appendChild(p); p = doc.createElementNS(ns, 'p'); p.textContent = Zotero.getString('publications.intro.text2'); div.appendChild(p); p = doc.createElementNS(ns, 'p'); let html = Zotero.getString('publications.intro.text3'); // Convert tags to placeholders html = html.replace('', ':b:').replace('', ':/b:'); // Encode any other special chars, which shouldn't exist html = Zotero.Utilities.htmlSpecialChars(html); // Restore bold text html = html.replace(':b:', '').replace(':/b:', ''); p.innerHTML = html; // AMO note: markup from hard-coded strings and filtered above div.appendChild(p); content = div; doc.defaultView.ZoteroPane_Local.setItemsPaneMessage(content); } else { this._ownerDocument.defaultView.ZoteroPane_Local.clearItemsPaneMessage(); } } if (this.collectionTreeRow && this.collectionTreeRow.itemToSelect) { var item = this.collectionTreeRow.itemToSelect; yield this.selectItem(item['id'], item['expand']); this.collectionTreeRow.itemToSelect = null; } Zotero.debug("Set tree for items view " + this.id + " in " + (Date.now() - start) + " ms"); this._initialized = true; yield this._runListeners('load'); } catch (e) { Zotero.debug(e, 1); Components.utils.reportError(e); if (this.onError) { this.onError(e); } throw e; } }); Zotero.ItemTreeView.prototype.setSortColumn = function() { var dir, col, currentCol, currentDir; for (let i=0, len=this._treebox.columns.count; i non-feed) if (! this.collectionTreeRow.isFeed() && colID) { col = this._treebox.columns.getNamedColumn(colID); dir = Zotero.Prefs.get('itemTree.sortDirection'); Zotero.Prefs.clear('itemTree.sortColumnID'); Zotero.Prefs.clear('itemTree.sortDirection'); // No previous sort setting stored, so store it (non-feed -> feed) } else if (this.collectionTreeRow.isFeed() && !colID && currentCol) { Zotero.Prefs.set('itemTree.sortColumnID', currentCol.id); Zotero.Prefs.set('itemTree.sortDirection', currentDir); // Retain current sort setting (non-feed -> non-feed) } else { col = currentCol; dir = currentDir; } if (col) { col.element.setAttribute('sortActive', true); col.element.setAttribute('sortDirection', dir); } } /** * Reload the rows from the data access methods * (doesn't call the tree.invalidate methods, etc.) */ Zotero.ItemTreeView.prototype.refresh = Zotero.serial(Zotero.Promise.coroutine(function* () { Zotero.debug('Refreshing items list for ' + this.id); // DEBUG: necessary? try { this._treebox.columns.count } // If treebox isn't ready, skip refresh catch (e) { return false; } var resolve, reject; this._refreshPromise = new Zotero.Promise(function () { resolve = arguments[0]; reject = arguments[1]; }); try { Zotero.CollectionTreeCache.clear(); var newItems = yield this.collectionTreeRow.getItems(); if (!this.selection.selectEventsSuppressed) { var unsuppress = this.selection.selectEventsSuppressed = true; this._treebox.beginUpdateBatch(); } var savedSelection = this.getSelectedItems(true); var savedOpenState = this._saveOpenState(); var oldCount = this.rowCount; var newSearchItemIDs = {}; var newSearchParentIDs = {}; var newCellTextCache = {}; var newSearchMode = this.collectionTreeRow.isSearchMode(); var newRows = []; var added = 0; for (let i=0, len=newItems.length; i < len; i++) { let item = newItems[i]; // Only add regular items if sourcesOnly is set if (this._sourcesOnly && !item.isRegularItem()) { continue; } // Don't add child items directly (instead mark their parents for // inclusion below) let parentItemID = item.parentItemID; if (parentItemID) { newSearchParentIDs[parentItemID] = true; } // Add top-level items else { this._addRowToArray( newRows, new Zotero.ItemTreeRow(item, 0, false), added++ ); } newSearchItemIDs[item.id] = true; } // Add parents of matches if not matches themselves for (let id in newSearchParentIDs) { if (!newSearchItemIDs[id]) { let item = Zotero.Items.get(id); this._addRowToArray( newRows, new Zotero.ItemTreeRow(item, 0, false), added++ ); } } this._rows = newRows; this.rowCount = this._rows.length; var diff = this.rowCount - oldCount; if (diff != 0) { this._treebox.rowCountChanged(0, diff); } this._refreshItemRowMap(); this._searchMode = newSearchMode; this._searchItemIDs = newSearchItemIDs; // items matching the search this._searchParentIDs = newSearchParentIDs; this._cellTextCache = {}; this.rememberOpenState(savedOpenState); this.rememberSelection(savedSelection); this.expandMatchParents(); if (unsuppress) { this._treebox.endUpdateBatch(); this.selection.selectEventsSuppressed = false; } setTimeout(function () { resolve(); }); } catch (e) { setTimeout(function () { reject(e); }); throw e; } })); /* * Called by Zotero.Notifier on any changes to items in the data layer */ Zotero.ItemTreeView.prototype.notify = Zotero.Promise.coroutine(function* (action, type, ids, extraData) { Zotero.debug("Yielding for refresh promise"); // TEMP yield this._refreshPromise; if (!this._treebox || !this._treebox.treeBody) { Zotero.debug("Treebox didn't exist in itemTreeView.notify()"); return; } if (!this._rowMap) { Zotero.debug("Item row map didn't exist in itemTreeView.notify()"); return; } if (type == 'search' && action == 'modify') { // TODO: Only refresh on condition change (not currently available in extraData) yield this.refresh(); this.sort(); this._treebox.invalidate(); return; } // Clear item type icon and tag colors when a tag is added to or removed from an item if (type == 'item-tag') { // TODO: Only update if colored tag changed? ids.map(val => val.split("-")[0]).forEach(function (val) { delete this._itemImages[val]; }.bind(this)); return; } var collectionTreeRow = this.collectionTreeRow; if (collectionTreeRow.isFeed() && action == 'modify') { for (let i=0; i extraData[id] && extraData[id].skipSelect)) { scrollPosition = this._saveScrollPosition(); } // Redraw the tree (for tag color and progress changes) if (action == 'redraw') { // Redraw specific rows if (type == 'item' && ids.length) { // Redraw specific cells if (extraData && extraData.column) { var col = this._treebox.columns.getNamedColumn( 'zotero-items-column-' + extraData.column ); for (let id of ids) { if (extraData.column == 'title') { delete this._itemImages[id]; } this._treebox.invalidateCell(this._rowMap[id], col); } } else { for (let id of ids) { delete this._itemImages[id]; this._treebox.invalidateRow(this._rowMap[id]); } } } // Redraw the whole tree else { this._itemImages = {}; this._treebox.invalidate(); } return; } if (action == 'refresh') { if (type == 'share-items') { if (collectionTreeRow.isShare()) { yield this.refresh(); refreshed = true; } } else if (type == 'bucket') { if (collectionTreeRow.isBucket()) { yield this.refresh(); refreshed = true; } } else if (type == 'publications') { if (collectionTreeRow.isPublications()) { yield this.refresh(); refreshed = true; } } // If refreshing a single item, clear caches and then unselect and reselect row else if (savedSelection.length == 1 && savedSelection[0] == ids[0]) { let row = this._rowMap[ids[0]]; delete this._cellTextCache[row]; this.selection.clearSelection(); this.rememberSelection(savedSelection); } else { this._cellTextCache = {}; } return; } if (collectionTreeRow.isShare()) { return; } // See if we're in the active window var zp = Zotero.getActiveZoteroPane(); var activeWindow = zp && zp.itemsView == this; var quicksearch = this._ownerDocument.getElementById('zotero-tb-search'); // 'collection-item' ids are in the form collectionID-itemID if (type == 'collection-item') { if (!collectionTreeRow.isCollection()) { return; } var splitIDs = []; for (let id of ids) { var split = id.split('-'); // Skip if not an item in this collection if (split[0] != collectionTreeRow.ref.id) { continue; } splitIDs.push(split[1]); } ids = splitIDs; // Select the last item even if there are no changes (e.g. if the tag // selector is open and already refreshed the pane) /*if (splitIDs.length > 0 && (action == 'add' || action == 'modify')) { var selectItem = splitIDs[splitIDs.length - 1]; }*/ } this.selection.selectEventsSuppressed = true; //this._treebox.beginUpdateBatch(); if ((action == 'remove' && !collectionTreeRow.isLibrary(true)) || action == 'delete' || action == 'trash' || (action == 'removeDuplicatesMaster' && collectionTreeRow.isDuplicates())) { // Since a remove involves shifting of rows, we have to do it in order, // so sort the ids by row var rows = []; let push = action == 'delete' || action == 'trash' || action == 'removeDuplicatesMaster'; for (var i=0, len=ids.length; i 0) { rows.push(row); } } } } if (rows.length > 0) { // Child items might have been added more than once rows = Zotero.Utilities.arrayUnique(rows); rows.sort(function(a,b) { return a-b }); for (let i = rows.length - 1; i >= 0; i--) { this._removeRow(rows[i]); } madeChanges = true; sort = true; } } else if (type == 'item' && action == 'modify') { // Clear row caches var items = Zotero.Items.get(ids); for (let i=0; i 0) { let previousItem = Zotero.Items.get(ids[0]); if (previousItem && !previousItem.isTopLevelItem()) { if (this._rows[previousFirstSelectedRow] && this.getLevel(previousFirstSelectedRow) == 0) { previousFirstSelectedRow--; } } } if (previousFirstSelectedRow !== undefined && this._rows[previousFirstSelectedRow]) { this.selection.select(previousFirstSelectedRow); } // If no item at previous position, select last item in list else if (this._rows[this._rows.length - 1]) { this.selection.select(this._rows.length - 1); } } } else { this.rememberSelection(savedSelection); } } this._rememberScrollPosition(scrollPosition); this._treebox.invalidate(); } // For special case in which an item needs to be selected without changes // necessarily having been made // ('collection-item' add with tag selector open) /*else if (selectItem) { yield this.selectItem(selectItem); }*/ //this._treebox.endUpdateBatch(); if (madeChanges) { var deferred = Zotero.Promise.defer(); this.addEventListener('select', () => deferred.resolve()); } this.selection.selectEventsSuppressed = false; if (madeChanges) { Zotero.debug("Yielding for select promise"); // TEMP return deferred.promise; } }); /* * Unregisters view from Zotero.Notifier (called on window close) */ Zotero.ItemTreeView.prototype.unregister = function() { Zotero.Notifier.unregisterObserver(this._unregisterID); if (this.listener) { if (!this._treebox.treeBody) { Zotero.debug("No more tree body in Zotero.ItemTreeView::unregister()"); this.listener = null; return; } let tree = this._treebox.treeBody.parentNode; tree.removeEventListener('keypress', this.listener, false); this.listener = null; } } //////////////////////////////////////////////////////////////////////////////// /// /// nsITreeView functions /// //////////////////////////////////////////////////////////////////////////////// Zotero.ItemTreeView.prototype.getCellText = function (row, column) { var obj = this.getRow(row); var itemID = obj.id; // If value is available, retrieve synchronously if (this._cellTextCache[itemID] && this._cellTextCache[itemID][column.id] !== undefined) { return this._cellTextCache[itemID][column.id]; } if (!this._cellTextCache[itemID]) { this._cellTextCache[itemID] = {} } var val; // Image only if (column.id === "zotero-items-column-hasAttachment") { return; } else if(column.id == "zotero-items-column-itemType") { val = Zotero.ItemTypes.getLocalizedString(obj.ref.itemTypeID); } // Year column is just date field truncated else if (column.id == "zotero-items-column-year") { val = obj.getField('date', true).substr(0, 4) } else if (column.id === "zotero-items-column-numNotes") { val = obj.numNotes(); } else { var col = column.id.substring(20); if (col == 'title') { val = obj.ref.getDisplayTitle(); } else { val = obj.getField(col); } } switch (column.id) { // Format dates as short dates in proper locale order and locale time // (e.g. "4/4/07 14:27:23") case 'zotero-items-column-dateAdded': case 'zotero-items-column-dateModified': case 'zotero-items-column-accessDate': case 'zotero-items-column-date': if (column.id == 'zotero-items-column-date' && !this.collectionTreeRow.isFeed()) { break; } if (val) { var order = Zotero.Date.getLocaleDateOrder(); if (order == 'mdy') { order = 'mdy'; var join = '/'; } else if (order == 'dmy') { order = 'dmy'; var join = '/'; } else if (order == 'ymd') { order = 'YMD'; var join = '-'; } var date = Zotero.Date.sqlToDate(val, true); var parts = []; for (var i=0; i<3; i++) { switch (order[i]) { case 'y': parts.push(date.getFullYear().toString().substr(2)); break; case 'Y': parts.push(date.getFullYear()); break; case 'm': parts.push((date.getMonth() + 1)); break; case 'M': parts.push(Zotero.Utilities.lpad((date.getMonth() + 1).toString(), '0', 2)); break; case 'd': parts.push(date.getDate()); break; case 'D': parts.push(Zotero.Utilities.lpad(date.getDate().toString(), '0', 2)); break; } val = parts.join(join); val += ' ' + date.toLocaleTimeString(); } } } return this._cellTextCache[itemID][column.id] = val; } Zotero.ItemTreeView.prototype.getImageSrc = function(row, col) { if(col.id == 'zotero-items-column-title') { // Get item type icon and tag swatches var item = this.getRow(row).ref; var itemID = item.id; if (this._itemImages[itemID]) { return this._itemImages[itemID]; } item.getImageSrcWithTags() .then(function (uriWithTags) { this._itemImages[itemID] = uriWithTags; this._treebox.invalidateCell(row, col); }.bind(this)); return item.getImageSrc(); } else if (col.id == 'zotero-items-column-hasAttachment') { if (this.collectionTreeRow.isTrash()) return false; var treerow = this.getRow(row); var item = treerow.ref; if ((!this.isContainer(row) || !this.isContainerOpen(row)) && Zotero.Sync.Storage.getItemDownloadImageNumber(item)) { return ''; } var itemID = item.id; let suffix = Zotero.hiDPISuffix; if (treerow.level === 0) { if (item.isRegularItem()) { let state = item.getBestAttachmentStateCached(); if (state !== null) { switch (state) { case 1: return `chrome://zotero/skin/bullet_blue${suffix}.png`; case -1: return `chrome://zotero/skin/bullet_blue_empty${suffix}.png`; default: return ""; } } item.getBestAttachmentState() // Refresh cell when promise is fulfilled .then(function (state) { this._treebox.invalidateCell(row, col); }.bind(this)) .done(); } } if (item.isFileAttachment()) { let exists = item.fileExistsCached(); if (exists !== null) { return exists ? `chrome://zotero/skin/bullet_blue${suffix}.png` : `chrome://zotero/skin/bullet_blue_empty${suffix}.png`; } item.fileExists() // Refresh cell when promise is fulfilled .then(function (exists) { this._treebox.invalidateCell(row, col); }.bind(this)); } } return ""; } Zotero.ItemTreeView.prototype.isContainer = function(row) { return this.getRow(row).ref.isRegularItem(); } Zotero.ItemTreeView.prototype.isContainerEmpty = function(row) { if (this._sourcesOnly) { return true; } var item = this.getRow(row).ref; if (!item.isRegularItem()) { return false; } var includeTrashed = this.collectionTreeRow.isTrash(); return item.numNotes(includeTrashed) === 0 && item.numAttachments(includeTrashed) == 0; } // Gets the index of the row's container, or -1 if none (top-level) Zotero.ItemTreeView.prototype.getParentIndex = function(row) { if (row==-1) { return -1; } var thisLevel = this.getLevel(row); if(thisLevel == 0) return -1; for(var i = row - 1; i >= 0; i--) if(this.getLevel(i) < thisLevel) return i; return -1; } Zotero.ItemTreeView.prototype.hasNextSibling = function(row,afterIndex) { var thisLevel = this.getLevel(row); for(var i = afterIndex + 1; i < this.rowCount; i++) { var nextLevel = this.getLevel(i); if(nextLevel == thisLevel) return true; else if(nextLevel < thisLevel) return false; } } Zotero.ItemTreeView.prototype.toggleOpenState = function (row, skipRowMapRefresh) { // Shouldn't happen but does if an item is dragged over a closed // container until it opens and then released, since the container // is no longer in the same place when the spring-load closes if (!this.isContainer(row)) { return; } if (this.isContainerOpen(row)) { return this._closeContainer(row, skipRowMapRefresh); } var count = 0; var level = this.getLevel(row); // // Open // var item = this.getRow(row).ref; //Get children var includeTrashed = this.collectionTreeRow.isTrash(); var attachments = item.getAttachments(includeTrashed); var notes = item.getNotes(includeTrashed); var newRows; if (attachments.length && notes.length) { newRows = notes.concat(attachments); } else if (attachments.length) { newRows = attachments; } else if (notes.length) { newRows = notes; } if (newRows) { newRows = Zotero.Items.get(newRows); for (let i = 0; i < newRows.length; i++) { count++; this._addRow( new Zotero.ItemTreeRow(newRows[i], level + 1, false), row + i + 1, true ); } } this._rows[row].isOpen = true; if (count == 0) { return; } this._treebox.invalidateRow(row); if (!skipRowMapRefresh) { Zotero.debug('Refreshing hash map'); this._refreshItemRowMap(); } } Zotero.ItemTreeView.prototype._closeContainer = function (row, skipRowMapRefresh) { // isContainer == false shouldn't happen but does if an item is dragged over a closed // container until it opens and then released, since the container is no longer in the same // place when the spring-load closes if (!this.isContainer(row)) return; if (!this.isContainerOpen(row)) return; var count = 0; var level = this.getLevel(row); // Remove child rows while ((row + 1 < this._rows.length) && (this.getLevel(row + 1) > level)) { // Skip the map update here and just refresh the whole map below, // since we might be removing multiple rows this._removeRow(row + 1, true); count--; } this._rows[row].isOpen = false; if (count == 0) { return; } this._treebox.invalidateRow(row); if (!skipRowMapRefresh) { Zotero.debug('Refreshing hash map'); this._refreshItemRowMap(); } } Zotero.ItemTreeView.prototype.isSorted = function() { // We sort by the first column if none selected, so return true return true; } Zotero.ItemTreeView.prototype.cycleHeader = Zotero.Promise.coroutine(function* (column) { if (this.collectionTreeRow.isFeed()) { return; } if (column.id == 'zotero-items-column-hasAttachment') { Zotero.debug("Caching best attachment states"); if (!this._cachedBestAttachmentStates) { let t = new Date(); for (let i = 0; i < this._rows.length; i++) { let item = this.getRow(i).ref; if (item.isRegularItem()) { yield item.getBestAttachmentState(); } } Zotero.debug("Cached best attachment states in " + (new Date - t) + " ms"); this._cachedBestAttachmentStates = true; } } for(var i=0, len=this._treebox.columns.count; i cache[x] = {}) // Get the display field for a row (which might be a placeholder title) function getField(field, row) { var item = row.ref; switch (field) { case 'title': return Zotero.Items.getSortTitle(item.getDisplayTitle()); case 'hasAttachment': if (item.isFileAttachment()) { var state = item.fileExistsCached() ? 1 : -1; } else if (item.isRegularItem()) { var state = item.getBestAttachmentStateCached(); } else { return 0; } // Make sort order present, missing, empty when ascending if (state === 1) { state = 2; } else if (state === -1) { state = 1; } return state; case 'numNotes': return row.numNotes(false, true) || 0; // Use unformatted part of date strings (YYYY-MM-DD) for sorting case 'date': var val = row.ref.getField('date', true, true); if (val) { val = val.substr(0, 10); if (val.indexOf('0000') == 0) { val = ""; } } return val; case 'year': var val = row.ref.getField('date', true, true); if (val) { val = val.substr(0, 4); if (val == '0000') { val = ""; } } return val; default: return row.ref.getField(field, false, true); } } var includeTrashed = this.collectionTreeRow.isTrash(); function fieldCompare(a, b, sortField) { var aItemID = a.id; var bItemID = b.id; var fieldA = cache[sortField][aItemID]; var fieldB = cache[sortField][bItemID]; switch (sortField) { case 'firstCreator': return creatorSort(a, b); case 'itemType': var typeA = Zotero.ItemTypes.getLocalizedString(a.ref.itemTypeID); var typeB = Zotero.ItemTypes.getLocalizedString(b.ref.itemTypeID); return (typeA > typeB) ? 1 : (typeA < typeB) ? -1 : 0; default: if (fieldA === undefined) { cache[sortField][aItemID] = fieldA = getField(sortField, a); } if (fieldB === undefined) { cache[sortField][bItemID] = fieldB = getField(sortField, b); } // Display rows with empty values last if (!emptyFirst[sortField]) { if(fieldA === '' && fieldB !== '') return 1; if(fieldA !== '' && fieldB === '') return -1; } if (sortField == 'hasAttachment') { return fieldB - fieldA; } return collation.compareString(1, fieldA, fieldB); } } var rowSort = function (a, b) { var sortFields = Array.slice(arguments, 2); var sortField; while (sortField = sortFields.shift()) { let cmp = fieldCompare(a, b, sortField); if (cmp !== 0) { return cmp; } } return 0; }; var creatorSortCache = {}; // Regexp to extract the whole string up to an optional "and" or "et al." var andEtAlRegExp = new RegExp( // Extract the beginning of the string in non-greedy mode "^.+?" // up to either the end of the string, "et al." at the end of string + "(?=(?: " + Zotero.getString('general.etAl').replace('.', '\.') + ")?$" // or ' and ' + "| " + Zotero.getString('general.and') + " " + ")" ); function creatorSort(a, b) { var itemA = a.ref; var itemB = b.ref; // // Try sorting by the first name in the firstCreator field, since we already have it // // For sortCreatorAsString mode, just use the whole string // var aItemID = a.id, bItemID = b.id, fieldA = creatorSortCache[aItemID], fieldB = creatorSortCache[bItemID]; var prop = sortCreatorAsString ? 'firstCreator' : 'sortCreator'; var sortStringA = itemA[prop]; var sortStringB = itemB[prop]; if (fieldA === undefined) { let firstCreator = Zotero.Items.getSortTitle(sortStringA); if (sortCreatorAsString) { var fieldA = firstCreator; } else { var matches = andEtAlRegExp.exec(firstCreator); var fieldA = matches ? matches[0] : ''; } creatorSortCache[aItemID] = fieldA; } if (fieldB === undefined) { let firstCreator = Zotero.Items.getSortTitle(sortStringB); if (sortCreatorAsString) { var fieldB = firstCreator; } else { var matches = andEtAlRegExp.exec(firstCreator); var fieldB = matches ? matches[0] : ''; } creatorSortCache[bItemID] = fieldB; } if (fieldA === "" && fieldB === "") { return 0; } // Display rows with empty values last if (fieldA === '' && fieldB !== '') return 1; if (fieldA !== '' && fieldB === '') return -1; return collation.compareString(1, fieldA, fieldB); } // Need to close all containers before sorting if (!this.selection.selectEventsSuppressed) { var unsuppress = this.selection.selectEventsSuppressed = true; this._treebox.beginUpdateBatch(); } var savedSelection = this.getSelectedItems(true); var openItemIDs = this._saveOpenState(true); // Single-row sort if (itemID) { let row = this._rowMap[itemID]; for (let i=0, len=this._rows.length; i 0) { let rowItem = this._rows.splice(row, 1); this._rows.splice(row < i ? i-1 : i, 0, rowItem[0]); this._treebox.invalidate(); break; } // If greater than last row, move to end if (i == len-1) { let rowItem = this._rows.splice(row, 1); this._rows.splice(i, 0, rowItem[0]); this._treebox.invalidate(); } } } // Full sort else { this._rows.sort(function (a, b) { return rowSort.apply(this, [a, b].concat(sortFields)) * order; }.bind(this)); Zotero.debug("Sorted items list without creators in " + (new Date - t) + " ms"); } this._refreshItemRowMap(); this.rememberOpenState(openItemIDs); this.rememberSelection(savedSelection); if (unsuppress) { this._treebox.endUpdateBatch(); this.selection.selectEventsSuppressed = false; } Zotero.debug("Sorted items list in " + (new Date - t) + " ms"); }; //////////////////////////////////////////////////////////////////////////////// /// /// Additional functions for managing data in the tree /// //////////////////////////////////////////////////////////////////////////////// /* * Select an item */ Zotero.ItemTreeView.prototype.selectItem = Zotero.Promise.coroutine(function* (id, expand, noRecurse) { // If no row map, we're probably in the process of switching collections, // so store the item to select on the item group for later if (!this._rowMap) { if (this.collectionTreeRow) { this.collectionTreeRow.itemToSelect = { id: id, expand: expand }; Zotero.debug("_rowMap not yet set; not selecting item"); return false; } Zotero.debug('Item group not found and no row map in ItemTreeView.selectItem() -- discarding select', 2); return false; } var selected = this.getSelectedItems(true); if (selected.length == 1 && selected[0] == id) { Zotero.debug("Item " + id + " is already selected"); return true; } var row = this._rowMap[id]; // Get the row of the parent, if there is one var parentRow = null; var item = yield Zotero.Items.getAsync(id); // Can't select a deleted item if we're not in the trash if (item.deleted && !this.collectionTreeRow.isTrash()) { return false; } var parent = item.parentItemID; if (parent && this._rowMap[parent] != undefined) { parentRow = this._rowMap[parent]; } // If row with id not visible, check to see if it's hidden under a parent if(row == undefined) { if (!parent || parentRow === null) { // No parent -- it's not here // Clear the quicksearch and tag selection and try again (once) if (!noRecurse && this._ownerDocument.defaultView.ZoteroPane_Local) { let cleared1 = yield this._ownerDocument.defaultView.ZoteroPane_Local.clearQuicksearch(); let cleared2 = this._ownerDocument.defaultView.ZoteroPane_Local.clearTagSelection(); if (cleared1 || cleared2) { return this.selectItem(id, expand, true); } } Zotero.debug("Could not find row for item; not selecting item"); return false; } // If parent is already open and we haven't found the item, the child // hasn't yet been added to the view, so close parent to allow refresh this._closeContainer(parentRow); // Open the parent this.toggleOpenState(parentRow); row = this._rowMap[id]; } // this.selection.select() triggers the 's 'onselect' attribute, which calls // ZoteroPane.itemSelected(), which calls ZoteroItemPane.viewItem(), which refreshes the // itembox. But since the 'onselect' doesn't handle promises, itemSelected() isn't waited for // here, which means that 'yield selectItem(itemID)' continues before the itembox has been // refreshed. To get around this, we wait for a select event that's triggered by // itemSelected() when it's done. if (this.selection.selectEventsSuppressed) { this.selection.select(row); } else { var deferred = Zotero.Promise.defer(); this.addEventListener('select', () => deferred.resolve()); this.selection.select(row); } // If |expand|, open row if container if (expand && this.isContainer(row) && !this.isContainerOpen(row)) { this.toggleOpenState(row); } this.selection.select(row); if (deferred) { yield deferred.promise; } this.betterEnsureRowIsVisible(row, parentRow); return true; }); /** * Select multiple top-level items * * @param {Integer[]} ids An array of itemIDs */ Zotero.ItemTreeView.prototype.selectItems = function(ids) { if (ids.length == 0) { return; } var rows = []; for (let id of ids) { if(this._rowMap[id] !== undefined) rows.push(this._rowMap[id]); } rows.sort(function (a, b) { return a - b; }); this.selection.clearSelection(); this.selection.selectEventsSuppressed = true; var lastStart = 0; for (var i = 0, len = rows.length; i < len; i++) { if (i == len - 1 || rows[i + 1] != rows[i] + 1) { this.selection.rangedSelect(rows[lastStart], rows[i], true); lastStart = i + 1; } } this.selection.selectEventsSuppressed = false; // TODO: This could probably be improved to try to focus more of the selected rows this.betterEnsureRowIsVisible(rows[0]); } Zotero.ItemTreeView.prototype.betterEnsureRowIsVisible = function (row, parentRow = null) { // We aim for a row 5 below the target row, since ensureRowIsVisible() does // the bare minimum to get the row in view for (let v = row + 5; v >= row; v--) { if (this._rows[v]) { this._treebox.ensureRowIsVisible(v); if (this._treebox.getFirstVisibleRow() <= row) { break; } } } // If the parent row isn't in view and we have enough room, make parent visible if (parentRow !== null && this._treebox.getFirstVisibleRow() > parentRow) { if ((row - parentRow) < this._treebox.getPageLength()) { this._treebox.ensureRowIsVisible(parentRow); } } }; /* * Return an array of Item objects for selected items * * If asIDs is true, return an array of itemIDs instead */ Zotero.ItemTreeView.prototype.getSelectedItems = function(asIDs) { var items = [], start = {}, end = {}; for (var i=0, len = this.selection.getRangeCount(); i 1) { throw ("deleteSelection() no longer takes two parameters"); } if (this.selection.count == 0) { return; } //this._treebox.beginUpdateBatch(); // Collapse open items for (var i=0; i=0; i--) { this.toggleOpenState(rowsToOpen[i], true); } this._refreshItemRowMap(); if (unsuppress) { this._treebox.endUpdateBatch(); this.selection.selectEventsSuppressed = false; } } Zotero.ItemTreeView.prototype.expandMatchParents = function () { var t = new Date(); var time = 0; // Expand parents of child matches if (!this._searchMode) { return; } var parentIDs = new Set(); for (let id in this._searchParentIDs) { parentIDs.add(parseInt(id)); } if (!this.selection.selectEventsSuppressed) { var unsuppress = this.selection.selectEventsSuppressed = true; this._treebox.beginUpdateBatch(); } for (var i=0; i x.trim()) .filter((x) => x !== ''); } catch (e) { Zotero.debug(e, 1); Components.utils.reportError(e); // This should match the default value for the fallbackSort pref var fallbackFields = ['firstCreator', 'date', 'title', 'dateAdded']; } fields = Zotero.Utilities.arrayUnique(fields.concat(fallbackFields)); // If date appears after year, remove it, unless it's the explicit secondary sort var yearPos = fields.indexOf('year'); if (yearPos != -1) { let datePos = fields.indexOf('date'); if (datePos > yearPos && secondaryField != 'date') { fields.splice(datePos, 1); } } return fields; } /* * Returns 'ascending' or 'descending' */ Zotero.ItemTreeView.prototype.getSortDirection = function() { if (this.collectionTreeRow.isFeed()) { return Zotero.Prefs.get('feeds.sortAscending') ? 'ascending' : 'descending'; } var column = this._treebox.columns.getSortedColumn(); if (!column) { return 'ascending'; } return column.element.getAttribute('sortDirection'); } Zotero.ItemTreeView.prototype.getSecondarySortField = function () { var primaryField = this.getSortField(); var secondaryField = Zotero.Prefs.get('secondarySort.' + primaryField); if (!secondaryField || secondaryField == primaryField) { return false; } return secondaryField; } Zotero.ItemTreeView.prototype.setSecondarySortField = function (secondaryField) { var primaryField = this.getSortField(); var currentSecondaryField = this.getSecondarySortField(); var sortFields = this.getSortFields(); if (primaryField == secondaryField) { return false; } if (currentSecondaryField) { // If same as the current explicit secondary sort, ignore if (currentSecondaryField == secondaryField) { return false; } // If not, but same as first implicit sort, remove current explicit sort if (sortFields[2] && sortFields[2] == secondaryField) { Zotero.Prefs.clear('secondarySort.' + primaryField); return true; } } // If same as current implicit secondary sort, ignore else if (sortFields[1] && sortFields[1] == secondaryField) { return false; } Zotero.Prefs.set('secondarySort.' + primaryField, secondaryField); return true; } /** * Build the More Columns and Secondary Sort submenus while the popup is opening */ Zotero.ItemTreeView.prototype.onColumnPickerShowing = function (event) { var menupopup = event.originalTarget; var ns = 'http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'; var prefix = 'zotero-column-header-'; var doc = menupopup.ownerDocument; var anonid = menupopup.getAttribute('anonid'); if (anonid.indexOf(prefix) == 0) { return; } var lastChild = menupopup.lastChild; try { // More Columns menu let id = prefix + 'more-menu'; let moreMenu = doc.createElementNS(ns, 'menu'); moreMenu.setAttribute('label', Zotero.getString('pane.items.columnChooser.moreColumns')); moreMenu.setAttribute('anonid', id); let moreMenuPopup = doc.createElementNS(ns, 'menupopup'); moreMenuPopup.setAttribute('anonid', id + '-popup'); let treecols = menupopup.parentNode.parentNode; let subs = Array.from(treecols.getElementsByAttribute('submenu', 'true')) .map(x => x.getAttribute('label')); var moreItems = []; for (let i=0; i e.getAttribute('disabled-in').split(' ').indexOf(this.collectionTreeRow.type) != -1) .map(e => e.getAttribute('label')); for (let i = 0; i < menupopup.childNodes.length; i++) { let elem = menupopup.childNodes[i]; elem.setAttribute('disabled', labels.indexOf(elem.getAttribute('label')) != -1); } // Sort fields and move to submenu var collation = Zotero.getLocaleCollation(); moreItems.sort(function (a, b) { return collation.compareString(1, a.getAttribute('label'), b.getAttribute('label')); }); moreItems.forEach(function (elem) { moreMenuPopup.appendChild(menupopup.removeChild(elem)); }); moreMenu.appendChild(moreMenuPopup); menupopup.insertBefore(moreMenu, lastChild); } catch (e) { Components.utils.reportError(e); Zotero.debug(e, 1); } // // Secondary Sort menu // if (!this.collectionTreeRow.isFeed()) { try { let id = prefix + 'sort-menu'; let primaryField = this.getSortField(); let sortFields = this.getSortFields(); let secondaryField = false; if (sortFields[1]) { secondaryField = sortFields[1]; } // Get localized names from treecols, since the names are currently done via .dtd let treecols = menupopup.parentNode.parentNode; let primaryFieldLabel = treecols.getElementsByAttribute('id', 'zotero-items-column-' + primaryField)[0].getAttribute('label'); let sortMenu = doc.createElementNS(ns, 'menu'); sortMenu.setAttribute('label', Zotero.getString('pane.items.columnChooser.secondarySort', primaryFieldLabel)); sortMenu.setAttribute('anonid', id); let sortMenuPopup = doc.createElementNS(ns, 'menupopup'); sortMenuPopup.setAttribute('anonid', id + '-popup'); // Generate menuitems let sortOptions = [ 'title', 'firstCreator', 'itemType', 'date', 'year', 'publisher', 'publicationTitle', 'dateAdded', 'dateModified' ]; for (let i=0; i item.isAttachment()) .map(item => item.getFilePath()) .filter(path => path); if (files.length) { // Advanced multi-file drag (with unique filenames, which otherwise happen automatically on // Windows but not Linux) and auxiliary snapshot file copying on macOS let dataProvider; if (Zotero.isMac) { dataProvider = new Zotero.ItemTreeView.fileDragDataProvider(itemIDs); } for (let i = 0; i < files.length; i++) { let file = Zotero.File.pathToFile(files[i]); if (dataProvider) { Zotero.debug("Adding application/x-moz-file-promise"); event.dataTransfer.mozSetDataAt("application/x-moz-file-promise", dataProvider, i); } // Allow dragging to filesystem on Linux and Windows let uri; if (!Zotero.isMac) { Zotero.debug("Adding text/x-moz-url " + i); let fph = Components.classes["@mozilla.org/network/protocol;1?name=file"] .createInstance(Components.interfaces.nsIFileProtocolHandler); uri = fph.getURLSpecFromFile(file); event.dataTransfer.mozSetDataAt("text/x-moz-url", uri + '\n' + file.leafName, i); } // Allow dragging to web targets (e.g., Gmail) Zotero.debug("Adding application/x-moz-file " + i); event.dataTransfer.mozSetDataAt("application/x-moz-file", file, i); if (Zotero.isWin) { event.dataTransfer.mozSetDataAt("application/x-moz-file-promise-url", uri, i); } else if (Zotero.isLinux) { // Don't create a symlink for an unmodified drag event.dataTransfer.effectAllowed = 'copy'; } } } // Get Quick Copy format for current URL var url = this._ownerDocument.defaultView.content && this._ownerDocument.defaultView.content.location ? this._ownerDocument.defaultView.content.location.href : null; var format = Zotero.QuickCopy.getFormatFromURL(url); Zotero.debug("Dragging with format " + format); var exportCallback = function(obj, worked) { if (!worked) { Zotero.log(Zotero.getString("fileInterface.exportError"), 'warning'); return; } var text = obj.string.replace(/\r\n/g, "\n"); event.dataTransfer.setData("text/plain", text); } format = Zotero.QuickCopy.unserializeSetting(format); try { if (format.mode == 'export') { Zotero.QuickCopy.getContentFromItems(items, format, exportCallback); } else if (format.mode == 'bibliography') { var content = Zotero.QuickCopy.getContentFromItems(items, format, null, event.shiftKey); if (content) { if (content.html) { event.dataTransfer.setData("text/html", content.html); } event.dataTransfer.setData("text/plain", content.text); } } else { Components.utils.reportError("Invalid Quick Copy mode"); } } catch (e) { Zotero.debug(e); Components.utils.reportError(e + " with '" + format.id + "'"); } }; // Implements nsIFlavorDataProvider for dragging attachment files to OS // // Not used on Windows in Firefox 3 or higher Zotero.ItemTreeView.fileDragDataProvider = function (itemIDs) { this._itemIDs = itemIDs; }; Zotero.ItemTreeView.fileDragDataProvider.prototype = { QueryInterface : function(iid) { if (iid.equals(Components.interfaces.nsIFlavorDataProvider) || iid.equals(Components.interfaces.nsISupports)) { return this; } throw Components.results.NS_NOINTERFACE; }, getFlavorData : function(transferable, flavor, data, dataLen) { Zotero.debug("Getting flavor data for " + flavor); if (flavor == "application/x-moz-file-promise") { // On platforms other than OS X, the only directory we know of here // is the system temp directory, and we pass the nsIFile of the file // copied there in data.value below var useTemp = !Zotero.isMac; // Get the destination directory var dirPrimitive = {}; var dataSize = {}; transferable.getTransferData("application/x-moz-file-promise-dir", dirPrimitive, dataSize); var destDir = dirPrimitive.value.QueryInterface(Components.interfaces.nsILocalFile); var draggedItems = Zotero.Items.get(this._itemIDs); var items = []; // Make sure files exist var notFoundNames = []; for (var i=0; i 1) { var tmpDirName = 'Zotero Dragged Files'; destDir.append(tmpDirName); if (destDir.exists()) { destDir.remove(true); } destDir.create(Components.interfaces.nsIFile.DIRECTORY_TYPE, 0o755); } var copiedFiles = []; var existingItems = []; var existingFileNames = []; for (var i=0; i 1) { var dirName = Zotero.Attachments.getFileBaseNameFromItem(items[i]); try { if (useTemp) { var copiedFile = destDir.clone(); copiedFile.append(dirName); if (copiedFile.exists()) { // If item directory already exists in the temp dir, // delete it if (items.length == 1) { copiedFile.remove(true); } // If item directory exists in the container // directory, it's a duplicate, so give this one // a different name else { copiedFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0o644); var newName = copiedFile.leafName; copiedFile.remove(null); } } } parentDir.copyToFollowingLinks(destDir, newName ? newName : dirName); // Store nsIFile if (useTemp) { copiedFiles.push(copiedFile); } } catch (e) { if (e.name == 'NS_ERROR_FILE_ALREADY_EXISTS') { // Keep track of items that already existed existingItems.push(items[i].id); existingFileNames.push(dirName); } else { throw (e); } } } // Otherwise just copy else { try { if (useTemp) { var copiedFile = destDir.clone(); copiedFile.append(file.leafName); if (copiedFile.exists()) { // If file exists in the temp directory, // delete it if (items.length == 1) { copiedFile.remove(true); } // If file exists in the container directory, // it's a duplicate, so give this one a different // name else { copiedFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0o644); var newName = copiedFile.leafName; copiedFile.remove(null); } } } file.copyToFollowingLinks(destDir, newName ? newName : null); // Store nsIFile if (useTemp) { copiedFiles.push(copiedFile); } } catch (e) { if (e.name == 'NS_ERROR_FILE_ALREADY_EXISTS') { existingItems.push(items[i].id); existingFileNames.push(items[i].getFile().leafName); } else { throw (e); } } } } // Files passed via data.value will be automatically moved // from the temp directory to the destination directory if (useTemp && copiedFiles.length) { if (items.length > 1) { data.value = destDir.QueryInterface(Components.interfaces.nsISupports); } else { data.value = copiedFiles[0].QueryInterface(Components.interfaces.nsISupports); } dataLen.value = 4; } if (notFoundNames.length || existingItems.length) { var promptService = Components.classes["@mozilla.org/embedcomp/prompt-service;1"] .getService(Components.interfaces.nsIPromptService); } // Display alert if files were not found if (notFoundNames.length > 0) { // On platforms that use a temporary directory, an alert here // would interrupt the dragging process, so we just log a // warning to the console if (useTemp) { for (let name of notFoundNames) { var msg = "Attachment file for dragged item '" + name + "' not found"; Zotero.log(msg, 'warning', 'chrome://zotero/content/xpcom/itemTreeView.js'); } } else { promptService.alert(null, Zotero.getString('general.warning'), Zotero.getString('dragAndDrop.filesNotFound') + "\n\n" + notFoundNames.join("\n")); } } // Display alert if existing files were skipped if (existingItems.length > 0) { promptService.alert(null, Zotero.getString('general.warning'), Zotero.getString('dragAndDrop.existingFiles') + "\n\n" + existingFileNames.join("\n")); } } } } /** * Called by treechildren.onDragOver() before setting the dropEffect, * which is checked in libraryTreeView.canDrop() */ Zotero.ItemTreeView.prototype.canDropCheck = function (row, orient, dataTransfer) { //Zotero.debug("Row is " + row + "; orient is " + orient); var dragData = Zotero.DragDrop.getDataFromDataTransfer(dataTransfer); if (!dragData) { Zotero.debug("No drag data"); return false; } var dataType = dragData.dataType; var data = dragData.data; var collectionTreeRow = this.collectionTreeRow; if (row != -1 && orient == 0) { var rowItem = this.getRow(row).ref; // the item we are dragging over } if (dataType == 'zotero/item') { let items = Zotero.Items.get(data); // Directly on a row if (rowItem) { var canDrop = false; for (let item of items) { // If any regular items, disallow drop if (item.isRegularItem()) { return false; } // Disallow cross-library child drag if (item.libraryID != collectionTreeRow.ref.libraryID) { return false; } // Only allow dragging of notes and attachments // that aren't already children of the item if (item.parentItemID != rowItem.id) { canDrop = true; } } return canDrop; } // In library, allow children to be dragged out of parent else if (collectionTreeRow.isLibrary(true) || collectionTreeRow.isCollection()) { for (let item of items) { // Don't allow drag if any top-level items if (item.isTopLevelItem()) { return false; } // Don't allow web attachments to be dragged out of parents, // but do allow PDFs for now so they can be recognized if (item.isWebAttachment() && item.attachmentContentType != 'application/pdf') { return false; } // Don't allow children to be dragged within their own parents var parentItemID = item.parentItemID; var parentIndex = this._rowMap[parentItemID]; if (row != -1 && this.getLevel(row) > 0) { if (this.getRow(this.getParentIndex(row)).ref.id == parentItemID) { return false; } } // Including immediately after the parent if (orient == 1) { if (row == parentIndex) { return false; } } // And immediately before the next parent if (orient == -1) { var nextParentIndex = null; for (var i = parentIndex + 1; i < this.rowCount; i++) { if (this.getLevel(i) == 0) { nextParentIndex = i; break; } } if (row === nextParentIndex) { return false; } } // Disallow cross-library child drag if (item.libraryID != collectionTreeRow.ref.libraryID) { return false; } } return true; } return false; } else if (dataType == "text/x-moz-url" || dataType == 'application/x-moz-file') { // Disallow direct drop on a non-regular item (e.g. note) if (rowItem) { if (!rowItem.isRegularItem()) { return false; } } // Don't allow drop into searches else if (collectionTreeRow.isSearch()) { return false; } return true; } return false; }; /* * Called when something's been dropped on or next to a row */ Zotero.ItemTreeView.prototype.drop = Zotero.Promise.coroutine(function* (row, orient, dataTransfer) { if (!this.canDrop(row, orient, dataTransfer)) { return false; } var dragData = Zotero.DragDrop.getDataFromDataTransfer(dataTransfer); if (!dragData) { Zotero.debug("No drag data"); return false; } var dropEffect = dragData.dropEffect; var dataType = dragData.dataType; var data = dragData.data; var sourceCollectionTreeRow = Zotero.DragDrop.getDragSource(dataTransfer); var collectionTreeRow = this.collectionTreeRow; var targetLibraryID = collectionTreeRow.ref.libraryID; if (dataType == 'zotero/item') { var ids = data; var items = Zotero.Items.get(ids); if (items.length < 1) { return; } // TEMP: This is always false for now, since cross-library drag // is disallowed in canDropCheck() // // TODO: support items coming from different sources? if (items[0].libraryID == targetLibraryID) { var sameLibrary = true; } else { var sameLibrary = false; } var toMove = []; // Dropped directly on a row if (orient == 0) { // Set drop target as the parent item for dragged items // // canDrop() limits this to child items var rowItem = this.getRow(row).ref; // the item we are dragging over yield Zotero.DB.executeTransaction(function* () { for (let i=0; i