zotero/chrome/content/zotero/xpcom/itemTreeView.js
Dan Stillman 48580c49d1 Retraction Watch integration
- Check for retracted items using data from Retraction Watch
- Show an X next to retracted items in the items list, and show a
  scary message at the top of the item pane with more info and links.
- Lookup is done in a privacy-preserving manner using k-anonymity --
  the server is unable to determine the specific items that exist in
  the client, so people who don't sync don't need to share any library
  data (though the server doesn't log the lookups anyway).

TODO:

- Pop up an alert when new items are found
- Show a confirmation prompt when citing a retracted item
- Support items without DOIs or PMIDs
- Add a proper PMID field and expand DOI to more item types so these
  values don't need to be parsed out of Extra
- Clear the banner immediately when all possible fields are cleared
  instead of waiting a few seconds
2019-06-06 09:17:21 -04:00

3504 lines
98 KiB
JavaScript

/*
***** 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 <http://www.gnu.org/licenses/>.
***** 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) {
Zotero.LibraryTreeView.apply(this);
this.wrappedJSObject = this;
this.rowCount = 0;
this.collectionTreeRow = collectionTreeRow;
collectionTreeRow.view.itemTreeView = this;
this._skipKeypress = false;
this._ownerDocument = null;
this._needsSort = false;
this._introText = null;
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';
Zotero.ItemTreeView.prototype.regularOnly = false;
Zotero.ItemTreeView.prototype.expandAll = false;
Zotero.ItemTreeView.prototype.collapseAll = false;
Object.defineProperty(Zotero.ItemTreeView.prototype, 'window', {
get: function () {
return this._ownerDocument.defaultView;
},
enumerable: true
});
/**
* Called by the tree itself
*/
Zotero.ItemTreeView.prototype.setTree = async function (treebox) {
try {
if (this._treebox) {
if (this._needsSort) {
this.sort();
}
return;
}
var start = Date.now();
Zotero.debug("Setting tree for " + this.collectionTreeRow.id + " items view " + this.id);
if (!treebox) {
Zotero.debug("Treebox not passed in setTree()", 2);
return;
}
this._treebox = treebox;
if (!this._ownerDocument) {
try {
this._ownerDocument = treebox.treeBody.ownerDocument;
}
catch (e) {}
if (!this._ownerDocument) {
Zotero.debug("No owner document in setTree()", 2);
return;
}
}
this.setSortColumn();
if (this.window.ZoteroPane) {
this.window.ZoteroPane.setItemsPaneMessage(Zotero.getString('pane.items.loading'));
}
if (Zotero.locked) {
Zotero.debug("Zotero is locked -- not loading items tree", 2);
if (this.window.ZoteroPane) {
this.window.ZoteroPane.clearItemsPaneMessage();
}
return;
}
// Don't expand to show search matches in My Publications
var skipExpandMatchParents = this.collectionTreeRow.isPublications();
await this.refresh(skipExpandMatchParents);
if (!this._treebox.treeBody) {
return;
}
// Expand all parent items in the view, regardless of search matches. We do this here instead
// of refresh so that it doesn't get reverted after item changes.
if (this.expandAll) {
var t = new Date();
for (let i = 0; i < this._rows.length; i++) {
if (this.isContainer(i) && !this.isContainerOpen(i)) {
this.toggleOpenState(i, true);
}
}
Zotero.debug(`Opened all parent items in ${new Date() - t} ms`);
}
this._refreshItemRowMap();
// Add a keypress listener for expand/collapse
var tree = this._getTreeElement();
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 == event.DOM_VK_RIGHT || event.keyCode == event.DOM_VK_LEFT) {
if (self._treebox.view.selection.count > 1) {
switch (event.keyCode) {
case event.DOM_VK_RIGHT:
self.expandSelectedRows();
break;
case event.DOM_VK_LEFT:
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();
event.stopPropagation();
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._updateIntroText();
if (this.collectionTreeRow && this.collectionTreeRow.itemsToSelect) {
await this.selectItems(this.collectionTreeRow.itemsToSelect);
this.collectionTreeRow.itemsToSelect = null;
}
Zotero.debug("Set tree for items view " + this.id + " in " + (Date.now() - start) + " ms");
this._initialized = true;
await 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<len; i++) {
let column = this._treebox.columns.getColumnAt(i);
if (column.element.getAttribute('sortActive')) {
currentCol = column;
currentDir = column.element.getAttribute('sortDirection');
column.element.removeAttribute('sortActive');
column.element.removeAttribute('sortDirection');
break;
}
}
let colID = Zotero.Prefs.get('itemTree.sortColumnID');
// Restore previous sort setting (feed -> 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* (skipExpandMatchParents) {
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();
// Get the full set of items we want to show
let newSearchItems = yield this.collectionTreeRow.getItems();
// Remove notes and attachments if necessary
if (this.regularOnly) {
newSearchItems = newSearchItems.filter(item => item.isRegularItem());
}
let newSearchItemIDs = new Set(newSearchItems.map(item => item.id));
// Find the items that aren't yet in the tree
let itemsToAdd = newSearchItems.filter(item => this._rowMap[item.id] === undefined);
// Find the parents of search matches
let newSearchParentIDs = new Set(
this.regularOnly
? []
: newSearchItems.filter(item => !!item.parentItemID).map(item => item.parentItemID)
);
newSearchItems = new Set(newSearchItems);
if (!this.selection.selectEventsSuppressed) {
var unsuppress = this.selection.selectEventsSuppressed = true;
this._treebox.beginUpdateBatch();
}
var savedSelection = this.getSelectedItems(true);
var oldCount = this.rowCount;
var newCellTextCache = {};
var newSearchMode = this.collectionTreeRow.isSearchMode();
var newRows = [];
var allItemIDs = new Set();
var addedItemIDs = new Set();
// Copy old rows to new array, omitting top-level items not in the new set and their children
//
// This doesn't add new child items to open parents or remove child items that no longer exist,
// which is done by toggling all open containers below.
var skipChildren;
for (let i = 0; i < this._rows.length; i++) {
let row = this._rows[i];
// Top-level items
if (row.level == 0) {
let isSearchParent = newSearchParentIDs.has(row.ref.id);
// If not showing children or no children match the search, close
if (this.regularOnly || !isSearchParent) {
row.isOpen = false;
skipChildren = true;
}
else {
skipChildren = false;
}
// Skip items that don't match the search and don't have children that do
if (!newSearchItems.has(row.ref) && !isSearchParent) {
continue;
}
}
// Child items
else if (skipChildren) {
continue;
}
newRows.push(row);
allItemIDs.add(row.ref.id);
}
// Add new items
for (let i = 0; i < itemsToAdd.length; i++) {
let item = itemsToAdd[i];
// If child item matches search and parent hasn't yet been added, add parent
let parentItemID = item.parentItemID;
if (parentItemID) {
if (allItemIDs.has(parentItemID)) {
continue;
}
item = Zotero.Items.get(parentItemID);
}
// Parent item may have already been added from child
else if (allItemIDs.has(item.id)) {
continue;
}
// Add new top-level items
let row = new Zotero.ItemTreeRow(item, 0, false);
newRows.push(row);
allItemIDs.add(item.id);
addedItemIDs.add(item.id);
}
this._rows = newRows;
this.rowCount = this._rows.length;
this._refreshItemRowMap();
// Sort only the new items
//
// This still results in a lot of extra work (e.g., when clearing a quick search, we have to
// re-sort all items that didn't match the search), so as a further optimization we could keep
// a sorted list of items for a given column configuration and restore items from that.
this.sort([...addedItemIDs]);
var diff = this.rowCount - oldCount;
if (diff != 0) {
this._treebox.rowCountChanged(0, diff);
}
// Toggle all open containers closed and open to refresh child items
//
// This could be avoided by making sure that items in notify() that aren't present are always
// added.
var t = new Date();
for (let i = 0; i < this._rows.length; i++) {
if (this.isContainer(i) && this.isContainerOpen(i)) {
this.toggleOpenState(i, true);
this.toggleOpenState(i, true);
}
}
Zotero.debug(`Refreshed open parents in ${new Date() - t} ms`);
this._refreshItemRowMap();
this._searchMode = newSearchMode;
this._searchItemIDs = newSearchItemIDs; // items matching the search
this._cellTextCache = {};
this.rememberSelection(savedSelection);
if (!skipExpandMatchParents) {
this.expandMatchParents(newSearchParentIDs);
}
if (unsuppress) {
this._treebox.endUpdateBatch();
this.selection.selectEventsSuppressed = false;
}
// Clear My Publications intro text on a refresh with items
if (this.collectionTreeRow.isPublications() && this.rowCount) {
this.window.ZoteroPane.clearItemsPaneMessage();
}
yield this.runListeners('refresh');
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();
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<ids.length; i++) {
this._treebox.invalidateRow(this._rowMap[ids[i]]);
}
}
var madeChanges = false;
var refreshed = false;
var sort = false;
var savedSelection = this.getSelectedItems(true);
var previousFirstSelectedRow = this._rowMap[
// 'collection-item' ids are in the form <collectionID>-<itemID>
// 'item' events are just integers
type == 'collection-item' ? ids[0].split('-')[1] : ids[0]
];
// If there's not at least one new item to be selected, get a scroll position to restore later
var scrollPosition = false;
if (action != 'add' || ids.every(id => 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;
}
}
// If refreshing a single item, clear caches and then deselect and reselect row
else if (savedSelection.length == 1 && savedSelection[0] == ids[0]) {
let id = ids[0];
let row = this._rowMap[id];
delete this._cellTextCache[row];
delete this._itemImages[id];
this._treebox.invalidateRow(row);
this.selection.clearSelection();
this.rememberSelection(savedSelection);
}
else {
for (let id of ids) {
let row = this._rowMap[id];
if (row === undefined) continue;
delete this._cellTextCache[row];
delete this._itemImages[id];
this._treebox.invalidateRow(row);
}
}
// For a refresh on an item in the trash, check if the item still belongs
if (type == 'item' && collectionTreeRow.isTrash()) {
let rows = [];
for (let id of ids) {
let row = this.getRowIndexByID(id);
if (row === false) continue;
let item = Zotero.Items.get(id);
if (!item.deleted && !item.numChildren()) {
rows.push(row);
}
}
this._removeRows(rows);
}
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');
var hasQuickSearch = quickSearch && quickSearch.value != '';
// '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<len; i++) {
if (!push) {
push = !collectionTreeRow.ref.hasItem(ids[i]);
}
// Row might already be gone (e.g. if this is a child and
// 'modify' was sent to parent)
let row = this._rowMap[ids[i]];
if (push && row !== undefined) {
// Don't remove child items from collections, because it's handled by 'modify'
if (action == 'remove' && this.getParentIndex(row) != -1) {
continue;
}
rows.push(row);
// Remove child items of removed parents
if (this.isContainer(row) && this.isContainerOpen(row)) {
while (++row < this.rowCount && this.getLevel(row) > 0) {
rows.push(row);
}
}
}
}
if (rows.length > 0) {
this._removeRows(rows);
madeChanges = true;
}
}
else if (type == 'item' && action == 'modify')
{
// Clear row caches
var items = Zotero.Items.get(ids);
for (let i=0; i<items.length; i++) {
let id = items[i].id;
delete this._itemImages[id];
delete this._cellTextCache[id];
}
// If saved search, publications, or trash, just re-run search
if (collectionTreeRow.isSearch()
|| collectionTreeRow.isPublications()
|| collectionTreeRow.isTrash()
|| hasQuickSearch) {
let skipExpandMatchParents = collectionTreeRow.isPublications();
yield this.refresh(skipExpandMatchParents);
refreshed = true;
madeChanges = true;
// Don't bother re-sorting in trash, since it's probably just a modification of a parent
// item that's about to be deleted
if (!collectionTreeRow.isTrash()) {
sort = true;
}
}
else if (collectionTreeRow.isFeed()) {
this.window.ZoteroPane.updateReadLabel();
}
// If not a search, process modifications manually
else {
var items = Zotero.Items.get(ids);
for (let i = 0; i < items.length; i++) {
let item = items[i];
let id = item.id;
let row = this._rowMap[id];
// Deleted items get a modify that we have to ignore when
// not viewing the trash
if (item.deleted) {
continue;
}
// Item already exists in this view
if (row !== undefined) {
let parentItemID = this.getRow(row).ref.parentItemID;
let parentIndex = this.getParentIndex(row);
// Top-level item
if (this.isContainer(row)) {
// If Unfiled Items and itm was added to a collection, remove from view
if (collectionTreeRow.isUnfiled() && item.getCollections().length) {
this._removeRow(row);
}
// Otherwise just resort
else {
sort = id;
}
}
// If item moved from top-level to under another item, remove the old row.
else if (parentIndex == -1 && parentItemID) {
this._removeRow(row);
}
// If moved from under another item to top level, remove old row and add new one
else if (parentIndex != -1 && !parentItemID) {
this._removeRow(row);
let beforeRow = this.rowCount;
this._addRow(new Zotero.ItemTreeRow(item, 0, false), beforeRow);
sort = id;
}
// If item was moved from one parent to another, remove from old parent
else if (parentItemID && parentIndex != -1 && this._rowMap[parentItemID] != parentIndex) {
this._removeRow(row);
}
// If not moved from under one item to another, just resort the row,
// which also invalidates it and refreshes it
else {
sort = id;
}
madeChanges = true;
}
// Otherwise, for a top-level item in a library root or a collection
// containing the item, the item has to be added
else if (item.isTopLevelItem()) {
// Root view
let add = collectionTreeRow.isLibrary(true)
&& collectionTreeRow.ref.libraryID == item.libraryID;
// Collection containing item
if (!add && collectionTreeRow.isCollection()) {
add = item.inCollection(collectionTreeRow.ref.id);
}
if (add) {
//most likely, the note or attachment's parent was removed.
let beforeRow = this.rowCount;
this._addRow(new Zotero.ItemTreeRow(item, 0, false), beforeRow);
madeChanges = true;
sort = id;
}
}
}
if (sort && ids.length != 1) {
sort = true;
}
}
}
else if(type == 'item' && action == 'add')
{
let items = Zotero.Items.get(ids);
// In some modes, just re-run search
if (collectionTreeRow.isSearch()
|| collectionTreeRow.isPublications()
|| collectionTreeRow.isTrash()
|| collectionTreeRow.isUnfiled()
|| hasQuickSearch) {
if (hasQuickSearch) {
// For item adds, clear the quick search, unless all the new items have
// skipSelect or are child items
if (activeWindow && type == 'item') {
let clear = false;
for (let i=0; i<items.length; i++) {
if (!extraData[items[i].id].skipSelect && items[i].isTopLevelItem()) {
clear = true;
break;
}
}
if (clear) {
quickSearch.value = '';
collectionTreeRow.setSearch('');
}
}
}
yield this.refresh();
refreshed = true;
madeChanges = true;
sort = true;
}
// Otherwise process new items manually
else {
for (let i=0; i<items.length; i++) {
let item = items[i];
// if the item belongs in this collection
if (((collectionTreeRow.isLibrary(true)
&& collectionTreeRow.ref.libraryID == item.libraryID)
|| (collectionTreeRow.isCollection() && item.inCollection(collectionTreeRow.ref.id)))
// if we haven't already added it to our hash map
&& this._rowMap[item.id] == null
// Regular item or standalone note/attachment
&& item.isTopLevelItem()) {
let beforeRow = this.rowCount;
this._addRow(new Zotero.ItemTreeRow(item, 0, false), beforeRow);
madeChanges = true;
}
}
if (madeChanges) {
sort = (items.length == 1) ? items[0].id : true;
}
}
}
var reselect = false;
if(madeChanges)
{
// If we made individual changes, we have to clear the cache
if (!refreshed) {
Zotero.CollectionTreeCache.clear();
}
var singleSelect = false;
// If adding a single top-level item and this is the active window, select it
if (action == 'add' && activeWindow) {
if (ids.length == 1) {
singleSelect = ids[0];
}
// If there's only one parent item in the set of added items,
// mark that for selection in the UI
//
// Only bother checking for single parent item if 1-5 total items,
// since a translator is unlikely to save more than 4 child items
else if (ids.length <= 5) {
var items = Zotero.Items.get(ids);
if (items) {
var found = false;
for (let item of items) {
// Check for note and attachment type, since it's quicker
// than checking for parent item
if (item.itemTypeID == 1 || item.itemTypeID == 14) {
continue;
}
// We already found a top-level item, so cancel the
// single selection
if (found) {
singleSelect = false;
break;
}
found = true;
singleSelect = item.id;
}
}
}
}
if (sort) {
this.sort(typeof sort == 'number' ? [sort] : false);
}
else {
this._refreshItemRowMap();
}
if (singleSelect) {
if (!extraData[singleSelect] || !extraData[singleSelect].skipSelect) {
// Reset to Info tab
this._ownerDocument.getElementById('zotero-view-tabbox').selectedIndex = 0;
yield this.selectItem(singleSelect);
reselect = true;
}
}
// If single item is selected and was modified
else if (action == 'modify' && ids.length == 1 &&
savedSelection.length == 1 && savedSelection[0] == ids[0]) {
if (activeWindow) {
yield this.selectItem(ids[0]);
reselect = true;
}
else {
this.rememberSelection(savedSelection);
reselect = true;
}
}
// On removal of a selected row, select item at previous position
else if (savedSelection.length) {
if ((action == 'remove' || action == 'trash' || action == 'delete')
&& savedSelection.some(id => this.getRowIndexByID(id) === false)) {
// In duplicates view, select the next set on delete
if (collectionTreeRow.isDuplicates()) {
if (this._rows[previousFirstSelectedRow]) {
// Mirror ZoteroPane.onTreeMouseDown behavior
var itemID = this._rows[previousFirstSelectedRow].ref.id;
var setItemIDs = collectionTreeRow.ref.getSetItemsByItemID(itemID);
this.selectItems(setItemIDs);
reselect = true;
}
}
else {
// If this was a child item and the next item at this
// position is a top-level item, move selection one row
// up to select a sibling or parent
if (ids.length == 1 && previousFirstSelectedRow > 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);
reselect = true;
}
// 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);
reselect = true;
}
}
}
else {
this.rememberSelection(savedSelection);
reselect = true;
}
}
this._rememberScrollPosition(scrollPosition);
}
// 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._updateIntroText();
this._treebox.endUpdateBatch();
// If we made changes to the selection (including reselecting the same item, which will register as
// a selection when selectEventsSuppressed is set to false), wait for a select event on the tree
// view (e.g., as triggered by itemsView.runListeners('select') in ZoteroPane::itemSelected())
// before returning. This guarantees that changes are reflected in the middle and right-hand panes
// before returning from the save transaction.
//
// If no onselect handler is set on the tree element, as is the case in the Advanced Search window,
// the select listeners never get called, so don't wait.
let selectPromise;
var tree = this._getTreeElement();
var hasOnSelectHandler = tree.getAttribute('onselect') != '';
if (reselect && hasOnSelectHandler) {
selectPromise = this.waitForSelect();
this.selection.selectEventsSuppressed = false;
Zotero.debug("Yielding for select promise"); // TEMP
return selectPromise;
}
else {
this.selection.selectEventsSuppressed = false;
}
});
Zotero.ItemTreeView.prototype.unregister = async function() {
Zotero.Notifier.unregisterObserver(this._unregisterID);
if (this.collectionTreeRow.onUnload) {
await this.collectionTreeRow.onUnload();
}
if (this.listener) {
if (!this._treebox.treeBody) {
Zotero.debug("No more tree body in Zotero.ItemTreeView::unregister()");
this.listener = null;
return;
}
let tree = this._getTreeElement();
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();
if (!val) {
val = '';
}
}
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) {
let date = Zotero.Date.sqlToDate(val, true);
if (date) {
// If no time, interpret as local, not UTC
if (Zotero.Date.isSQLDate(val)) {
date = Zotero.Date.sqlToDate(val);
val = date.toLocaleDateString();
}
else {
val = date.toLocaleString();
}
}
else {
val = '';
}
}
}
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.regularOnly) {
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 item row 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 item row 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<len; i++)
{
col = this._treebox.columns.getColumnAt(i);
if(column != col)
{
col.element.removeAttribute('sortActive');
col.element.removeAttribute('sortDirection');
}
else
{
// If not yet selected, start with ascending
if (!col.element.getAttribute('sortActive')) {
col.element.setAttribute('sortDirection', 'ascending');
}
else {
col.element.setAttribute('sortDirection', col.element.getAttribute('sortDirection') == 'descending' ? 'ascending' : 'descending');
}
col.element.setAttribute('sortActive', true);
}
}
this.selection.selectEventsSuppressed = true;
var savedSelection = this.getSelectedItems(true);
if (savedSelection.length == 1) {
var pos = this._rowMap[savedSelection[0]] - this._treebox.getFirstVisibleRow();
}
this.sort();
this.rememberSelection(savedSelection);
// If single row was selected, try to keep it in the same place
if (savedSelection.length == 1) {
var newRow = this._rowMap[savedSelection[0]];
// Calculate the last row that would give us a full view
var fullTop = Math.max(0, this._rows.length - this._treebox.getPageLength());
// Calculate the row that would give us the same position
var consistentTop = Math.max(0, newRow - pos);
this._treebox.scrollToRow(Math.min(fullTop, consistentTop));
}
this._treebox.invalidate();
this.selection.selectEventsSuppressed = false;
});
/*
* Sort the items by the currently sorted column.
*/
Zotero.ItemTreeView.prototype.sort = function (itemIDs) {
var t = new Date;
// If Zotero pane is hidden, mark tree for sorting later in setTree()
if (!this._treebox.columns) {
this._needsSort = true;
return;
}
this._needsSort = false;
// For child items, just close and reopen parents
if (itemIDs) {
let parentItemIDs = new Set();
let skipped = [];
for (let itemID of itemIDs) {
let row = this._rowMap[itemID];
let item = this.getRow(row).ref;
let parentItemID = item.parentItemID;
if (!parentItemID) {
skipped.push(itemID);
continue;
}
parentItemIDs.add(parentItemID);
}
let parentRows = [...parentItemIDs].map(itemID => this._rowMap[itemID]);
parentRows.sort();
for (let i = parentRows.length - 1; i >= 0; i--) {
let row = parentRows[i];
this._closeContainer(row, true);
this.toggleOpenState(row, true);
}
this._refreshItemRowMap();
let numSorted = itemIDs.length - skipped.length;
if (numSorted) {
Zotero.debug(`Sorted ${numSorted} child items by parent toggle`);
}
if (!skipped.length) {
return;
}
itemIDs = skipped;
if (numSorted) {
Zotero.debug(`${itemIDs.length} items left to sort`);
}
}
var primaryField = this.getSortField();
var sortFields = this.getSortFields();
var dir = this.getSortDirection();
var order = dir == 'descending' ? -1 : 1;
var collation = Zotero.getLocaleCollation();
var sortCreatorAsString = Zotero.Prefs.get('sortCreatorAsString');
Zotero.debug(`Sorting items list by ${sortFields.join(", ")} ${dir} `
+ (itemIDs && itemIDs.length
? `for ${itemIDs.length} ` + Zotero.Utilities.pluralize(itemIDs.length, ['item', 'items'])
: ""));
// Set whether rows with empty values should be displayed last,
// which may be different for primary and secondary sorting.
var emptyFirst = {};
switch (primaryField) {
case 'title':
emptyFirst.title = true;
break;
// When sorting by title we want empty titles at the top, but if not
// sorting by title, empty titles should sort to the bottom so that new
// empty items don't get sorted to the middle of the items list.
default:
emptyFirst.title = false;
}
// Cache primary values while sorting, since base-field-mapped getField()
// calls are relatively expensive
var cache = {};
sortFields.forEach(x => 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) {
for (let i = 0; i < sortFields.length; i++) {
let cmp = fieldCompare(a, b, sortFields[i]);
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);
// Sort specific items
if (itemIDs) {
let idsToSort = new Set(itemIDs);
this._rows.sort((a, b) => {
// Don't re-sort existing items. This assumes a stable sort(), which is the case in Firefox
// but not Chrome/v8.
if (!idsToSort.has(a.ref.id) && !idsToSort.has(b.ref.id)) return 0;
return rowSort(a, b) * order;
});
}
// Full sort
else {
this._rows.sort((a, b) => rowSort(a, b) * order);
}
this._refreshItemRowMap();
this.rememberOpenState(openItemIDs);
this.rememberSelection(savedSelection);
if (unsuppress) {
this._treebox.endUpdateBatch();
this.selection.selectEventsSuppressed = false;
}
this._treebox.invalidate();
var numSorted = itemIDs ? itemIDs.length : this._rows.length;
Zotero.debug(`Sorted ${numSorted} ${Zotero.Utilities.pluralize(numSorted, ['item', 'items'])} `
+ `in ${new Date - t} ms`);
};
/**
* Show intro text in middle pane for some views when no items
*/
Zotero.ItemTreeView.prototype._updateIntroText = function() {
if (!this.window.ZoteroPane) {
return;
}
if (this.collectionTreeRow && !this.rowCount) {
let doc = this._ownerDocument;
let ns = 'http://www.w3.org/1999/xhtml'
let div;
// My Library and no groups
if (this.collectionTreeRow.isLibrary() && !Zotero.Groups.getAll().length) {
div = doc.createElementNS(ns, 'div');
let p = doc.createElementNS(ns, 'p');
let html = Zotero.getString(
'pane.items.intro.text1',
[
Zotero.clientName
]
);
// Encode special chars, which shouldn't exist
html = Zotero.Utilities.htmlSpecialChars(html);
html = `<b>${html}</b>`;
p.innerHTML = html;
div.appendChild(p);
p = doc.createElementNS(ns, 'p');
html = Zotero.getString(
'pane.items.intro.text2',
[
Zotero.getString('connector.name', Zotero.clientName),
Zotero.clientName
]
);
// Encode special chars, which shouldn't exist
html = Zotero.Utilities.htmlSpecialChars(html);
html = html.replace(
/\[([^\]]+)](.+)\[([^\]]+)]/,
`<span class="text-link" data-href="${ZOTERO_CONFIG.QUICK_START_URL}">$1</span>`
+ '$2'
+ `<span class="text-link" data-href="${ZOTERO_CONFIG.CONNECTORS_URL}">$3</span>`
);
p.innerHTML = html;
div.appendChild(p);
p = doc.createElementNS(ns, 'p');
html = Zotero.getString('pane.items.intro.text3', [Zotero.clientName]);
// Encode special chars, which shouldn't exist
html = Zotero.Utilities.htmlSpecialChars(html);
html = html.replace(
/\[([^\]]+)]/,
'<span class="text-link" '
+ `onclick="Zotero.Utilities.Internal.openPreferences('zotero-prefpane-sync')">$1</span>`
);
p.innerHTML = html;
div.appendChild(p);
// Activate text links
for (let span of div.getElementsByTagName('span')) {
if (span.classList.contains('text-link') && !span.hasAttribute('onclick')) {
span.onclick = function () {
doc.defaultView.ZoteroPane.loadURI(this.getAttribute('data-href'));
};
}
}
div.setAttribute('allowdrop', true);
}
// My Publications
else if (this.collectionTreeRow.isPublications()) {
div = doc.createElementNS(ns, 'div');
div.className = 'publications';
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 <b> tags to placeholders
html = html.replace('<b>', ':b:').replace('</b>', ':/b:');
// Encode any other special chars, which shouldn't exist
html = Zotero.Utilities.htmlSpecialChars(html);
// Restore bold text
html = html.replace(':b:', '<strong>').replace(':/b:', '</strong>');
p.innerHTML = html; // AMO note: markup from hard-coded strings and filtered above
div.appendChild(p);
}
if (div) {
this._introText = true;
doc.defaultView.ZoteroPane_Local.setItemsPaneMessage(div);
return;
}
this._introText = null;
}
if (this._introText || this._introText === null) {
this.window.ZoteroPane.clearItemsPaneMessage();
this._introText = false;
}
};
////////////////////////////////////////////////////////////////////////////////
///
/// Additional functions for managing data in the tree
///
////////////////////////////////////////////////////////////////////////////////
/*
* Select an item
*/
Zotero.ItemTreeView.prototype.selectItem = async function (id) {
return this.selectItems([id]);
};
Zotero.ItemTreeView.prototype.selectItems = async function (ids, noRecurse) {
if (!ids.length) return 0;
// If no row map, we're probably in the process of switching collections,
// so store the items to select on the item group for later
if (!this._rowMap) {
if (this.collectionTreeRow) {
this.collectionTreeRow.itemsToSelect = ids;
Zotero.debug("_rowMap not yet set; not selecting items");
return 0;
}
Zotero.debug('Item group not found and no row map in ItemTreeView.selectItem() -- discarding select', 2);
return 0;
}
var idsToSelect = [];
for (let id of ids) {
let row = this._rowMap[id];
let item = Zotero.Items.get(id);
// Can't select a deleted item if we're not in the trash
if (item.deleted && !this.collectionTreeRow.isTrash()) {
continue;
}
// Get the row of the parent, if there is one
let parent = item.parentItemID;
let parentRow = parent && this._rowMap[parent];
// If row with id isn't visible, check to see if it's hidden under a parent
if (row == undefined) {
if (!parent || parentRow === undefined) {
// No parent -- it's not here
// Clear the quick search and tag selection and try again (once)
if (!noRecurse && this.window.ZoteroPane) {
let cleared1 = await this.window.ZoteroPane.clearQuicksearch();
let cleared2 = this.window.ZoteroPane.tagSelector
&& this.window.ZoteroPane.tagSelector.clearTagSelection();
if (cleared1 || cleared2) {
return this.selectItems(ids, true);
}
}
Zotero.debug(`Couldn't find row for item ${id} -- not selecting`);
continue;
}
// 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);
}
// Since we're opening containers, we still need to reference by id
idsToSelect.push(id);
}
// Now that all items have been expanded, get associated rows
var rowsToSelect = [];
for (let id of idsToSelect) {
let row = this._rowMap[id];
rowsToSelect.push(row);
}
// If items are already selected, just scroll to the top-most one
var selectedRows = new Set(this.getSelectedRowIndexes());
if (rowsToSelect.length == selectedRows.size && rowsToSelect.every(row => selectedRows.has(row))) {
this.ensureRowsAreVisible(rowsToSelect);
return rowsToSelect.length;
}
// Single item
if (rowsToSelect.length == 1) {
// this.selection.select() triggers the <tree>'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.
let promise;
try {
if (!this.selection.selectEventsSuppressed) {
promise = this.waitForSelect();
}
this.selection.select(rowsToSelect[0]);
}
// Ignore NS_ERROR_UNEXPECTED from nsITreeSelection::select(), apparently when the tree
// disappears before it's called (though I can't reproduce it):
//
// https://forums.zotero.org/discussion/comment/297039/#Comment_297039
catch (e) {
Zotero.logError(e);
}
if (promise) {
await promise;
}
}
// Multiple items
else {
this.selection.clearSelection();
this.selection.selectEventsSuppressed = true;
var lastStart = 0;
for (let i = 0, len = rowsToSelect.length; i < len; i++) {
if (i == len - 1 || rowsToSelect[i + 1] != rowsToSelect[i] + 1) {
this.selection.rangedSelect(rowsToSelect[lastStart], rowsToSelect[i], true);
lastStart = i + 1;
}
}
this.selection.selectEventsSuppressed = false;
}
this.ensureRowsAreVisible(rowsToSelect);
return rowsToSelect.length;
};
Zotero.ItemTreeView.prototype.ensureRowsAreVisible = function (rows) {
const firstVisibleRow = this._treebox.getFirstVisibleRow();
const pageLength = this._treebox.getPageLength();
const lastVisibleRow = firstVisibleRow + pageLength;
const maxBuffer = 5;
var isRowVisible = function (row) {
return row >= firstVisibleRow && row <= lastVisibleRow;
};
rows = rows.concat();
rows.sort((a, b) => a - b);
var rowsWithParents = [];
for (let row of rows) {
let parent = this.getParentIndex(row);
rowsWithParents.push(parent != -1 ? parent : row);
}
// If all rows are visible, don't change anything
if (rows.every(row => isRowVisible(row))) {
//Zotero.debug("All rows are visible");
return;
}
// If we can fit all parent rows in view, do that
for (let buffer = maxBuffer; buffer >= 0; buffer--) {
if (rowsWithParents[rowsWithParents.length - 1] - rowsWithParents[0] - buffer < pageLength) {
//Zotero.debug(`We can fit all parent rows with buffer ${buffer}`);
this._treebox.scrollToRow(rowsWithParents[0] - buffer);
return;
}
}
// If we can fit all rows in view, do that
for (let buffer = maxBuffer; buffer >= 0; buffer--) {
if (rows[rows.length - 1] - rows[0] - buffer < pageLength) {
//Zotero.debug(`We can fit all rows with buffer ${buffer}`);
this._treebox.scrollToRow(rows[0] - buffer);
return;
}
}
// If more than half of the rows are visible, don't change anything
var visible = 0;
for (let row of rows) {
if (isRowVisible(row)) {
visible++;
}
}
if (visible > rows / 2) {
//Zotero.debug("More than half of rows are visible");
return;
}
// If the first parent row isn't in view and we have enough room, make it visible, trying to
// put it five rows from the top
if (rows[0] != rowsWithParents[0]) {
for (let buffer = maxBuffer; buffer >= 0; buffer--) {
if (rows[0] - rowsWithParents[0] - buffer <= pageLength) {
//Zotero.debug(`Scrolling to first parent minus ${buffer}`);
this._treebox.scrollToRow(rowsWithParents[0] - buffer);
return;
}
}
}
// Otherwise just put the first row at the top
//Zotero.debug("Scrolling to first row " + Math.max(rows[0] - maxBuffer, 0));
this._treebox.scrollToRow(Math.max(rows[0] - maxBuffer, 0));
};
/*
* 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<len; i++)
{
this.selection.getRangeAt(i,start,end);
for (var j=start.value; j<=end.value; j++) {
let row = this.getRow(j);
if (!row) {
Zotero.logError(`Row ${j} not found`);
continue;
}
items.push(asIDs ? row.id : row.ref);
}
}
return items;
}
/**
* Delete the selection
*
* @param {Boolean} [force=false] Delete item even if removing from a collection
*/
Zotero.ItemTreeView.prototype.deleteSelection = Zotero.Promise.coroutine(function* (force)
{
if (arguments.length > 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<this.rowCount; i++) {
if (this.selection.isSelected(i) && this.isContainer(i)) {
this._closeContainer(i, true);
}
}
this._refreshItemRowMap();
// Create an array of selected items
var ids = [];
var start = {};
var end = {};
for (var i=0, len=this.selection.getRangeCount(); i<len; i++)
{
this.selection.getRangeAt(i,start,end);
for (var j=start.value; j<=end.value; j++)
ids.push(this.getRow(j).id);
}
var collectionTreeRow = this.collectionTreeRow;
if (collectionTreeRow.isBucket()) {
collectionTreeRow.ref.deleteItems(ids);
}
if (collectionTreeRow.isTrash()) {
yield Zotero.Items.erase(ids);
}
else if (collectionTreeRow.isLibrary(true) || force) {
yield Zotero.Items.trashTx(ids);
}
else if (collectionTreeRow.isCollection()) {
yield Zotero.DB.executeTransaction(function* () {
yield collectionTreeRow.ref.removeItems(ids);
});
}
else if (collectionTreeRow.isPublications()) {
yield Zotero.Items.removeFromPublications(ids.map(id => Zotero.Items.get(id)));
}
//this._treebox.endUpdateBatch();
});
/*
* Set the search/tags filter on the view
*/
Zotero.ItemTreeView.prototype.setFilter = Zotero.Promise.coroutine(function* (type, data) {
if (!this._treebox || !this._treebox.treeBody) {
Components.utils.reportError("Treebox didn't exist in itemTreeView.setFilter()");
return;
}
this.selection.selectEventsSuppressed = true;
//this._treebox.beginUpdateBatch();
switch (type) {
case 'search':
this.collectionTreeRow.setSearch(data);
break;
case 'tags':
this.collectionTreeRow.setTags(data);
break;
default:
throw ('Invalid filter type in setFilter');
}
var oldCount = this.rowCount;
yield this.refresh();
//this._treebox.endUpdateBatch();
this.selection.selectEventsSuppressed = false;
});
/*
* Create map of item ids to row indexes
*/
Zotero.ItemTreeView.prototype._refreshItemRowMap = function()
{
var rowMap = {};
for (var i=0, len=this.rowCount; i<len; i++) {
let row = this.getRow(i);
let id = row.ref.id;
if (rowMap[id] !== undefined) {
Zotero.debug(`WARNING: Item row ${rowMap[id]} already found for item ${id} at ${i}`, 2);
Zotero.debug(new Error().stack, 2);
}
rowMap[id] = i;
}
this._rowMap = rowMap;
}
Zotero.ItemTreeView.prototype.saveSelection = function () {
Zotero.debug("Zotero.ItemTreeView::saveSelection() is deprecated -- use getSelectedItems(true)");
return this.getSelectedItems(true);
}
/*
* Sets the selection based on saved selection ids
*/
Zotero.ItemTreeView.prototype.rememberSelection = function (selection) {
if (!selection.length) {
return;
}
this.selection.clearSelection();
if (!this.selection.selectEventsSuppressed) {
var unsuppress = this.selection.selectEventsSuppressed = true;
this._treebox.beginUpdateBatch();
}
try {
for (let i = 0; i < selection.length; i++) {
if (this._rowMap[selection[i]] != null) {
this.selection.toggleSelect(this._rowMap[selection[i]]);
}
// Try the parent
else {
var item = Zotero.Items.get(selection[i]);
if (!item) {
continue;
}
var parent = item.parentItemID;
if (!parent) {
continue;
}
if (this._rowMap[parent] != null) {
this._closeContainer(this._rowMap[parent]);
this.toggleOpenState(this._rowMap[parent]);
this.selection.toggleSelect(this._rowMap[selection[i]]);
}
}
}
}
// Ignore NS_ERROR_UNEXPECTED from nsITreeSelection::toggleSelect(), apparently when the tree
// disappears before it's called (though I can't reproduce it):
//
// https://forums.zotero.org/discussion/69226/papers-become-invisible-in-the-middle-pane
catch (e) {
Zotero.logError(e);
}
if (unsuppress) {
this._treebox.endUpdateBatch();
this.selection.selectEventsSuppressed = false;
}
}
Zotero.ItemTreeView.prototype.selectSearchMatches = function () {
if (this._searchMode) {
this.rememberSelection(Array.from(this._searchItemIDs));
}
else {
this.selection.clearSelection();
}
}
Zotero.ItemTreeView.prototype._saveOpenState = function (close) {
var itemIDs = [];
if (close) {
if (!this.selection.selectEventsSuppressed) {
var unsuppress = this.selection.selectEventsSuppressed = true;
this._treebox.beginUpdateBatch();
}
}
for (var i=0; i<this._rows.length; i++) {
if (this.isContainer(i) && this.isContainerOpen(i)) {
itemIDs.push(this.getRow(i).ref.id);
if (close) {
this._closeContainer(i, true);
}
}
}
if (close) {
this._refreshItemRowMap();
if (unsuppress) {
this._treebox.endUpdateBatch();
this.selection.selectEventsSuppressed = false;
}
}
return itemIDs;
}
Zotero.ItemTreeView.prototype.rememberOpenState = function (itemIDs) {
var rowsToOpen = [];
for (let id of itemIDs) {
var row = this._rowMap[id];
// Item may not still exist
if (row == undefined) {
continue;
}
rowsToOpen.push(row);
}
rowsToOpen.sort(function (a, b) {
return a - b;
});
if (!this.selection.selectEventsSuppressed) {
var unsuppress = this.selection.selectEventsSuppressed = true;
this._treebox.beginUpdateBatch();
}
// Reopen from bottom up
for (var i=rowsToOpen.length-1; i>=0; i--) {
this.toggleOpenState(rowsToOpen[i], true);
}
this._refreshItemRowMap();
if (unsuppress) {
this._treebox.endUpdateBatch();
this.selection.selectEventsSuppressed = false;
}
}
Zotero.ItemTreeView.prototype.expandMatchParents = function (searchParentIDs) {
var t = new Date();
var time = 0;
// Expand parents of child matches
if (!this._searchMode) {
return;
}
if (!this.selection.selectEventsSuppressed) {
var unsuppress = this.selection.selectEventsSuppressed = true;
this._treebox.beginUpdateBatch();
}
for (var i=0; i<this.rowCount; i++) {
var id = this.getRow(i).ref.id;
if (searchParentIDs.has(id) && this.isContainer(i) && !this.isContainerOpen(i)) {
var t2 = new Date();
this.toggleOpenState(i, true);
time += (new Date() - t2);
}
}
this._refreshItemRowMap();
if (unsuppress) {
this._treebox.endUpdateBatch();
this.selection.selectEventsSuppressed = false;
}
}
Zotero.ItemTreeView.prototype.expandAllRows = function () {
var unsuppress = this.selection.selectEventsSuppressed = true;
this._treebox.beginUpdateBatch();
for (var i=0; i<this.rowCount; i++) {
if (this.isContainer(i) && !this.isContainerOpen(i)) {
this.toggleOpenState(i, true);
}
}
this._refreshItemRowMap();
this._treebox.endUpdateBatch();
this.selection.selectEventsSuppressed = false;
}
Zotero.ItemTreeView.prototype.collapseAllRows = function () {
var unsuppress = this.selection.selectEventsSuppressed = true;
this._treebox.beginUpdateBatch();
for (var i=0; i<this.rowCount; i++) {
if (this.isContainer(i)) {
this._closeContainer(i, true);
}
}
this._refreshItemRowMap();
this._treebox.endUpdateBatch();
this.selection.selectEventsSuppressed = false;
};
Zotero.ItemTreeView.prototype.expandSelectedRows = function () {
var start = {}, end = {};
this.selection.selectEventsSuppressed = true;
this._treebox.beginUpdateBatch();
for (var i = 0, len = this.selection.getRangeCount(); i<len; i++) {
this.selection.getRangeAt(i, start, end);
for (var j = start.value; j <= end.value; j++) {
if (this.isContainer(j) && !this.isContainerOpen(j)) {
this.toggleOpenState(j, true);
}
}
}
this._refreshItemRowMap();
this._treebox.endUpdateBatch();
this.selection.selectEventsSuppressed = false;
}
Zotero.ItemTreeView.prototype.collapseSelectedRows = function () {
var start = {}, end = {};
this.selection.selectEventsSuppressed = true;
this._treebox.beginUpdateBatch();
for (var i = 0, len = this.selection.getRangeCount(); i<len; i++) {
this.selection.getRangeAt(i, start, end);
for (var j = start.value; j <= end.value; j++) {
if (this.isContainer(j)) {
this._closeContainer(j, true);
}
}
}
this._refreshItemRowMap();
this._treebox.endUpdateBatch();
this.selection.selectEventsSuppressed = false;
}
Zotero.ItemTreeView.prototype.getVisibleFields = function() {
var columns = [];
for (var i=0, len=this._treebox.columns.count; i<len; i++) {
var col = this._treebox.columns.getColumnAt(i);
if (col.element.getAttribute('hidden') != 'true') {
columns.push(col.id.substring(20));
}
}
return columns;
}
/**
* Returns an array of items of visible items in current sort order
*
* @param {Boolean} asIDs - Return itemIDs
* @return {Zotero.Item[]|Integer[]} - An array of Zotero.Item objects or itemIDs
*/
Zotero.ItemTreeView.prototype.getSortedItems = function(asIDs) {
return this._rows.map(row => asIDs ? row.ref.id : row.ref);
}
Zotero.ItemTreeView.prototype.getSortField = function() {
if (this.collectionTreeRow.isFeed()) {
return 'id';
}
var column = this._treebox.columns.getSortedColumn();
if (!column) {
column = this._treebox.columns.getFirstColumn();
}
// zotero-items-column-_________
return column.id.substring(20);
}
Zotero.ItemTreeView.prototype.getSortFields = function () {
var fields = [this.getSortField()];
var secondaryField = this.getSecondarySortField();
if (secondaryField) {
fields.push(secondaryField);
}
try {
var fallbackFields = Zotero.Prefs.get('fallbackSort')
.split(',')
.map((x) => 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<menupopup.childNodes.length; i++) {
let elem = menupopup.childNodes[i];
if (elem.localName == 'menuseparator') {
break;
}
if (elem.localName == 'menuitem' && subs.indexOf(elem.getAttribute('label')) != -1) {
moreItems.push(elem);
}
}
// Disable certain fields for feeds
let labels = Array.from(treecols.getElementsByAttribute('disabled-in', '*'))
.filter(e => 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<sortOptions.length; i++) {
let field = sortOptions[i];
// Hide current primary field, and don't show Year for Date, since it would be a no-op
if (field == primaryField || (primaryField == 'date' && field == 'year')) {
continue;
}
let label = treecols.getElementsByAttribute('id',
'zotero-items-column-' + field)[0].getAttribute('label');
let sortMenuItem = doc.createElementNS(ns, 'menuitem');
sortMenuItem.setAttribute('fieldName', field);
sortMenuItem.setAttribute('label', label);
sortMenuItem.setAttribute('type', 'checkbox');
if (field == secondaryField) {
sortMenuItem.setAttribute('checked', 'true');
}
sortMenuItem.setAttribute('oncommand',
'var view = ZoteroPane.itemsView; '
+ 'if (view.setSecondarySortField(this.getAttribute("fieldName"))) { view.sort(); }');
sortMenuPopup.appendChild(sortMenuItem);
}
sortMenu.appendChild(sortMenuPopup);
menupopup.insertBefore(sortMenu, lastChild);
}
catch (e) {
Components.utils.reportError(e);
Zotero.debug(e, 1);
}
}
sep = doc.createElementNS(ns, 'menuseparator');
sep.setAttribute('anonid', prefix + 'sep');
menupopup.insertBefore(sep, lastChild);
}
Zotero.ItemTreeView.prototype.onColumnPickerHidden = function (event) {
var menupopup = event.originalTarget;
var prefix = 'zotero-column-header-';
for (let i=0; i<menupopup.childNodes.length; i++) {
let elem = menupopup.childNodes[i];
if (elem.getAttribute('anonid').indexOf(prefix) == 0) {
try {
menupopup.removeChild(elem);
}
catch (e) {
Zotero.debug(e, 1);
}
i--;
}
}
}
Zotero.ItemTreeView.prototype._getTreeElement = function () {
return this._treebox.treeBody && this._treebox.treeBody.parentNode;
}
////////////////////////////////////////////////////////////////////////////////
///
/// Command Controller:
/// for Select All, etc.
///
////////////////////////////////////////////////////////////////////////////////
Zotero.ItemTreeCommandController = function(tree)
{
this.tree = tree;
}
Zotero.ItemTreeCommandController.prototype.supportsCommand = function(cmd)
{
return (cmd == 'cmd_selectAll');
}
Zotero.ItemTreeCommandController.prototype.isCommandEnabled = function(cmd)
{
return (cmd == 'cmd_selectAll');
}
Zotero.ItemTreeCommandController.prototype.doCommand = function (cmd) {
if (cmd == 'cmd_selectAll') {
if (this.tree.view.wrappedJSObject.collectionTreeRow.isSearchMode()) {
this.tree.view.wrappedJSObject.selectSearchMatches();
}
else {
this.tree.view.selection.selectAll();
}
}
}
Zotero.ItemTreeCommandController.prototype.onEvent = function(evt)
{
}
////////////////////////////////////////////////////////////////////////////////
///
/// Drag-and-drop functions
///
////////////////////////////////////////////////////////////////////////////////
/**
* Start a drag using HTML 5 Drag and Drop
*/
Zotero.ItemTreeView.prototype.onDragStart = function (event) {
// See note in LibraryTreeView::_setDropEffect()
if (Zotero.isWin || Zotero.isLinux) {
event.dataTransfer.effectAllowed = 'copyMove';
}
var itemIDs = this.getSelectedItems(true);
event.dataTransfer.setData("zotero/item", itemIDs);
// dataTransfer.mozSourceNode doesn't seem to be properly set anymore (tested in 50), so store
// event target separately
if (!event.dataTransfer.mozSourceNode) {
Zotero.debug("mozSourceNode not set -- storing source node");
Zotero.DragDrop.currentSourceNode = event.target;
}
var items = Zotero.Items.get(itemIDs);
// If at least one file is a non-web-link attachment and can be found,
// enable dragging to file system
var files = items
.filter(item => 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 (set via /ping from connector)
var format = Zotero.QuickCopy.getFormatFromURL(Zotero.QuickCopy.lastActiveURL);
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 + "'");
}
};
Zotero.ItemTreeView.prototype.onDragEnd = function (event) {
setTimeout(function () {
Zotero.DragDrop.currentDragSource = null;
});
}
// 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<draggedItems.length; i++) {
// TODO create URL?
if (!draggedItems[i].isAttachment() ||
draggedItems[i].getAttachmentLinkMode() == Zotero.Attachments.LINK_MODE_LINKED_URL) {
continue;
}
if (draggedItems[i].getFile()) {
items.push(draggedItems[i]);
}
else {
notFoundNames.push(draggedItems[i].getField('title'));
}
}
// If using the temp directory, create a directory to store multiple
// files, since we can (it seems) only pass one nsIFile in data.value
if (useTemp && items.length > 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<items.length; i++) {
// TODO create URL?
if (!items[i].isAttachment() ||
items[i].attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
continue;
}
var file = items[i].getFile();
// Determine if we need to copy multiple files for this item
// (web page snapshots)
if (items[i].attachmentLinkMode != Zotero.Attachments.LINK_MODE_LINKED_FILE) {
var parentDir = file.parent;
var files = parentDir.directoryEntries;
var numFiles = 0;
while (files.hasMoreElements()) {
var f = files.getNext();
f.QueryInterface(Components.interfaces.nsILocalFile);
if (f.leafName.indexOf('.') != 0) {
numFiles++;
}
}
}
// Create folder if multiple files
if (numFiles > 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 or publications
else if (collectionTreeRow.isSearch() || collectionTreeRow.isPublications()) {
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<items.length; i++) {
let item = items[i];
item.parentID = rowItem.id;
yield item.save();
}
});
}
// Dropped outside of a row
else
{
// Remove from parent and make top-level
if (collectionTreeRow.isLibrary(true)) {
yield Zotero.DB.executeTransaction(function* () {
for (let i=0; i<items.length; i++) {
let item = items[i];
if (!item.isRegularItem()) {
item.parentID = false;
yield item.save()
}
}
});
}
// Add to collection
else
{
yield Zotero.DB.executeTransaction(function* () {
for (let i=0; i<items.length; i++) {
let item = items[i];
var source = item.isRegularItem() ? false : item.parentItemID;
// Top-level item
if (source) {
item.parentID = false;
item.addToCollection(collectionTreeRow.ref.id);
yield item.save();
}
else {
item.addToCollection(collectionTreeRow.ref.id);
yield item.save();
}
toMove.push(item.id);
}
});
}
}
// If moving, remove items from source collection
if (dropEffect == 'move' && toMove.length) {
if (!sameLibrary) {
throw new Error("Cannot move items between libraries");
}
if (!sourceCollectionTreeRow || !sourceCollectionTreeRow.isCollection()) {
throw new Error("Drag source must be a collection");
}
if (collectionTreeRow.id != sourceCollectionTreeRow.id) {
yield Zotero.DB.executeTransaction(function* () {
yield collectionTreeRow.ref.removeItems(toMove);
}.bind(this));
}
}
}
else if (dataType == 'text/x-moz-url' || dataType == 'application/x-moz-file') {
// Disallow drop into read-only libraries
if (!collectionTreeRow.editable) {
let win = Services.wm.getMostRecentWindow("navigator:browser");
win.ZoteroPane.displayCannotEditLibraryMessage();
return;
}
var targetLibraryID = collectionTreeRow.ref.libraryID;
var parentItemID = false;
var parentCollectionID = false;
if (orient == 0) {
let treerow = this.getRow(row);
parentItemID = treerow.ref.id
}
else if (collectionTreeRow.isCollection()) {
var parentCollectionID = collectionTreeRow.ref.id;
}
let addedItems = [];
var notifierQueue = new Zotero.Notifier.Queue;
try {
// If there's a single file being added to a parent, automatic renaming is enabled,
// and there are no other non-HTML attachments, we'll rename the file as long as it's
// an allowed type. The dragged data could be a URL, so we don't yet know the file type.
// This should be kept in sync with ZoteroPane.addAttachmentFromDialog().
let renameIfAllowedType = false;
let parentItem;
if (parentItemID
&& data.length == 1
&& Zotero.Attachments.shouldAutoRenameFile(dropEffect == 'link')) {
parentItem = Zotero.Items.get(parentItemID);
if (!parentItem.numNonHTMLFileAttachments()) {
renameIfAllowedType = true;
}
}
for (var i=0; i<data.length; i++) {
var file = data[i];
if (dataType == 'text/x-moz-url') {
var url = data[i];
if (url.indexOf('file:///') == 0) {
let win = Services.wm.getMostRecentWindow("navigator:browser");
// If dragging currently loaded page, only convert to
// file if not an HTML document
if (win.content.location.href != url ||
win.content.document.contentType != 'text/html') {
var nsIFPH = Components.classes["@mozilla.org/network/protocol;1?name=file"]
.getService(Components.interfaces.nsIFileProtocolHandler);
try {
var file = nsIFPH.getFileFromURLSpec(url);
}
catch (e) {
Zotero.debug(e);
}
}
}
// Still string, so remote URL
if (typeof file == 'string') {
let item;
if (parentItemID) {
if (!collectionTreeRow.filesEditable) {
let win = Services.wm.getMostRecentWindow("navigator:browser");
win.ZoteroPane.displayCannotEditLibraryFilesMessage();
return;
}
item = yield Zotero.Attachments.importFromURL({
libraryID: targetLibraryID,
url,
renameIfAllowedType,
parentItemID,
saveOptions: {
notifierQueue
}
});
}
else {
let win = Services.wm.getMostRecentWindow("navigator:browser");
item = yield win.ZoteroPane.addItemFromURL(url, 'temporaryPDFHack'); // TODO: don't do this
}
if (item) {
addedItems.push(item);
}
continue;
}
// Otherwise file, so fall through
}
file = file.path;
// Rename file if it's an allowed type
let fileBaseName = false;
if (renameIfAllowedType) {
fileBaseName = yield Zotero.Attachments.getRenamedFileBaseNameIfAllowedType(
parentItem, file
);
}
let item;
if (dropEffect == 'link') {
// Rename linked file, with unique suffix if necessary
try {
if (fileBaseName) {
let ext = Zotero.File.getExtension(file);
let newName = yield Zotero.File.rename(
file,
fileBaseName + (ext ? '.' + ext : ''),
{
unique: true
}
);
// Update path in case the name was changed to be unique
file = OS.Path.join(OS.Path.dirname(file), newName);
}
}
catch (e) {
Zotero.logError(e);
}
item = yield Zotero.Attachments.linkFromFile({
file,
parentItemID,
collections: parentCollectionID ? [parentCollectionID] : undefined,
saveOptions: {
notifierQueue
}
});
}
else {
if (file.endsWith(".lnk")) {
let win = Services.wm.getMostRecentWindow("navigator:browser");
win.ZoteroPane.displayCannotAddShortcutMessage(file);
continue;
}
item = yield Zotero.Attachments.importFromFile({
file,
fileBaseName,
libraryID: targetLibraryID,
parentItemID,
collections: parentCollectionID ? [parentCollectionID] : undefined,
saveOptions: {
notifierQueue
}
});
// If moving, delete original file
if (dragData.dropEffect == 'move') {
try {
yield OS.File.remove(file);
}
catch (e) {
Zotero.logError("Error deleting original file " + file + " after drag");
}
}
}
if (item) {
addedItems.push(item);
}
}
}
finally {
yield Zotero.Notifier.commit(notifierQueue);
}
// Automatically retrieve metadata for PDFs
if (!parentItemID) {
Zotero.RecognizePDF.autoRecognizeItems(addedItems);
}
}
});
////////////////////////////////////////////////////////////////////////////////
///
/// Functions for nsITreeView that we have to stub out.
///
////////////////////////////////////////////////////////////////////////////////
Zotero.ItemTreeView.prototype.isSeparator = function(row) { return false; }
Zotero.ItemTreeView.prototype.isSelectable = function (row, col) { return true; }
Zotero.ItemTreeView.prototype.getRowProperties = function(row, prop) {}
Zotero.ItemTreeView.prototype.getColumnProperties = function(col, prop) {}
Zotero.ItemTreeView.prototype.getCellProperties = function(row, col, prop) {
var treeRow = this.getRow(row);
var itemID = treeRow.ref.id;
var props = [];
// Mark items not matching search as context rows, displayed in gray
if (this._searchMode && !this._searchItemIDs.has(itemID)) {
props.push("contextRow");
}
// Mark hasAttachment column, which needs special image handling
if (col.id == 'zotero-items-column-hasAttachment') {
props.push("hasAttachment");
// Don't show pie for open parent items, since we show it for the
// child item
if (!this.isContainer(row) || !this.isContainerOpen(row)) {
var num = Zotero.Sync.Storage.getItemDownloadImageNumber(treeRow.ref);
//var num = Math.round(new Date().getTime() % 10000 / 10000 * 64);
if (num !== false) props.push("pie", "pie" + num);
}
}
// Style unread items in feeds
if (treeRow.ref.isFeedItem && !treeRow.ref.isRead) props.push('unread');
return props.join(" ");
}
Zotero.ItemTreeRow = function(ref, level, isOpen)
{
this.ref = ref; //the item associated with this
this.level = level;
this.isOpen = isOpen;
this.id = ref.id;
}
Zotero.ItemTreeRow.prototype.getField = function(field, unformatted)
{
return this.ref.getField(field, unformatted, true);
}
Zotero.ItemTreeRow.prototype.numNotes = function() {
if (this.ref.isNote()) {
return 0;
}
if (this.ref.isAttachment()) {
return this.ref.getNote() !== '' ? 1 : 0;
}
return this.ref.numNotes(false, true) || 0;
}