/* ***** 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'], '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) { yield 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; 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; } // 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().done(); break; case 37: self.collapseSelectedRows().done(); break; } event.preventDefault(); } return; } var key = String.fromCharCode(event.which); if (key == '+' && !(event.ctrlKey || event.altKey || event.metaKey)) { self.expandAllRows().done(); event.preventDefault(); return; } else if (key == '-' && !(event.shiftKey || event.ctrlKey || event.altKey || event.metaKey)) { self.collapseAllRows().done(); event.preventDefault(); return; } // Ignore other non-character keypresses if (!event.charCode || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) { return; } event.preventDefault(); Zotero.Promise.try(function () { if (coloredTagsRE.test(key)) { let libraryID = self.collectionTreeRow.ref.libraryID; let position = parseInt(key) - 1; return Zotero.Tags.getColorByPosition(libraryID, position) .then(function (colorData) { // If a color isn't assigned to this number or any // other numbers, allow key navigation if (!colorData) { return Zotero.Tags.getColors(libraryID) .then(function (colors) { return !Object.keys(colors).length; }); } var items = self.getSelectedItems(); return Zotero.Tags.toggleItemsListTags(libraryID, items, colorData.name) .then(function () { return false; }); }); } return true; }) // We have to disable key navigation on the tree in order to // keep it from acting on the 1-6 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... .then(function (resend) { if (!resend) { return; } 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.debug(e, 1); Components.utils.reportError(e); }) .done(); }; // 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 () {}; yield this.sort(); yield this.expandMatchParents(); if (this._ownerDocument.defaultView.ZoteroPane_Local) { this._ownerDocument.defaultView.ZoteroPane_Local.clearItemsPaneMessage(); } // Select a queued item from selectItem() 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; } }); /** * 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); //if(!Zotero.ItemTreeView._haveCachedFields) yield Zotero.Promise.resolve(); var cacheFields = ['title', 'date']; // Cache the visible fields so they don't load individually try { var visibleFields = this.getVisibleFields(); } // 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 { for (let i=0; i 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') { // On a delete in duplicates mode, just refresh rather than figuring // out what to remove if (collectionTreeRow.isDuplicates()) { yield this.refresh(); refreshed = true; madeChanges = true; sort = true; } else { // Since a remove involves shifting of rows, we have to do it in order, // so sort the ids by row var rows = []; 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 (action == 'modify') { // Clear row caches var items = yield Zotero.Items.getAsync(ids); for (let i=0; i 0) { let previousItem = yield Zotero.Items.getAsync(ids[0]); if (previousItem && !previousItem.isTopLevelItem()) { if (this._rows[previousFirstRow] && this.getLevel(previousFirstRow) == 0) { previousFirstRow--; } } } if (previousFirstRow !== undefined && this._rows[previousFirstRow]) { this.selection.select(previousFirstRow); } // 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 { yield this.rememberSelection(savedSelection); } } 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); }*/ if (Zotero.suppressUIUpdates) { yield this.rememberSelection(savedSelection); } //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) { 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': 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) { var self = this; 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; if (treerow.level === 0) { if (item.isRegularItem()) { let state = item.getBestAttachmentStateCached(); if (state !== null) { switch (state) { case 1: return "chrome://zotero/skin/bullet_blue.png"; case -1: return "chrome://zotero/skin/bullet_blue_empty.png"; default: return ""; } } item.getBestAttachmentState() // Refresh cell when promise is fulfilled .then(function (state) { self._treebox.invalidateCell(row, col); }) .done(); } } if (item.isFileAttachment()) { let exists = item.fileExistsCached(); if (exists !== null) { return exists ? "chrome://zotero/skin/bullet_blue.png" : "chrome://zotero/skin/bullet_blue_empty.png"; } item.fileExists() // Refresh cell when promise is fulfilled .then(function (exists) { self._treebox.invalidateCell(row, col); }); } } } 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 = Zotero.Promise.coroutine(function* (row, skipItemMapRefresh) { // 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, skipItemMapRefresh); } var count = 0; var level = this.getLevel(row); // // Open // var item = this.getRow(row).ref; yield item.loadChildItems(); //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 = yield Zotero.Items.getAsync(newRows); for (let i = 0; i < newRows.length; i++) { count++; this._addRow( new Zotero.ItemTreeRow(newRows[i], level + 1, false), row + i + 1 ); } } this._rows[row].isOpen = true; if (count == 0) { return; } this._treebox.invalidateRow(row); if (!skipItemMapRefresh) { Zotero.debug('Refreshing hash map'); this._refreshItemRowMap(); } }); Zotero.ItemTreeView.prototype._closeContainer = function (row, skipItemMapRefresh) { // 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 (!skipItemMapRefresh) { 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) { for(var i=0, len=this._treebox.columns.count; i 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; } 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(); yield this.rememberOpenState(openItemIDs); yield this.rememberSelection(savedSelection); if (unsuppress) { this.selection.selectEventsSuppressed = false; //this._treebox.endUpdateBatch(); } 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) { // Don't change selection if UI updates are disabled (e.g., during sync) if (Zotero.suppressUIUpdates) { Zotero.debug("Sync is running; not selecting item"); return false; } // 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; } 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 = yield 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 yield 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)) { yield this.toggleOpenState(row); } this.selection.select(row); if (deferred) { yield deferred.promise; } // We aim for a row 5 below the target row, since ensureRowIsVisible() does // the bare minimum to get the row in view for (var 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 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 each(var id in 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; } /* * 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--) { yield this.toggleOpenState(rowsToOpen[i], true); } this._refreshItemRowMap(); if (unsuppress) { //this._treebox.endUpdateBatch(); this.selection.selectEventsSuppressed = false; } }); Zotero.ItemTreeView.prototype.expandMatchParents = Zotero.Promise.coroutine(function* () { // Expand parents of child matches if (!this._searchMode) { return; } var hash = {}; for (var id in this._searchParentIDs) { hash[id] = true; } 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() { 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; // More Columns menu try { 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 = [x.getAttribute('label') for (x of treecols.getElementsByAttribute('submenu', 'true'))]; var moreItems = []; for (let 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, 0755); } 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, 0644); var newName = copiedFile.leafName; copiedFile.remove(null); } } } parentDir.copyTo(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, 0644); var newName = copiedFile.leafName; copiedFile.remove(null); } } } file.copyTo(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 each(var name in 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 each(var item in 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 each(var item in 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