zotero/chrome/content/zotero/collectionTree.jsx
Tom Najdek bdc5f71990 Fix unselectable tree item elements
The previous workaround to ensure the twisty animation plays caused an issue
where, in some cases, tree item entries became unselectable.

Instead of skipping the rendering of the entire row (when it has been marked as
just toggled), we are now forcing the toggle animation to play after the row has
been re-rendered.

Additionally, one case where the just-toggled state wasn't cleared was fixed.
2024-01-24 23:32:00 -05:00

2941 lines
85 KiB
JavaScript

/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2019 Corporation for Digital Scholarship
Vienna, Virginia, USA
http://zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
const React = require('react');
const ReactDOM = require('react-dom');
const LibraryTree = require('./libraryTree');
const VirtualizedTable = require('components/virtualized-table');
const { TreeSelectionStub } = VirtualizedTable;
const { getCSSIcon } = require('components/icons');
const { getDragTargetOrient } = require('components/utils');
const { Cc, Ci, Cu } = require('chrome');
const CHILD_INDENT = 15;
var CollectionTree = class CollectionTree extends LibraryTree {
static async init(domEl, opts) {
Zotero.debug("Initializing React CollectionTree");
var ref;
opts.domEl = domEl;
let elem = (
<CollectionTree ref={c => ref = c } {...opts} />
);
await new Promise(resolve => ReactDOM.render(elem, domEl, resolve));
Zotero.debug('React CollectionTree initialized');
return ref;
}
constructor(props) {
super(props);
this.itemTreeView = null;
this.itemToSelect = null;
this.hideSources = [];
this.type = 'collection';
this.name = "CollectionTree";
this.id = "collection-tree";
this._highlightedRows = new Set();
this._unregisterID = Zotero.Notifier.registerObserver(
this,
[
'collection',
'search',
'feed',
'share',
'group',
'trash',
'bucket'
],
'collectionTree',
25
);
try {
this._containerState = JSON.parse(Zotero.Prefs.get("sourceList.persist"));
}
catch (e) {
this._containerState = {};
}
this._virtualCollectionLibraries = {};
this._trashNotEmpty = {};
this._editing = null;
this._editingInput = null;
this._dropRow = null;
this._typingTimeout = null;
this._customRowHeights = [];
this._separatorHeight = 8;
this._filter = "";
this._filterResultsCache = {};
this._hiddenFocusedRow = null;
this.onLoad = this.createEventBinding('load', true, true);
}
async makeVisible() {
await this.refresh();
var lastViewedID = Zotero.Prefs.get('lastViewedFolder');
if (lastViewedID) {
var selected = await this.selectByID(lastViewedID);
}
if (!selected) {
await this.selectByID('L' + Zotero.Libraries.userLibraryID);
}
if (this.selection.selectEventsSuppressed) {
let promise = this.waitForSelect();
this.selection.selectEventsSuppressed = false;
await promise;
}
await this.runListeners('load');
Zotero.debug("React CollectionTree loaded");
}
componentDidMount() {
this.selection.select(0);
this.makeVisible();
}
componentDidUpdate() {
this.runListeners('refresh');
}
// Add a keypress listener for expand/collapse
handleKeyDown = (event) => {
if (this._editing) {
if (event.key == 'Enter' || event.key == 'Escape') {
if (event.key != 'Escape') {
this.commitEditingName(this._editing);
}
this.stopEditing();
}
return false; // In-line editing active
}
var libraryID = this.getSelectedLibraryID();
if (!libraryID) return true;
let treeRow = this.getRow(this.selection.focused);
if (event.key == '+' && !(event.ctrlKey || event.altKey || event.metaKey)) {
this.expandLibrary(libraryID, true);
}
else if (event.key == '-' && !(event.shiftKey || event.ctrlKey
|| event.altKey || event.metaKey)) {
this.collapseLibrary(libraryID);
}
else if ((event.key == 'Backspace' && Zotero.isMac) || event.key == 'Delete') {
var deleteItems = event.metaKey || (!Zotero.isMac && event.shiftKey);
window.ZoteroPane.deleteSelectedCollection(deleteItems);
event.preventDefault();
}
else if (event.key == "F2" && !Zotero.isMac && treeRow.isCollection()) {
this.handleActivate(event, [this.selection.focused]);
}
else if (["ArrowDown", "ArrowUp"].includes(event.key)) {
// Specific logic for keypress navigation during collection filtering
// that skips context-rows
if (!this._isFilterEmpty()) {
this.focusNextMatchingRow(this.selection.focused, event.key == "ArrowUp", false);
return false;
}
}
else if (["ArrowRight", "ArrowLeft"].includes(event.key)) {
// No collapsing rows with arrows to avoid focusing on context rows
if (!this._isFilterEmpty()) {
return false;
}
}
else if (event.key == "End" && !this._isFilterEmpty()) {
this.focusLastMatchingRow();
return false;
}
else if (event.key == "Home" && !this._isFilterEmpty()) {
this.focusFirstMatchingRow(true);
return false;
}
return true;
}
_handleSelectionChange = (selection, shouldDebounce) => {
let treeRow = this.getRow(selection.focused);
// If selection changed (by click on a different row) and we are editing
// commit the edit
if (this._editing) {
if (this._editing == treeRow) return;
this.commitEditingName(this._editing);
this._editing = null;
}
// If the filter is on, the last row can be a previously focused
// row that does not match the filter. If the focus moves
// away to another row, we can delete it.
if (!this._isFilterEmpty() && this._hiddenFocusedRow && treeRow) {
if (this._hiddenFocusedRow.isCollection()
|| this._hiddenFocusedRow.isGroup()
|| this._hiddenFocusedRow.isSearch()
|| this._hiddenFocusedRow.isFeed()) {
if (!this._includedInTree(this._hiddenFocusedRow.ref) && treeRow.id !== this._hiddenFocusedRow.id) {
let indexToDelete = this.getRowIndexByID(this._hiddenFocusedRow.id);
if (indexToDelete) {
this._removeRow(indexToDelete);
this._hiddenFocusedRow = null;
}
}
}
}
// Update aria-activedescendant on the tree
this.forceUpdate();
if (shouldDebounce) {
this._onSelectionChangeDebounced();
}
else {
this._onSelectionChange();
}
}
handleActivate = (event, indices) => {
let index = indices[0];
let treeRow = this.getRow(index);
if (treeRow.isCollection() && this.editable && this.selection.focused == index) {
this._editing = treeRow;
treeRow.editingName = treeRow.ref.name;
this.tree.invalidateRow(index);
}
else if (treeRow.isLibrary()) {
let uri = Zotero.URI.getCurrentUserLibraryURI();
if (uri) {
window.ZoteroPane.loadURI(uri);
event.stopPropagation();
}
}
else if (treeRow.isSearch()) {
window.ZoteroPane.editSelectedCollection();
}
else if (treeRow.isFeed()) {
window.ZoteroPane.editSelectedFeed();
}
else if (treeRow.isGroup()) {
let uri = Zotero.URI.getGroupURI(treeRow.ref, true);
window.ZoteroPane.loadURI(uri);
}
}
handleEditingChange = (event, index) => {
this.getRow(index).editingName = event.target.value;
}
async commitEditingName() {
let treeRow = this._editing;
if (!treeRow.editingName) return;
treeRow.ref.name = treeRow.editingName;
delete treeRow.editingName;
await treeRow.ref.saveTx();
}
stopEditing = () => {
this._editing = null;
// Returning focus to the tree container
this.tree.invalidate();
this.tree.focus();
}
renderItem = (index, selection, oldDiv, columns) => {
const treeRow = this.getRow(index);
// Div creation and content
let div = oldDiv || document.createElement('div');
div.innerHTML = "";
// When a hidden focused row is added last during filtering, it
// is removed on focus change, which can happen at the same time as rendering.
// In this case, just return empty div.
if (index >= this._rows.length) {
return div;
}
// Classes
div.className = "row";
div.classList.toggle('selected', selection.isSelected(index));
div.classList.toggle('highlighted', this._highlightedRows.has(treeRow.id));
div.classList.toggle('drop', this._dropRow == index);
div.classList.toggle('unread', treeRow.ref && treeRow.ref.unreadCount > 0);
let { matchesFilter, hasChildMatchingFilter } = this._matchesFilter(treeRow.ref);
div.classList.toggle('context-row', !matchesFilter && hasChildMatchingFilter);
// Hide currently focused but filtered out row to avoid confusing itemTree
if (this._hiddenFocusedRow && this._hiddenFocusedRow.id == treeRow.id) {
div.style.display = "none";
}
else if (div.style.display == "none") {
// Make sure we unhide the div if the row matches filter conditions
div.style.display = "";
}
// Depth indent
let depth = treeRow.level;
// The arrow on macOS is a full icon's width.
// For non-userLibrary/feed items that are drawn under headers
// we do not draw the arrow and need to move all items 1 level up
if (Zotero.isMac && !treeRow.isHeader() && !treeRow.isFeed()
&& treeRow.ref && treeRow.ref.libraryID != Zotero.Libraries.userLibraryID) {
depth--;
}
// Ensures the feeds row has no padding
if (treeRow.isFeeds()) {
depth = 0;
}
div.style.paddingInlineStart = (CHILD_INDENT * depth) + 'px';
// Create a single-cell for the row (for the single-column layout)
let cell = document.createElement('span');
cell.className = "cell label primary";
// Twisty/spacer
let twisty;
if (this.isContainerEmpty(index) || !hasChildMatchingFilter) {
twisty = document.createElement('span');
if (Zotero.isMac && treeRow.isHeader()) {
twisty.classList.add("spacer-header");
}
else {
twisty.classList.add("spacer-twisty");
}
}
else {
twisty = getCSSIcon("twisty");
twisty.classList.add('twisty');
if (this.isContainerOpen(index)) {
twisty.classList.add('open');
}
twisty.style.pointerEvents = 'auto';
twisty.addEventListener('mousedown', event => event.stopPropagation());
twisty.addEventListener('mouseup', event => this.handleTwistyMouseUp(event, index),
{ passive: true });
}
const icon = this._getIcon(index);
icon.classList.add('cell-icon');
// Label
let label = document.createElement('span');
label.innerText = treeRow.getName();
label.className = 'cell-text';
label.dir = 'auto';
// Editing input
div.classList.toggle('editing', treeRow == this._editing);
if (treeRow == this._editing) {
label = document.createElement('input');
label.className = 'cell-text';
label.setAttribute("size", 5);
label.value = treeRow.editingName;
label.addEventListener('input', e => this.handleEditingChange(e, index));
label.addEventListener('mousedown', (e) => e.stopImmediatePropagation());
label.addEventListener('mouseup', (e) => e.stopImmediatePropagation());
label.addEventListener('blur', async (e) => {
await this.commitEditingName();
this.stopEditing();
});
// Feels like a bit of a hack, but it gets the job done
setTimeout(() => {
label.focus();
label.select();
});
}
cell.appendChild(twisty);
cell.appendChild(icon);
cell.appendChild(label);
div.appendChild(cell);
// Accessibility
div.setAttribute('aria-level', depth+1);
if (!this.isContainerEmpty(index)) {
div.setAttribute('aria-expanded', this.isContainerOpen(index));
}
div.setAttribute('role', 'treeitem');
if (treeRow.isSeparator()) {
div.setAttribute('role', 'none');
}
let children = [];
for (let i = index + 1; ; i++) {
let row = this.getRow(i);
if (!row || treeRow.level >= row.level) break;
children.push(this.id + '-row-' + i);
}
// Drag-and-drop stuff
if (this.props.dragAndDrop) {
div.setAttribute('draggable', treeRow != this._editing);
}
if (!oldDiv) {
if (this.props.dragAndDrop) {
div.addEventListener('dragstart', e => this.onDragStart(e, index), { passive: true });
div.addEventListener('dragover', e => this.onDragOver(e, index));
div.addEventListener('dragend', this.onDragEnd, { passive: true });
div.addEventListener('dragleave', this.onDragLeave, { passive: true });
div.addEventListener('drop', (e) => {
e.stopPropagation();
this.onDrop(e, index);
}, { passive: true });
}
}
// since row has been re-rendered, if it has been toggled open/close, we need to force twisty animation
if (this._lastToggleOpenStateIndex === index) {
let twisty = div.querySelector('.twisty');
if (twisty) {
twisty.classList.toggle('open', !this.isContainerOpen(index));
setTimeout(() => {
twisty.classList.toggle('open', this.isContainerOpen(index));
}, 0);
}
}
return div;
}
render() {
return React.createElement(VirtualizedTable,
{
getRowCount: () => this._rows.length,
id: this.id,
ref: ref => this.tree = ref,
treeboxRef: ref => this._treebox = ref,
renderItem: this.renderItem,
alternatingRowColors: null,
onSelectionChange: this._handleSelectionChange,
isSelectable: this.isSelectable,
getParentIndex: this.getParentIndex,
isContainer: this.isContainer,
isContainerEmpty: this.isContainerEmpty,
isContainerOpen: this.isContainerOpen,
toggleOpenState: this.toggleOpenState,
getRowString: this.getRowString.bind(this),
onItemContextMenu: (...args) => this.props.onContextMenu && this.props.onContextMenu(...args),
onKeyDown: this.handleKeyDown,
onActivate: this.handleActivate,
role: 'tree',
label: Zotero.getString('pane.collections.title')
}
);
}
////////////////////////////////////////////////////////////////////////////////
///
/// Component control methods
///
////////////////////////////////////////////////////////////////////////////////
/**
* Rebuild the tree from the data access methods and clear the selection
*
* Calling code must restore the selection, and unsuppress selection events
*/
async refresh() {
try {
Zotero.debug("Refreshing collections pane");
if (this.hideSources.indexOf('duplicates') == -1) {
this._virtualCollectionLibraries.duplicates =
Zotero.Prefs.getVirtualCollectionState('duplicates');
}
this._virtualCollectionLibraries.unfiled =
Zotero.Prefs.getVirtualCollectionState('unfiled');
this._virtualCollectionLibraries.retracted =
Zotero.Prefs.getVirtualCollectionState('retracted');
var newRows = [];
var added = 0;
this._filterResultsCache = {};
let libraryIncluded, groupsIncluded, feedsIncluded;
//
// Add "My Library"
//
libraryIncluded = this._includedInTree({ libraryID: Zotero.Libraries.userLibraryID });
if (libraryIncluded) {
newRows.splice(added++, 0,
new Zotero.CollectionTreeRow(this, 'library', { libraryID: Zotero.Libraries.userLibraryID }));
newRows[0].isOpen = true;
added += await this._expandRow(newRows, 0);
}
// Add groups
var groups = Zotero.Groups.getAll();
groupsIncluded = groups.some(group => this._includedInTree(group));
if (groups.length && groupsIncluded) {
if (libraryIncluded) {
newRows.splice(added++, 0, new Zotero.CollectionTreeRow(this, 'separator', false, 0));
}
let groupHeader = new Zotero.CollectionTreeRow(this, 'header', {
id: "group-libraries-header",
label: Zotero.getString('pane.collections.groupLibraries'),
libraryID: -1
});
newRows.splice(added++, 0, groupHeader);
for (let group of groups) {
if (!this._includedInTree(group)) continue;
newRows.splice(added++, 0,
new Zotero.CollectionTreeRow(this, 'group', group, 1),
);
added += await this._expandRow(newRows, added - 1);
}
}
let feeds = {
get unreadCount() {
return Zotero.Feeds.totalUnreadCount();
},
async updateFeed() {
for (let feed of Zotero.Feeds.getAll()) {
await feed.updateFeed();
}
}
};
feedsIncluded = this._includedInTree(feeds);
if (this.hideSources.indexOf('feeds') == -1 && Zotero.Feeds.haveFeeds() && feedsIncluded) {
if (groupsIncluded || libraryIncluded) {
newRows.splice(added++, 0,
new Zotero.CollectionTreeRow(this, 'separator', false),
);
}
newRows.splice(added++, 0,
new Zotero.CollectionTreeRow(this, 'feeds', feeds)
);
added += await this._expandRow(newRows, added - 1);
}
this.selection.selectEventsSuppressed = true;
// If the focused row does not match the filter, create a hidden dummy row at the bottom
// of the tree to focus on to prevent itemTree from changing selection
this._hiddenFocusedRow = this._createFocusedFilteredRow(newRows);
if (this._hiddenFocusedRow) {
newRows.splice(added++, 0,
this._hiddenFocusedRow
);
}
this._rows = newRows;
this._refreshRowMap();
} catch (e) {
Zotero.logError(e);
window.ZoteroPane.displayErrorMessage();
}
}
/**
* Refresh tree, restore selection and unsuppress select events
*
* See note for refresh() for requirements of calling code
*/
async reload() {
await this.refresh();
this.tree.invalidate();
}
async selectByID(id, ensureRowVisible = true) {
var type = id[0];
id = parseInt(('' + id).substr(1));
switch (type) {
case 'L':
return this.selectLibrary(id);
case 'C':
await this.expandToCollection(id);
break;
case 'S':
var search = await Zotero.Searches.getAsync(id);
await this.expandLibrary(search.libraryID);
break;
case 'D':
case 'U':
case 'T':
await this.expandLibrary(id);
break;
}
var row = this.getRowIndexByID(type + id);
if (row === false) {
return false;
}
if (ensureRowVisible) {
this.ensureRowIsVisible(row);
}
await this.selectWait(row);
return true;
}
/**
* Select a row and wait for its items view to be created
*
* Note that this doesn't wait for the items view to be loaded. For that, add a 'load' event
* listener to the items view.
*
* @param {Integer} index
* @return {Promise}
*/
async selectWait(index) {
if (this.tree && this.selection.isSelected(index)) {
Zotero.debug(`CollectionTree.selectWait(): row ${index} already selected`);
return;
}
var promise = this.waitForSelect();
this.selection.select(index);
if (this.selection.selectEventsSuppressed) {
Zotero.debug(`CollectionTree.selectWait(): selectEventsSuppressed. Not waiting to select row ${index}`);
return;
}
return promise;
}
async selectLibrary(libraryID = 1) {
var row = this.getRowIndexByID('L' + libraryID);
if (row === false) {
Zotero.debug(`CollectionTree.selectLibrary(): library with ID ${libraryID} not found in collection tree`);
return false;
}
this.ensureRowIsVisible(row);
await this.selectWait(row);
return true;
}
async selectTrash(libraryID) {
return this.selectByID('T'+libraryID);
}
async selectCollection(id) {
return this.selectByID('C' + id);
}
selectSearch(id) {
return this.selectByID('S' + id);
}
async selectFeeds() {
return this.selectByID('F1');
}
async selectItem(itemID, inLibraryRoot) {
return !!(await this.selectItems([itemID], inLibraryRoot));
}
/**
* Find items in current collection, or, if not there, in a library root, and select it
*
* @param {Array{Integer}} itemID
* @param {Boolean} [inLibraryRoot=false] - Always show in library root
* @return {Boolean} - True if item was found, false if not
*/
async selectItems(itemIDs, inLibraryRoot) {
if (!itemIDs.length) {
return false;
}
var items = await Zotero.Items.getAsync(itemIDs);
if (!items.length) {
return false;
}
await this.waitForLoad();
// Check if items from multiple libraries were specified
if (items.length > 1 && new Set(items.map(item => item.libraryID)).size > 1) {
Zotero.debug("Can't select items in multiple libraries", 2);
return 0;
}
var currentLibraryID = this.getSelectedLibraryID();
var libraryID = items[0].libraryID;
// If in a different library
if (libraryID != currentLibraryID) {
Zotero.debug("Library ID differs; switching library");
await this.selectLibrary(libraryID);
}
// Force switch to library view
else if (!this.getRow(this.selection.focused).isLibrary() && inLibraryRoot) {
Zotero.debug("Told to select in library; switching to library");
await this.selectLibrary(libraryID);
}
await this.itemTreeView.waitForLoad();
var numSelected = await this.itemTreeView.selectItems(itemIDs);
if (numSelected == items.length) {
return numSelected;
}
// If there's a single item and it's in the trash, switch to that
if (items.length == 1 && items[0].deleted) {
Zotero.debug("Item is deleted; switching to trash");
await this.selectTrash(libraryID);
}
else {
Zotero.debug("Item was not selected; switching to library");
await this.selectLibrary(libraryID);
}
await this.itemTreeView.waitForLoad();
return this.itemTreeView.selectItems(itemIDs);
}
waitForLoad() {
return this._waitForEvent('load');
}
/*
* Called by Zotero.Notifier on any changes to collections in the data layer
*/
async notify(action, type, ids, extraData) {
if ((!ids || ids.length == 0) && action != 'refresh' && action != 'redraw') {
return;
}
if (!this._rowMap) {
Zotero.debug("Row map didn't exist in collectionTree.notify()");
return;
}
//
// Actions that don't change the selection
//
if (action == 'redraw') {
this.tree.invalidate();
return;
}
if (action == 'refresh' && type != 'trash') {
// Trash handled below
return;
}
if (type == 'feed' && (action == 'unreadCountUpdated' || action == 'statusChanged')) {
// Refresh the feed
let feedRow = this.getRowIndexByID("L" + ids[0]);
if (feedRow !== false) {
this.tree.invalidateRow(feedRow);
}
// Refresh the Feeds row
let feedsRow = this.getRowIndexByID("F1");
if (feedsRow !== false) {
this.tree.invalidateRow(feedsRow);
}
return;
}
//
// Actions that can change the selection
//
var currentTreeRow = this.getRow(this.selection.focused);
this.selection.selectEventsSuppressed = true;
if (action == 'delete') {
let selectedIndex = this.selection.focused;
let feedDeleted = false;
var offset = 0;
// Since a delete involves shifting of rows, we have to do it in reverse order
let rows = [];
for (let i = 0; i < ids.length; i++) {
let id = ids[i];
switch (type) {
case 'collection':
if (this._rowMap['C' + id] !== undefined) {
// During filtering, calculate by how many rows focus needs to be shifted.
// e.g. Shift focus by 2 if a child is deleted and it's parent does not match the filter
offset = Math.max(this._calculateOffsetForRowSelection('C' + id), offset);
rows.push(this._rowMap['C' + id]);
}
break;
case 'search':
if (this._rowMap['S' + id] !== undefined) {
rows.push(this._rowMap['S' + id]);
}
break;
case 'feed':
case 'group':
let row = this._rowMap["L" + extraData[id].libraryID];
let level = this.getLevel(row);
do {
rows.push(row);
row++;
}
while (row < this._rows.length && this.getLevel(row) > level);
if (type == 'feed') {
feedDeleted = true;
}
break;
}
}
if (rows.length > 0) {
rows.sort(function(a,b) { return b-a });
for (let row of rows) {
this._removeRow(row);
}
// If a feed was removed and there are no more, remove the 'Feeds' row
// (and the splitter before it)
if (feedDeleted && !Zotero.Feeds.haveFeeds()) {
let row = this._rowMap['F1'];
this._removeRow(row);
this._removeRow(row - 1);
}
}
// If there's an active filter, we can have a child matching filter be deleted
// which means the non-matching parent needs to be removed, so the tree is rebuilt
if (!this._isFilterEmpty()) {
await this.reload();
}
this._selectAfterRowRemoval(selectedIndex - offset);
}
else if (action == 'modify') {
let row;
let id = ids[0];
let rowID = "C" + id;
let selectedIndex = this.selection.focused;
let handleFocusDuringSearch = async (type) => {
let object = type == 'collection' ? Zotero.Collections.get(id) : Zotero.Searches.get(id);
// If collections/searches are being filtered, some rows
// need to be (un-)greyed out or removed, so reload.
if (!this._isFilterEmpty()) {
let offset = 0;
if (!this._includedInTree(object, true)) {
offset = this._calculateOffsetForRowSelection(type[0].toUpperCase() + id);
}
await this.reload();
this._selectAfterRowRemoval(selectedIndex - offset);
}
};
switch (type) {
case 'collection':
let collection = Zotero.Collections.get(id);
row = this.getRowIndexByID(rowID);
// If collection is visible
if (row !== false) {
// TODO: Only move if name changed
let reopen = this.isContainerOpen(row);
if (reopen) {
this._closeContainer(row);
}
this._removeRow(row);
// Collection was moved to trash, so don't add it back
if (collection.deleted) {
this._refreshRowMap();
// If collection was selected, select next row
if (selectedIndex == row) {
this._selectAfterRowRemoval(selectedIndex);
}
}
else {
await this._addSortedRow('collection', id);
await this.selectByID(currentTreeRow.id);
if (reopen) {
let newRow = this.getRowIndexByID(rowID);
if (!this.isContainerOpen(newRow)) {
await this.toggleOpenState(newRow);
}
}
}
}
// If collection isn't currently visible and it isn't in the trash (because it was
// undeleted), add it (if possible without opening any containers)
else if (!collection.deleted) {
await this._addSortedRow('collection', id);
await this.selectByID(currentTreeRow.id);
// Invalidate parent in case it's become non-empty
let parentRow = this.getRowIndexByID("C" + collection.parentID);
if (parentRow !== false) {
this.tree.invalidateRow(parentRow);
}
}
await handleFocusDuringSearch('collection');
break;
case 'search':
let search = Zotero.Searches.get(id);
row = this.getRowIndexByID("S" + id);
if (row !== false) {
// TODO: Only move if name changed
this._removeRow(row);
// Search was moved to trash
if (search.deleted) {
this._refreshRowMap();
// If search was selected, select next row
if (selectedIndex == row) {
this._selectAfterRowRemoval(selectedIndex);
}
}
// If search isn't in trash, add it back
else {
await this._addSortedRow('search', id);
await this.selectByID(currentTreeRow.id);
}
}
// If search isn't currently visible and it isn't in the trash (because it was
// undeleted), add it
else if (!search.deleted) {
await this._addSortedRow('search', id);
await this.selectByID(currentTreeRow.id);
// Invalidate parent in case it's become non-empty
// NOTE: Not currently used, because searches can't yet have parents
if (search.parentID) {
let parentRow = this.getRowIndexByID("S" + search.parentID);
if (parentRow !== false) {
this.tree.invalidateRow(parentRow);
}
}
}
await handleFocusDuringSearch('search');
break;
case 'feed':
break;
default:
await this.reload();
await this.selectByID(currentTreeRow.id);
break;
}
}
else if(action == 'add')
{
// skipSelect isn't necessary if more than one object
let selectRow = ids.length == 1 && (!extraData[ids[0]] || !extraData[ids[0]].skipSelect);
for (let id of ids) {
switch (type) {
case 'collection':
case 'search':
const addedIndex = await this._addSortedRow(type, id);
if (selectRow) {
if (type == 'collection') {
await this.selectByID("C" + id);
}
else if (type == 'search') {
await this.selectByID("S" + id);
}
}
else if (addedIndex !== false && addedIndex <= this.selection.focused) {
await this.selectWait(this.selection.focused+1);
}
break;
case 'group':
case 'feed':
if (type == 'groups' && ids.length != 1) {
Zotero.logError("WARNING: Multiple groups shouldn't currently be added "
+ "together in collectionTree::notify()")
}
await this.reload();
await this.selectByID(
// Groups only come from sync, so they should never be auto-selected
(type != 'group' && selectRow)
? "L" + id
: currentTreeRow.id
);
break;
}
}
// After new collections were added, we need to update parents' info on if they have
// a child matching the filter to show the arrow or not.
if (!this._isFilterEmpty() && type == "collection") {
for (let id of ids) {
let rowIndex = this.getRowIndexByID("C" + id);
let parentIndex = rowIndex ? this.getParentIndex(rowIndex) : -1;
while (parentIndex > 0) {
let parent = this.getRow(parentIndex);
this._matchesFilter(parent.ref, true);
parentIndex = this.getParentIndex(parentIndex);
}
}
}
}
else if (action == 'refresh' && type == 'trash') {
// We need to update the trash's status (full or empty), and if empty,
// the row might be removed
await this.reload();
}
this.forceUpdate();
var promise = this.waitForSelect();
this.selection.selectEventsSuppressed = false;
return promise;
}
/**
* Set the rows that should be highlighted
*
* @param ids {String[]} list of row ids to be highlighted
*/
async setHighlightedRows(ids) {
if (this._editing) return;
try {
this._highlightedRows = new Set();
if (!ids || !ids.length) {
return;
}
// Make sure all highlighted collections are shown
for (let id of ids) {
if (id[0] == 'C') {
await this.expandToCollection(parseInt(id.substr(1)));
}
}
// Highlight rows
var rows = [];
for (let id of ids) {
let row = this._rowMap[id];
if (row) {
this._highlightedRows.add(id);
rows.push(row);
}
}
rows.sort();
// Select first collection
// TODO: This is slightly annoying if some subsequent
// but not the first row is visible
if (rows.length) {
this.ensureRowIsVisible(rows[0]);
}
} finally {
this.tree.invalidate();
}
}
/**
*
* @param rowID {String}
* @returns {Promise<boolean>}
*/
async expandToCollection(collectionID) {
var col = await Zotero.Collections.getAsync(collectionID);
if (!col) {
Zotero.debug("Cannot expand to nonexistent collection " + collectionID, 2);
return false;
}
if (!this._includedInTree(col)) {
return false;
}
// Open library if closed
var libraryRow = this._rowMap['L' + col.libraryID];
if (!this.isContainerOpen(libraryRow)) {
await this.toggleOpenState(libraryRow);
}
var row = this._rowMap["C" + collectionID];
if (row !== undefined) {
return true;
}
var path = [];
var parentID;
var seen = new Set([col.id])
while (parentID = col.parentID) {
// Detect infinite loop due to invalid nesting in DB
if (seen.has(parentID)) {
await Zotero.Schema.setIntegrityCheckRequired(true);
Zotero.crash();
return;
}
seen.add(parentID);
path.unshift(parentID);
col = await Zotero.Collections.getAsync(parentID);
}
for (let id of path) {
row = this._rowMap["C" + id];
if (!this.isContainerOpen(row)) {
await this.toggleOpenState(row);
}
}
this.tree.invalidate();
return true;
}
/**
* @param {Integer} libraryID
* @param {Boolean} [recursive=false] - Expand all collections and subcollections
*/
async expandLibrary(libraryID, recursive) {
var row = this._rowMap['L' + libraryID];
if (row === undefined) {
return false;
}
if (!this.isContainerOpen(row)) {
await this.toggleOpenState(row);
}
if (recursive) {
for (let i = row; i < this._rows.length && this.getRow(i).ref.libraryID == libraryID; i++) {
if (this.isContainer(i) && !this.isContainerOpen(i)) {
await this.toggleOpenState(i);
}
}
}
this.tree.invalidate();
this._saveOpenStates();
return true;
}
collapseLibrary(libraryID) {
var row = this._rowMap['L' + libraryID];
if (row === undefined) {
return false;
}
var closed = [];
var found = false;
for (let i = this._rows.length - 1; i >= row; i--) {
let treeRow = this.getRow(i);
if (treeRow.ref.libraryID !== libraryID) {
// Once we've moved beyond the original library, stop looking
if (found) {
break;
}
continue;
}
found = true;
if (this.isContainer(i) && this.isContainerOpen(i)) {
closed.push(treeRow.id);
this._closeContainer(i);
}
}
// Select the collapsed library
this.selection.select(row);
// We have to manually delete closed rows from the container state object, because otherwise
// _saveOpenStates() wouldn't see any of the rows under the library (since the library is now
// collapsed) and they'd remain as open in the persisted object.
closed.forEach(id => { delete this._containerState[id]; });
this.tree.invalidate();
this._saveOpenStates();
}
toggleOpenState = async (index) => {
if (this.isContainerEmpty(index)) return;
this._lastToggleOpenStateIndex = index;
if (this.isContainerOpen(index)) {
await this._closeContainer(index);
this._lastToggleOpenStateIndex = null;
return;
}
this.selection.selectEventsSuppressed = true;
var count = 0;
var treeRow = this.getRow(index);
if (treeRow.isLibrary(true) || treeRow.isCollection() || treeRow.isFeeds()) {
count = await this._expandRow(this._rows, index, true);
}
if (this.selection.focused > index) {
this.selection.select(this.selection.focused + count);
}
this.selection.selectEventsSuppressed = false;
this._rows[index].isOpen = true;
this._refreshRowMap();
this._saveOpenStates();
this.tree.invalidate(index);
this._lastToggleOpenStateIndex = null;
}
/**
* Toggle virtual collection (duplicates/unfiled) visibility
*
* @param libraryID {Number}
* @param type {String}
* @param show {Boolean}
* @returns {Promise}
*/
async toggleVirtualCollection(libraryID, type, show, select) {
const types = {
duplicates: 'D',
unfiled: 'U',
retracted: 'R'
};
if (!(type in types)) {
throw new Error("Invalid virtual collection type '" + type + "'");
}
let treeViewID = types[type] + libraryID;
Zotero.Prefs.setVirtualCollectionStateForLibrary(libraryID, type, show);
var promise = this.waitForSelect();
var selectedRow = this.selection.focused;
var selectedRowID = this.getRow(selectedRow).id;
await this.refresh();
// Select new or original row
if (show) {
await this.selectByID(select ? treeViewID : selectedRowID);
}
else if (type == 'retracted') {
await this.selectByID("L" + libraryID);
}
// Select next appropriate row after removal
else {
await this.selectWait(this.selection.focused);
}
this.selection.selectEventsSuppressed = false;
return promise;
}
/**
* Deletes the selected collection and optionally items within it
*
* @param deleteItems[boolean] Whether items contained in the collection should be removed
* @returns {Promise<void>}
*/
async deleteSelection(deleteItems) {
var treeRow = this.getRow(this.selection.focused);
if (treeRow.isCollection() || treeRow.isFeed()) {
await treeRow.ref.eraseTx({ deleteItems });
}
else if (treeRow.isSearch()) {
await Zotero.Searches.erase(treeRow.ref.id);
}
}
unregister() {
this._uninitialized = true;
Zotero.Notifier.unregisterObserver(this._unregisterID);
}
////////////////////////////////////////////////////////////////////////////////
///
/// Component data access methods
///
////////////////////////////////////////////////////////////////////////////////
get selectedTreeRow() {
if (!this.selection || !this.selection.count) {
return false;
}
return this.getRow(this.selection.focused);
}
/**
* Returns TRUE if the underlying view is editable
*/
get editable() {
return this.getRow(this.selection.focused).editable;
}
getRowString(index) {
// During filtering, context rows return an empty string to not be selectable
// with key-based navigation
if (!this._isFilterEmpty()) {
if (!this._matchesFilter(this.getRow(index).ref).matchesFilter) {
return "";
}
}
return this.getRow(index).getName();
}
/**
* Return libraryID of selected row (which could be a collection, etc.)
*/
getSelectedLibraryID() {
var treeRow = this.getRow(this.selection.focused);
return treeRow && treeRow.ref && treeRow.ref.libraryID !== undefined
&& treeRow.ref.libraryID;
}
getSelectedCollection(asID) {
var collection = this.getRow(this.selection.focused);
if (collection && collection.isCollection()) {
return asID ? collection.ref.id : collection.ref;
}
}
getSelectedSearch(asID) {
if (this.getRow(this.selection.focused)) {
var search = this.getRow(this.selection.focused);
if (search && search.isSearch()) {
return asID ? search.ref.id : search.ref;
}
}
return false;
}
getSelectedGroup(asID) {
if (this.getRow(this.selection.focused)) {
var group = this.getRow(this.selection.focused);
if (group && group.isGroup()) {
return asID ? group.ref.id : group.ref;
}
}
return false;
}
getIconName(index) {
const treeRow = this.getRow(index);
let collectionType = treeRow.type;
let icon = collectionType;
switch (collectionType) {
case 'group':
icon = 'library-group';
break;
case 'feed':
// Better alternative needed: https://github.com/zotero/zotero/pull/902#issuecomment-183185973
/*
if (treeRow.ref.updating) {
collectionType += 'Updating';
} else */if (treeRow.ref.lastCheckError) {
icon += '-error';
}
break;
case 'trash':
if (this._trashNotEmpty[treeRow.ref.libraryID]) {
icon += '-full';
}
break;
case 'feeds':
icon = 'feed-library';
break;
case 'header':
if (treeRow.ref.id == 'group-libraries-header') {
icon = 'groups';
}
break;
case 'separator':
return null;
}
return icon;
}
////////////////////////////////////////////////////////////////////////////////
///
/// Drag-and-drop methods
///
////////////////////////////////////////////////////////////////////////////////
onDragStart(event, index) {
const treeRow = this.getRow(index);
// See note in #setDropEffect()
if (Zotero.isWin || Zotero.isLinux) {
event.dataTransfer.effectAllowed = 'copyMove';
}
if (!treeRow.isCollection()) {
return;
}
event.dataTransfer.setData("zotero/collection", treeRow.ref.id);
Zotero.debug("Dragging collection " + treeRow.id);
}
onDragOver(event, index) {
if (!event.currentTarget.classList.contains('row')) return;
const treeRow = this.getRow(index);
try {
// Prevent modifier keys from doing their normal things
event.preventDefault();
var previousOrientation = Zotero.DragDrop.currentOrientation;
Zotero.DragDrop.currentOrientation = getDragTargetOrient(event);
if (!this.canDropCheck(index, Zotero.DragDrop.currentOrientation, event.dataTransfer)) {
this.setDropEffect(event, "none");
return;
}
if (event.dataTransfer.getData("zotero/item")) {
var sourceCollectionTreeRow = Zotero.DragDrop.getDragSource(event.dataTransfer);
if (sourceCollectionTreeRow) {
var targetCollectionTreeRow = treeRow;
if (sourceCollectionTreeRow.id == targetCollectionTreeRow.id) {
// Ignore drag into the same collection
this.setDropEffect(event, "none");
return false;
}
// If the source isn't a collection, the action has to be a copy
if (!sourceCollectionTreeRow.isCollection()) {
this.setDropEffect(event, "copy");
return false;
}
// For now, all cross-library drags are copies
if (sourceCollectionTreeRow.ref.libraryID != targetCollectionTreeRow.ref.libraryID) {
this.setDropEffect(event, "copy");
return false;
}
}
if ((Zotero.isMac && event.metaKey) || (!Zotero.isMac && event.shiftKey)) {
this.setDropEffect(event, "move");
}
else {
this.setDropEffect(event, "copy");
}
}
else if (event.dataTransfer.getData("zotero/collection")) {
let collectionID = Zotero.DragDrop.getDataFromDataTransfer(event.dataTransfer).data[0];
let { libraryID: sourceLibraryID } = Zotero.Collections.getLibraryAndKeyFromID(collectionID);
var targetCollectionTreeRow = treeRow;
// For now, all cross-library drags are copies
if (sourceLibraryID != targetCollectionTreeRow.ref.libraryID) {
/*if ((Zotero.isMac && event.metaKey) || (!Zotero.isMac && event.shiftKey)) {
this.setDropEffect(event, "move");
}
else {
this.setDropEffect(event, "copy");
}*/
this.setDropEffect(event, "copy");
return false;
}
// And everything else is a move
this.setDropEffect(event, "move");
}
else if (event.dataTransfer.types.contains("application/x-moz-file")) {
// As of Aug. 2013 nightlies:
//
// - Setting the dropEffect only works on Linux and OS X.
//
// - Modifier keys don't show up in the drag event on OS X until the
// drop (https://bugzilla.mozilla.org/show_bug.cgi?id=911918),
// so since we can't show a correct effect, we leave it at
// the default 'move', the least misleading option, and set it
// below in onDrop().
//
// - The cursor effect gets set by the system on Windows 7 and can't
// be overridden.
if (!Zotero.isMac) {
if (event.shiftKey) {
if (event.ctrlKey) {
event.dataTransfer.dropEffect = "link";
}
else {
event.dataTransfer.dropEffect = "move";
}
}
else {
event.dataTransfer.dropEffect = "copy";
}
}
}
return false;
} finally {
let prevDropRow = this._dropRow;
if (event.dataTransfer.dropEffect != 'none') {
this._dropRow = index;
} else {
this._dropRow = null;
}
if (prevDropRow != this._dropRow || previousOrientation != Zotero.DragDrop.currentOrientation) {
typeof prevDropRow == 'number' && this.tree.invalidateRow(prevDropRow);
this.tree.invalidateRow(index);
}
}
}
onDragEnd = () => {
let dropRow = this._dropRow;
this._dropRow = null;
this.tree.invalidateRow(dropRow);
}
onDragLeave = (e) => {
if (!e.currentTarget.classList.contains('row')) return;
let dropRow = this._dropRow;
this._dropRow = null;
this.tree.invalidateRow(dropRow);
}
canDropCheck = (row, orient, dataTransfer) => {
const treeRow = this.getRow(row);
// TEMP
Zotero.debug("Row is " + row + "; orient is " + orient);
var dragData = Zotero.DragDrop.getDataFromDataTransfer(dataTransfer);
if (!dragData) {
Zotero.debug("No drag data");
return false;
}
var {dataType, data} = dragData;
// Directly on a row
if (orient === 0) {
if (!treeRow.editable) {
// Zotero.debug("Drop target not editable");
return false;
}
if (dataType == 'zotero/item') {
// TODO: Is this still required?
if (treeRow.isBucket()) {
return true;
}
var ids = data;
var items = Zotero.Items.get(ids);
items = Zotero.Items.keepParents(items);
var skip = true;
for (let item of items) {
// Can only drag top-level items
if (!item.isTopLevelItem()) {
Zotero.debug("Can't drag child item");
return false;
}
if (treeRow.isWithinGroup() && item.isAttachment()) {
// Linked files can't be added to groups
if (item.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
Zotero.debug("Linked files cannot be added to groups");
return false;
}
if (!treeRow.filesEditable) {
Zotero.debug("Drop target does not allow files to be edited");
return false;
}
skip = false;
continue;
}
if (treeRow.isPublications()) {
if (item.isAttachment() || item.isNote()) {
Zotero.debug("Top-level attachments and notes cannot be added to My Publications");
return false;
}
if(item instanceof Zotero.FeedItem) {
Zotero.debug("FeedItems cannot be added to My Publications");
return false;
}
if (item.inPublications) {
Zotero.debug("Item " + item.id + " already exists in My Publications");
continue;
}
if (treeRow.ref.libraryID != item.libraryID) {
Zotero.debug("Cross-library drag to My Publications not allowed");
continue;
}
skip = false;
continue;
}
// Cross-library drag
if (treeRow.ref.libraryID != item.libraryID) {
// Only allow cross-library drag to root library and collections
if (!(treeRow.isLibrary(true) || treeRow.isCollection())) {
Zotero.debug("Cross-library drag to non-collection not allowed");
return false;
}
skip = false;
continue;
}
// Intra-library drag
// Don't allow drag onto root of same library
if (treeRow.isLibrary(true)) {
Zotero.debug("Can't drag into same library root");
return false;
}
// Make sure there's at least one item that's not already in this destination
if (treeRow.isCollection()) {
if (treeRow.ref.hasItem(item.id)) {
Zotero.debug("Item " + item.id + " already exists in collection");
continue;
}
skip = false;
continue;
}
}
if (skip) {
Zotero.debug("Drag skipped");
return false;
}
return true;
}
else if (dataType == 'text/x-moz-url' || dataType == 'application/x-moz-file') {
if (treeRow.isSearch() || treeRow.isPublications()) {
return false;
}
if (dataType == 'application/x-moz-file') {
// Don't allow folder drag
if (data[0].isDirectory()) {
return false;
}
// Don't allow drop if no permissions
if (!treeRow.filesEditable) {
return false;
}
}
return true;
}
else if (dataType == 'zotero/collection') {
if (!treeRow.isLibrary(true) && !treeRow.isCollection()) {
return false;
}
let draggedCollectionID = data[0];
let draggedCollection = Zotero.Collections.get(draggedCollectionID);
// Dragging within same library
if (treeRow.ref.libraryID == draggedCollection.libraryID) {
// Collections cannot be dropped on themselves
if (draggedCollectionID == treeRow.ref.id) {
return false;
}
// Nor in their children
if (draggedCollection.hasDescendent('collection', treeRow.ref.id)) {
return false;
}
}
return true;
}
}
return false;
}
async canDropCheckAsync(row, orient, dataTransfer) {
//Zotero.debug("Row is " + row + "; orient is " + orient);
var dragData = Zotero.DragDrop.getDataFromDataTransfer(dataTransfer);
if (!dragData) {
Zotero.debug("No drag data");
return false;
}
var dataType = dragData.dataType;
var data = dragData.data;
if (orient == 0) {
var treeRow = this.getRow(row); //the collection we are dragging over
if (dataType == 'zotero/item' && treeRow.isBucket()) {
return true;
}
if (dataType == 'zotero/item') {
var ids = data;
var items = Zotero.Items.get(ids);
var skip = true;
for (let i=0; i<items.length; i++) {
let item = items[i];
// Cross-library drag
if (treeRow.ref.libraryID != item.libraryID) {
let linkedItem = await item.getLinkedItem(treeRow.ref.libraryID, true);
if (linkedItem && !linkedItem.deleted) {
// For drag to root, skip if linked item exists
if (treeRow.isLibrary(true)) {
Zotero.debug("Linked item " + linkedItem.key + " already exists "
+ "in library " + treeRow.ref.libraryID);
continue;
}
// For drag to collection
else if (treeRow.isCollection()) {
// skip if linked item is already in it
if (treeRow.ref.hasItem(linkedItem.id)) {
Zotero.debug("Linked item " + linkedItem.key + " already exists "
+ "in collection");
continue;
}
// or if linked item is a child item
else if (!linkedItem.isTopLevelItem()) {
Zotero.debug("Linked item " + linkedItem.key + " already exists "
+ "as child item");
continue;
}
}
}
skip = false;
continue;
}
// Intra-library drags have already been vetted by canDrop(). This 'break' should be
// changed to a 'continue' if any asynchronous checks that stop the drag are added above
skip = false;
break;
}
if (skip) {
Zotero.debug("Drag skipped");
return false;
}
}
else if (dataType == 'zotero/collection') {
let draggedCollectionID = data[0];
let draggedCollection = Zotero.Collections.get(draggedCollectionID);
// Dragging a collection to a different library
if (treeRow.ref.libraryID != draggedCollection.libraryID) {
// Disallow if linked collection already exists
if (await draggedCollection.getLinkedCollection(treeRow.ref.libraryID, true)) {
Zotero.debug("Linked collection already exists in library");
return false;
}
let descendents = draggedCollection.getDescendents(false, 'collection');
for (let descendent of descendents) {
descendent = Zotero.Collections.get(descendent.id);
// Disallow if linked collection already exists for any subcollections
//
// If this is allowed in the future for the root collection,
// need to allow drag only to root
if (await descendent.getLinkedCollection(treeRow.ref.libraryID, true)) {
Zotero.debug("Linked subcollection already exists in library");
return false;
}
}
}
}
}
return true;
}
async onDrop(event, index) {
const treeRow = this.getRow(index);
this._dropRow = null;
this.tree.invalidate();
let orient = Zotero.DragDrop.currentOrientation;
let row = this._rowMap[treeRow.id];
let dataTransfer = event.dataTransfer;
if (!dataTransfer.dropEffect || dataTransfer.dropEffect == "none"
|| !(await this.canDropCheckAsync(row, orient, dataTransfer))) {
return false;
}
var dragData = Zotero.DragDrop.getDataFromDataTransfer(dataTransfer);
if (!dragData) {
Zotero.debug("No drag data");
return false;
}
var dropEffect = dragData.dropEffect;
var dataType = dragData.dataType;
var data = dragData.data;
var sourceTreeRow = Zotero.DragDrop.getDragSource(dataTransfer);
var targetTreeRow = treeRow;
var copyOptions = {
tags: Zotero.Prefs.get('groups.copyTags'),
childNotes: Zotero.Prefs.get('groups.copyChildNotes'),
childLinks: Zotero.Prefs.get('groups.copyChildLinks'),
childFileAttachments: Zotero.Prefs.get('groups.copyChildFileAttachments'),
annotations: Zotero.Prefs.get('groups.copyAnnotations'),
};
var copyItem = async function (item, targetLibraryID, options) {
var targetLibraryType = Zotero.Libraries.get(targetLibraryID).libraryType;
// Check if there's already a copy of this item in the library
var linkedItem = await item.getLinkedItem(targetLibraryID, true);
if (linkedItem) {
// If linked item is in the trash, undelete it and remove it from collections
// (since it shouldn't be restored to previous collections)
if (linkedItem.deleted) {
linkedItem.setCollections();
linkedItem.deleted = false;
await linkedItem.save({
skipSelect: true
});
}
return linkedItem.id;
/*
// TODO: support tags, related, attachments, etc.
// Overlay source item fields on unsaved clone of linked item
var newItem = item.clone(false, linkedItem.clone(true));
newItem.setField('dateAdded', item.dateAdded);
newItem.setField('dateModified', item.dateModified);
var diff = newItem.diff(linkedItem, false, ["dateAdded", "dateModified"]);
if (!diff) {
// Check if creators changed
var creatorsChanged = false;
var creators = item.getCreators();
var linkedCreators = linkedItem.getCreators();
if (creators.length != linkedCreators.length) {
Zotero.debug('Creators have changed');
creatorsChanged = true;
}
else {
for (var i=0; i<creators.length; i++) {
if (!creators[i].ref.equals(linkedCreators[i].ref)) {
Zotero.debug('changed');
creatorsChanged = true;
break;
}
}
}
if (!creatorsChanged) {
Zotero.debug("Linked item hasn't changed -- skipping conflict resolution");
continue;
}
}
toReconcile.push([newItem, linkedItem]);
continue;
*/
}
// Standalone attachment
if (item.isAttachment()) {
var linkMode = item.attachmentLinkMode;
// Skip linked files
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
Zotero.debug("Skipping standalone linked file attachment on drag");
return false;
}
if (!targetTreeRow.filesEditable) {
Zotero.debug("Skipping standalone file attachment on drag");
return false;
}
let newAttachment = await Zotero.Attachments.copyAttachmentToLibrary(item, targetLibraryID);
if (options.annotations) {
await Zotero.Items.copyChildItems(item, newAttachment);
}
return newAttachment.id;
}
// Create new clone item in target library
var newItem = item.clone(targetLibraryID, { skipTags: !options.tags });
var newItemID = await newItem.save({
skipSelect: true
});
// Record link
await newItem.addLinkedItem(item);
if (item.isNote()) {
if (Zotero.Libraries.get(newItem.libraryID).filesEditable) {
await Zotero.Notes.copyEmbeddedImages(item, newItem);
}
return newItemID;
}
// For regular items, add child items if prefs and permissions allow
// Child notes
if (options.childNotes) {
var noteIDs = item.getNotes();
var notes = Zotero.Items.get(noteIDs);
for (let note of notes) {
let newNote = note.clone(targetLibraryID, { skipTags: !options.tags });
newNote.parentID = newItemID;
await newNote.save({
skipSelect: true
})
if (Zotero.Libraries.get(newNote.libraryID).filesEditable) {
await Zotero.Notes.copyEmbeddedImages(note, newNote);
}
await newNote.addLinkedItem(note);
}
}
// Child attachments
if (options.childLinks || options.childFileAttachments) {
var attachmentIDs = item.getAttachments();
var attachments = Zotero.Items.get(attachmentIDs);
for (let attachment of attachments) {
var linkMode = attachment.attachmentLinkMode;
// Skip linked files
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
Zotero.debug("Skipping child linked file attachment on drag");
continue;
}
// Skip imported files if we don't have pref and permissions
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
if (!options.childLinks) {
Zotero.debug("Skipping child link attachment on drag");
continue;
}
}
else {
if (!options.childFileAttachments
|| (!targetTreeRow.filesEditable && !targetTreeRow.isPublications())) {
Zotero.debug("Skipping child file attachment on drag");
continue;
}
}
let newAttachment = await Zotero.Attachments.copyAttachmentToLibrary(
attachment, targetLibraryID, newItemID
);
if (options.annotations) {
await Zotero.Items.copyChildItems(attachment, newAttachment);
}
}
}
return newItemID;
};
var targetLibraryID = targetTreeRow.ref.libraryID;
var targetCollectionID = targetTreeRow.isCollection() ? targetTreeRow.ref.id : false;
if (dataType == 'zotero/collection') {
var droppedCollection = await Zotero.Collections.getAsync(data[0]);
// Collection drag between libraries
if (targetLibraryID != droppedCollection.libraryID) {
await Zotero.DB.executeTransaction(async function () {
var copyCollections = async function (descendents, parentID, addItems) {
for (var desc of descendents) {
// Collections
if (desc.type == 'collection') {
var c = await Zotero.Collections.getAsync(desc.id);
let newCollection = c.clone(targetLibraryID);
if (parentID) {
newCollection.parentID = parentID;
}
var collectionID = await newCollection.save();
// Record link
await newCollection.addLinkedCollection(c);
// Recursively copy subcollections
if (desc.children.length) {
await copyCollections(desc.children, collectionID, addItems);
}
}
// Items
else {
var item = await Zotero.Items.getAsync(desc.id);
var id = await copyItem(item, targetLibraryID, copyOptions);
// Standalone attachments might not get copied
if (!id) {
continue;
}
// Mark copied item for adding to collection
if (parentID) {
let parentItems = addItems.get(parentID);
if (!parentItems) {
parentItems = [];
addItems.set(parentID, parentItems);
}
// If source item is a top-level non-regular item (which can exist in a
// collection) but target item is a child item (which can't), add
// target item's parent to collection instead
if (!item.isRegularItem()) {
let targetItem = await Zotero.Items.getAsync(id);
let targetItemParentID = targetItem.parentItemID;
if (targetItemParentID) {
id = targetItemParentID;
}
}
parentItems.push(id);
}
}
}
};
var collections = [{
id: droppedCollection.id,
children: droppedCollection.getDescendents(true),
type: 'collection'
}];
var addItems = new Map();
await copyCollections(collections, targetCollectionID, addItems);
for (let [collectionID, items] of addItems.entries()) {
let collection = await Zotero.Collections.getAsync(collectionID);
await collection.addItems(items);
}
// TODO: add subcollections and subitems, if they don't already exist,
// and display a warning if any of the subcollections already exist
});
}
// Collection drag within a library
else {
droppedCollection.parentID = targetCollectionID;
await droppedCollection.saveTx();
}
}
else if (dataType == 'zotero/item') {
var ids = data;
if (ids.length < 1) {
return;
}
if (targetTreeRow.isBucket()) {
targetTreeRow.ref.uploadItems(ids);
return;
}
var items = await Zotero.Items.getAsync(ids);
if (items.length == 0) {
return;
}
if (items[0] instanceof Zotero.FeedItem) {
if (!(targetTreeRow.isCollection() || targetTreeRow.isLibrary() || targetTreeRow.isGroup())) {
return;
}
let promises = [];
for (let item of items) {
// No transaction, because most time is spent traversing urls
promises.push(item.translate(targetLibraryID, targetCollectionID))
}
return Zotero.Promise.all(promises);
}
if (targetTreeRow.isPublications()) {
items = Zotero.Items.keepParents(items);
let io = window.ZoteroPane.showPublicationsWizard(items);
if (!io) {
return;
}
copyOptions.childNotes = io.includeNotes;
copyOptions.childFileAttachments = io.includeFiles;
copyOptions.childLinks = true;
copyOptions.annotations = false;
['keepRights', 'license', 'licenseName'].forEach(function (field) {
copyOptions[field] = io[field];
});
}
let newItems = [];
let newIDs = [];
let toMove = [];
// TODO: support items coming from different sources?
let sameLibrary = items[0].libraryID == targetLibraryID
for (let item of items) {
if (!item.isTopLevelItem()) {
continue;
}
newItems.push(item);
if (sameLibrary) {
newIDs.push(item.id);
toMove.push(item.id);
}
}
if (!sameLibrary) {
let toReconcile = [];
await Zotero.Utilities.Internal.forEachChunkAsync(
newItems,
100,
function (chunk) {
return Zotero.DB.executeTransaction(async function () {
for (let item of chunk) {
var id = await copyItem(item, targetLibraryID, copyOptions)
// Standalone attachments might not get copied
if (!id) {
continue;
}
newIDs.push(id);
}
});
}
);
if (toReconcile.length) {
let sourceName = Zotero.Libraries.getName(items[0].libraryID);
let targetName = Zotero.Libraries.getName(targetLibraryID);
let io = {
dataIn: {
type: "item",
captions: [
// TODO: localize
sourceName,
targetName,
"Merged Item"
],
objects: toReconcile
}
};
/*
if (type == 'item') {
if (!Zotero.Utilities.isEmpty(changedCreators)) {
io.dataIn.changedCreators = changedCreators;
}
}
*/
window.openDialog('chrome://zotero/content/merge.xhtml', '', 'chrome,modal,centerscreen', io);
await Zotero.DB.executeTransaction(async function () {
// DEBUG: This probably needs to be updated if this starts being used
for (let obj of io.dataOut) {
await obj.ref.save();
}
});
}
}
// Add items to target collection
if (targetCollectionID) {
let ids = newIDs.filter(itemID => Zotero.Items.get(itemID).isTopLevelItem());
await Zotero.DB.executeTransaction(async function () {
let collection = await Zotero.Collections.getAsync(targetCollectionID);
await collection.addItems(ids);
}.bind(this));
}
else if (targetTreeRow.isPublications()) {
await Zotero.Items.addToPublications(newItems, copyOptions);
}
// If moving, remove items from source collection
if (dropEffect == 'move' && toMove.length) {
if (!sameLibrary) {
throw new Error("Cannot move items between libraries");
}
if (!sourceTreeRow || !sourceTreeRow.isCollection()) {
throw new Error("Drag source must be a collection for move action");
}
await Zotero.DB.executeTransaction(async function () {
await sourceTreeRow.ref.removeItems(toMove);
}.bind(this));
}
}
else if (dataType == 'text/x-moz-url' || dataType == 'application/x-moz-file') {
// See note in onDragOver() above
if (dataType == 'application/x-moz-file' && Zotero.isMac) {
if (event.metaKey) {
if (event.altKey) {
dropEffect = 'link';
}
else {
dropEffect = 'move';
}
}
else {
dropEffect = 'copy';
}
}
var targetLibraryID = targetTreeRow.ref.libraryID;
if (targetTreeRow.isCollection()) {
var parentCollectionID = targetTreeRow.ref.id;
}
else {
var parentCollectionID = false;
}
var addedItems = [];
for (var i=0; i<data.length; i++) {
var file = data[i];
let item;
if (dataType == 'text/x-moz-url') {
var url = data[i];
// Still string, so remote URL
if (typeof file == 'string') {
window.ZoteroPane.addItemFromURL(url, 'temporaryPDFHack', null, row); // TODO: don't do this
continue;
}
// Otherwise file, so fall through
}
if (dropEffect == 'link') {
item = await Zotero.Attachments.linkFromFile({
file: file,
collections: parentCollectionID ? [parentCollectionID] : undefined
});
}
else {
item = await Zotero.Attachments.importFromFile({
file: file,
libraryID: targetLibraryID,
collections: parentCollectionID ? [parentCollectionID] : undefined
});
// If moving, delete original file
if (dropEffect == 'move') {
try {
file.remove(false);
}
catch (e) {
Zotero.logError("Error deleting original file " + file.path + " after drag");
}
}
}
addedItems.push(item);
}
// Automatically retrieve metadata for PDFs and ebooks
Zotero.RecognizeDocument.autoRecognizeItems(addedItems);
}
}
////////////////////////////////////////////////////////////////////////////////
///
/// Private methods
///
////////////////////////////////////////////////////////////////////////////////
isContainer = (index) => {
return this.getRow(index).isContainer();
}
isContainerOpen = (index) => {
return this.getRow(index).isOpen;
}
isContainerEmpty = (index) => {
var treeRow = this.getRow(index);
if (treeRow.isLibrary()) {
return false;
}
if (treeRow.isBucket()) {
return true;
}
if (treeRow.isGroup()) {
var libraryID = treeRow.ref.libraryID;
return !treeRow.ref.hasCollections()
&& !treeRow.ref.hasSearches()
// Duplicate Items not shown
&& (this.hideSources.indexOf('duplicates') != -1
|| this._virtualCollectionLibraries.duplicates[libraryID] === false)
// Unfiled Items not shown
&& this._virtualCollectionLibraries.unfiled[libraryID] === false
&& this.hideSources.indexOf('trash') != -1;
}
if (treeRow.isCollection()) {
return !treeRow.ref.hasChildCollections();
}
else if (treeRow.isFeeds()) {
// Hidden when empty
return false;
}
return true;
}
isSelectable = index => {
let treeRow = this.getRow(index);
return treeRow && !(treeRow.isSeparator() || treeRow.isHeader());
}
_closeContainer(row, skipMap) {
if (!this.isContainerOpen(row) || this.isContainerEmpty(row)) return;
this.selection.selectEventsSuppressed = true;
var level = this.getLevel(row);
var nextRow = row + 1;
// Remove child rows
while ((nextRow < this._rows.length) && (this.getLevel(nextRow) > level)) {
this._removeRow(nextRow, true);
}
this.selection.selectEventsSuppressed = false;
this._rows[row].isOpen = false;
this._refreshRowMap();
this._saveOpenStates();
this.tree.invalidate();
}
_getIcon(index) {
let iconName = this.getIconName(index);
return iconName
? getCSSIcon(iconName)
: document.createElement('span');
}
/**
* Persist the current open/closed state of rows to a pref
*/
_saveOpenStates = async function() {
var state = this._containerState;
// Every so often, remove obsolete rows
if (Math.random() < 1/20) {
Zotero.debug("Purging sourceList.persist");
for (var id in state) {
var m = id.match(/^C([0-9]+)$/);
if (m) {
if (!(await Zotero.Collections.getAsync(parseInt(m[1])))) {
delete state[id];
}
continue;
}
var m = id.match(/^G([0-9]+)$/);
if (m) {
if (!Zotero.Groups.get(parseInt(m[1]))) {
delete state[id];
}
continue;
}
}
}
for (var i = 0, len = this._rows.length; i < len; i++) {
var treeRow = this.getRow(i);
if (!treeRow.isContainer()) {
continue;
}
var open = this.isContainerOpen(i);
if ((!open && treeRow.isCollection())) {
delete state[treeRow.id];
continue;
}
state[treeRow.id] = open;
}
this._containerState = state;
this._storeOpenStates(state);
};
_storeOpenStates = Zotero.Utilities.debounce(function(state) {
Zotero.Prefs.set("sourceList.persist", JSON.stringify(state));
})
// When collections are renamed or deleted during search, more than
// a single row can be filtered out (if a child matching the filter is deleted,
// its non-matching parent is deleted too). To select the right row
// after such actions, the index should be offset by the total number of rows removed
_calculateOffsetForRowSelection(id) {
let rowIndex = this.getRowIndexByID(id);
let offset = 0;
let parentRowIndex = this.getParentIndex(rowIndex);
while (parentRowIndex > 0) {
let parentRow = this.getRow(parentRowIndex);
if (parentRow.depth < 1 || this._matchesFilter(parentRow.ref).matchesFilter) {
break;
}
parentRowIndex = this.getParentIndex(parentRowIndex);
offset += 1;
}
return offset;
}
/**
* Set collection filter and refresh collectionTree to only include
* rows that match the filter. Rows that do not match the filter but have children that do
* are displayed as context rows. All relevant rows are toggled open. Selection is kept
* on the currently selected row if any.
* @param {String} filterText - Text that rows have to contain to match the filter
* @param {Bool} scrollToSelected - Allow scrolling to the currently selected row.
*/
async setFilter(filterText, scrollToSelected) {
this._filter = filterText.toLowerCase();
let currentRow = this.getRow(this.selection.focused) || this._hiddenFocusedRow;
let currentRowDisplayed = currentRow && this._includedInTree(currentRow.ref);
// If current row does not match any filters, it'll be hidden, so clear selection
if (!currentRowDisplayed) {
this.selection.clearSelection();
}
await this.reload();
if (currentRow) {
// Special treatment for when there are no filter matches
// Otherwise, selection.focused does not get updated by selectByID, which breaks ZoteroPane.
if (this._rows.length == 1) {
this.selection.select(0);
}
// Re-select previously selected row
else {
await this.selectByID(currentRow.id, scrollToSelected);
}
}
let promise = this.waitForSelect();
this.selection.selectEventsSuppressed = false;
await promise;
// Expand all container rows to see all search results
if (!this._isFilterEmpty()) {
for (let i = 0; i < this._rows.length; i++) {
let row = this._rows[i];
if (this.isContainer(i) && this._matchesFilter(row.ref).hasChildMatchingFilter && !row.isOpen) {
await this.toggleOpenState(i);
}
}
}
}
/**
* Creates an extra hidden row to keep focus on it when a currently focused row does not match the filter.
* Required to avoid changes in itemTree during collection search.
* @param {CollectionTreeRow[]} rows - Rows of collectionTree.
* @return {CollectionTreeRow|null}
*/
_createFocusedFilteredRow(rows) {
if (this._isFilterEmpty()) {
return null;
}
let focused = this.getRow(this.selection.focused);
// If row already exists - nothing to add
let focusedRowAlreadyExists = rows.some(row => row.id == focused?.id);
if (!focused || focusedRowAlreadyExists) {
return null;
}
return new Zotero.CollectionTreeRow(this, focused.type, focused.ref, 0, false);
}
focusedRowMatchesFilter() {
let row = this.getRow(this.selection.focused);
return this._matchesFilter(row.ref).matchesFilter;
}
filterEquals(filterValue) {
return filterValue === this._filter;
}
_isFilterEmpty() {
return this._filter === "";
}
clearFilter() {
// Clear the search field
if (collectionsSearchField.value.length) {
collectionsSearchField.value = '';
ZoteroPane.handleCollectionSearchInput();
return null;
}
// If the search field is empty, focus the collection tree
return document.getElementById('collection-tree');
}
/**
* Select and focus the first row matching the collection filter. If it is a child of a collapsed
* container(s), the container(s) on the way will be toggled open.
* @param {Bool} scrollToLibrary - Scroll to the very top after selection
*/
async focusFirstMatchingRow(scrollToLibrary) {
let index = 0;
let row = this.getRow(index);
while (index < this._rows.length) {
row = this.getRow(index);
if (this._matchesFilter(row.ref).matchesFilter) {
// The matching row may not be selectable (e.g. Group Libraries header).
// In that case, just keep looking for the next row
let wasSelected = await this.selectByID(row.id);
if (wasSelected) {
// If selection happened, move focus onto the tree
this.tree.focus();
if (scrollToLibrary) {
this.ensureRowIsVisible(0);
}
return;
}
}
// Selection did not happen - keep going. Open any containers on the way
if (!this.isContainerOpen(index)) {
await this.toggleOpenState(index);
}
index += 1;
}
}
/**
* Select the last row matching collection filter. Opens any container rows on the way.
*/
async focusLastMatchingRow() {
let loopCounter = 0;
let offset = 1;
if (this._hiddenFocusedRow) {
offset = 2;
}
let lastRow = this.getRow(this._rows.length - offset);
while (lastRow && !lastRow.isOpen && this._matchesFilter(lastRow.ref).hasChildMatchingFilter) {
await this.toggleOpenState(this._rows.length - 1);
lastRow = this.getRow(this._rows.length - offset);
// Sanity check to make sure we are not stuck in an infinite loop if something goes wrong
loopCounter++;
if (loopCounter > 100) {
Zotero.debug("Reasonable collections depth exceeded");
return false;
}
}
return this.selectByID(lastRow.id);
}
/**
* Select and focus the next row after startIndex that matches the filter
*
* @param {Int} startIndex - Index of the row from which the search of the next matching row begins
* @param {Bool} up - Move focus up the collection tree. Unless true, default direction is down.
* @return {Bool} true if focus was shifted, false if selected row was not changed
*/
async focusNextMatchingRow(startIndex, up) {
if (this._isFilterEmpty()) {
return false;
}
// Increment or decrement the row index depending on direction
let moveInDirection = (rowIndex) => {
return up ? rowIndex - 1 : rowIndex + 1;
};
let rowIndex = startIndex;
while (rowIndex < this._rows.length && rowIndex >= 0) {
let nextIndex = moveInDirection(rowIndex);
let nextRow = this.getRow(nextIndex);
// If there is not next row or the next row is hidden (which should never happen), stop
if (!nextRow || nextRow.id == this._hiddenFocusedRow?.id) {
// If we stopped going up, make sure the library or group row is visible
if (up && !this.tree.rowIsVisible(0)) {
this.ensureRowIsVisible(0);
}
return false;
}
// Select the row if it's matching the filter unless it's a header or separator
if (this._matchesFilter(nextRow.ref).matchesFilter
&& !["separator", "header"].includes(nextRow.type)) {
this.tree.focus();
return this.selectByID(nextRow.id);
}
rowIndex = nextIndex;
}
return false;
}
/**
* Check if a given object matches filter or has children that match the filter.
*
* @param {Collection|Search|Library|Group} object - Object to check
* @param {Bool} resetCache - Ignore and reset existing cache value for that object
* @return {Object} { matchesFilter: Bool, hasChildMatchingFilter: Bool }
* matchesFilter = object itself matches the filter
* hasChildMatchingFilter = object has children that match the filter
*/
_matchesFilter(object, resetCache = false) {
// When the filter is empty, everything matches
if (this._isFilterEmpty()) {
return { matchesFilter: true, hasChildMatchingFilter: true };
}
// Handle separator or group headers
if ((object.libraryID === undefined || object.libraryID === -1) && !object.updateFeed) {
return { matchesFilter: true, hasChildMatchingFilter: false };
}
// Define objectID to be used in cache
let objectID = "L" + object.libraryID;
if (['Collection', 'Search', 'Feed'].includes(object._ObjectType)) {
objectID = object._ObjectType[0] + object.id;
}
else if (object.updateFeed && !object.libraryID) {
// Special ID for 'Feeds' parent row of all feeds
objectID = 'feeds';
}
// If we found the filter status during previous recursions, return that
if (this._filterResultsCache[objectID] && !resetCache) {
return this._filterResultsCache[objectID];
}
// Filtering is case insensitive
let objectName = (object.name || "").toLowerCase();
// Special treatment to fetch the name for My Library or Feeds
if (objectID[0] == 'L' && object._ObjectType !== "Group") {
objectName = Zotero.getString('pane.collections.library').toLowerCase();
}
else if (objectID == 'feeds') {
objectName = Zotero.getString('pane.collections.feedLibraries').toLowerCase();
}
let filterValue = this._filter;
let childrenToSearch = [];
if (object._ObjectType == 'Collection') {
let collection = Zotero.Collections.get(object.id);
childrenToSearch = collection.getChildCollections();
}
else if (object.libraryID && !["Search", "Feeds"].includes(object._ObjectType)) {
childrenToSearch = Zotero.Collections.getByLibrary(object.libraryID);
childrenToSearch = childrenToSearch.concat(Zotero.Searches.getByLibrary(object.libraryID));
}
else if (objectID == 'feeds') {
childrenToSearch = Zotero.Feeds.getAll();
}
let matchesFilter = objectName.includes(filterValue);
// For libraries, groups and collections, recursively check if they have any children that match the filter
let hasChildMatchingFilter = childrenToSearch.some((child) => {
let { matchesFilter, hasChildMatchingFilter } = this._matchesFilter(child);
return matchesFilter || hasChildMatchingFilter;
});
// Save filter status to cache
this._filterResultsCache[objectID] = {
matchesFilter: matchesFilter,
hasChildMatchingFilter: hasChildMatchingFilter
};
return this._filterResultsCache[objectID];
}
// A shortcut to call this._matchesFilter to check if a given object should be present
// in collectionTree or not
_includedInTree(object, resetCache) {
let { matchesFilter, hasChildMatchingFilter } = this._matchesFilter(object, resetCache);
return matchesFilter || hasChildMatchingFilter;
}
async _expandRow(rows, row, forceOpen) {
var treeRow = rows[row];
var level = rows[row].level;
var isLibrary = treeRow.isLibrary(true);
var isCollection = treeRow.isCollection();
var isFeeds = treeRow.isFeeds();
var libraryID = treeRow.ref.libraryID;
if (treeRow.isPublications() || treeRow.isFeed()) {
return false;
}
var collections = treeRow.getChildren();
if (isLibrary) {
var savedSearches = await Zotero.Searches.getAll(libraryID);
// Virtual collections default to showing if not explicitly hidden
var showDuplicates = this.hideSources.indexOf('duplicates') == -1
&& this._virtualCollectionLibraries.duplicates[libraryID] !== false;
var showUnfiled = this._virtualCollectionLibraries.unfiled[libraryID] !== false;
var showRetracted = this._virtualCollectionLibraries.retracted[libraryID] !== false
&& Zotero.Retractions.libraryHasRetractedItems(libraryID);
var showPublications = libraryID == Zotero.Libraries.userLibraryID;
var showTrash = this.hideSources.indexOf('trash') == -1;
}
else {
var savedSearches = [];
var showDuplicates = false;
var showUnfiled = false;
var showRetracted = false;
var showPublications = false;
var showTrash = false;
}
// If not a manual open and either the library is set to be collapsed or this is a collection that isn't explicitly opened,
// set the initial state to closed
if (!forceOpen
&& (this._containerState[treeRow.id] === false
|| (isCollection && !this._containerState[treeRow.id]))) {
rows[row].isOpen = false;
return 0;
}
var startOpen = !!(collections.length || savedSearches.length || showDuplicates || showUnfiled || showRetracted || showTrash);
// If this isn't a manual open, set the initial state depending on whether
// there are child nodes
if (!forceOpen) {
rows[row].isOpen = startOpen;
}
if (!startOpen) {
return 0;
}
var newRows = 0;
// Add collections
for (var i = 0, len = collections.length; i < len; i++) {
// Skip collections in trash
if (collections[i].deleted) continue;
// Skip collections that do not match the filter and have no matching children
if (!this._includedInTree(collections[i])) continue;
let beforeRow = row + 1 + newRows;
rows.splice(beforeRow, 0,
new Zotero.CollectionTreeRow(this, isFeeds ? 'feed' : 'collection', collections[i], level + 1));
newRows++;
// Recursively expand child collections that should be open
newRows += await this._expandRow(rows, beforeRow);
}
if (isCollection) {
return newRows;
}
// Add searches
for (var i = 0, len = savedSearches.length; i < len; i++) {
// Skip searches in trash
if (savedSearches[i].deleted) continue;
// Skip searches not matching the filter
if (!this._includedInTree(savedSearches[i])) continue;
rows.splice(row + 1 + newRows, 0,
new Zotero.CollectionTreeRow(this, 'search', savedSearches[i], level + 1));
newRows++;
}
if (showPublications && this._isFilterEmpty()) {
// Add "My Publications"
rows.splice(row + 1 + newRows, 0,
new Zotero.CollectionTreeRow(this,
'publications',
{
libraryID,
treeViewID: "P" + libraryID
},
level + 1
)
);
newRows++;
}
// Duplicate items
if (showDuplicates && this._isFilterEmpty()) {
let d = new Zotero.Duplicates(libraryID);
rows.splice(row + 1 + newRows, 0,
new Zotero.CollectionTreeRow(this, 'duplicates', d, level + 1));
newRows++;
}
// Unfiled items
if (showUnfiled && this._isFilterEmpty()) {
let s = new Zotero.Search;
s.libraryID = libraryID;
s.name = Zotero.getString('pane.collections.unfiled');
s.addCondition('libraryID', 'is', libraryID);
s.addCondition('unfiled', 'true');
rows.splice(row + 1 + newRows, 0,
new Zotero.CollectionTreeRow(this, 'unfiled', s, level + 1));
newRows++;
}
// Retracted items
if (showRetracted && this._isFilterEmpty()) {
let s = new Zotero.Search;
s.libraryID = libraryID;
s.name = Zotero.getString('pane.collections.retracted');
s.addCondition('libraryID', 'is', libraryID);
s.addCondition('retracted', 'true');
rows.splice(row + 1 + newRows, 0,
new Zotero.CollectionTreeRow(this, 'retracted', s, level + 1, treeRow));
newRows++;
}
if (showTrash && this._isFilterEmpty()) {
let deletedItems = await Zotero.Items.getDeleted(libraryID, true);
if (deletedItems.length || Zotero.Prefs.get("showTrashWhenEmpty")) {
var ref = {
libraryID: libraryID
};
rows.splice(row + 1 + newRows, 0,
new Zotero.CollectionTreeRow(this, 'trash', ref, level + 1));
newRows++;
}
this._trashNotEmpty[libraryID] = !!deletedItems.length;
}
return newRows;
}
/**
* Add a row in the appropriate place
*
* @param {String} objectType
* @param {Integer} id - collectionID
* @return {Integer|false} - Index at which the row was added, or false if it wasn't added
*/
async _addSortedRow(objectType, id) {
let beforeRow;
if (objectType == 'collection') {
let collection = await Zotero.Collections.getAsync(id);
let parentID = collection.parentID;
// If parent isn't visible, don't add
if (parentID && this._rowMap["C" + parentID] === undefined) {
return false;
}
let libraryID = collection.libraryID;
let startRow;
if (parentID) {
startRow = this._rowMap["C" + parentID];
}
else {
startRow = this._rowMap['L' + libraryID];
}
// If container isn't open, don't add
if (!this.isContainerOpen(startRow)) {
return false;
}
let level = this.getLevel(startRow) + 1;
// If container is empty, just add after
if (this.isContainerEmpty(startRow)) {
beforeRow = startRow + 1;
}
else {
// Get all collections at the same level that don't have a different parent
startRow++;
loop:
for (let i = startRow; i < this._rows.length; i++) {
let treeRow = this.getRow(i);
beforeRow = i;
// Since collections come first, if we reach something that's not a collection,
// stop
if (!treeRow.isCollection()) {
break;
}
let rowLevel = this.getLevel(i);
if (rowLevel < level) {
break;
}
else {
// Fast forward through subcollections
while (rowLevel > level) {
beforeRow = ++i;
if (i == this._rows.length || !this.getRow(i).isCollection()) {
break loop;
}
treeRow = this.getRow(i);
rowLevel = this.getLevel(i);
// If going from lower level to a row higher than the target level, we found
// our place:
//
// - 1
// - 3
// - 4
// - 2 <<<< 5, a sibling of 3, goes above here
if (rowLevel < level) {
break loop;
}
}
if (Zotero.localeCompare(treeRow.ref.name, collection.name) > 0) {
break;
}
}
}
}
if (this._includedInTree(collection)) {
this._addRow(
new Zotero.CollectionTreeRow(this, 'collection', collection, level),
beforeRow
);
}
}
else if (objectType == 'search') {
let search = Zotero.Searches.get(id);
let libraryID = search.libraryID;
let startRow = this._rowMap['L' + libraryID];
// If container isn't open, don't add
if (!this.isContainerOpen(startRow)) {
return false;
}
let level = this.getLevel(startRow) + 1;
// If container is empty, just add after
if (this.isContainerEmpty(startRow)) {
beforeRow = startRow + 1;
}
else {
startRow++;
for (let i = startRow; i < this._rows.length; i++) {
let treeRow = this.getRow(i);
beforeRow = i;
// If we've reached something other than collections, stop
if (treeRow.isSearch()) {
// If current search sorts after, stop
if (Zotero.localeCompare(treeRow.ref.name, search.name) > 0) {
break;
}
}
// If it's not a search and it's not a collection, stop
else if (!treeRow.isCollection()) {
break;
}
}
}
if (this._includedInTree(search)) {
this._addRow(
new Zotero.CollectionTreeRow(this, 'search', search, level),
beforeRow
);
}
}
return beforeRow;
}
_refreshRowMap() {
super._refreshRowMap();
let customRowHeights = [];
for (var i = 0; i < this.rowCount; i++) {
let row = this.getRow(i);
if (row.isSeparator()) {
customRowHeights.push([i, this._separatorHeight]);
}
}
this._customRowHeights = customRowHeights;
this.tree.updateCustomRowHeights(this._customRowHeights);
}
_selectAfterRowRemoval(row) {
// If last row was selected, stay on the last row
if (row >= this._rows.length) {
row = this._rows.length - 1;
};
// Make sure the selection doesn't land on a separator (e.g. deleting last feed)
while (row >= 0 && !this.isSelectable(row)) {
// move up, since we got shifted down
row--;
}
this.selection.select(row);
}
}
module.exports = CollectionTree;