www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | Submodules | README | LICENSE

itemTreeView.js (98982B)


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