this.getRowIndexByID(id) === false)) {
// In duplicates view, select the next set on delete
if (collectionTreeRow.isDuplicates()) {
if (this._rows[previousFirstSelectedRow]) {
var itemID = this._rows[previousFirstSelectedRow].ref.id;
var setItemIDs = collectionTreeRow.ref.getSetItemsByItemID(itemID);
this.selectItems(setItemIDs);
reselect = true;
}
}
else {
// If this was a child item and the next item at this
// position is a top-level item, move selection one row
// up to select a sibling or parent
if (ids.length == 1 && previousFirstSelectedRow > 0) {
let previousItem = Zotero.Items.get(ids[0]);
if (previousItem && !previousItem.isTopLevelItem()) {
if (this._rows[previousFirstSelectedRow]
&& this.getLevel(previousFirstSelectedRow) == 0) {
previousFirstSelectedRow--;
}
}
}
if (previousFirstSelectedRow !== undefined && previousFirstSelectedRow in this._rows) {
this.selection.select(previousFirstSelectedRow);
reselect = true;
}
// If no item at previous position, select last item in list
else if (this._rows.length > 0 && this._rows[this._rows.length - 1]) {
this.selection.select(this._rows.length - 1);
reselect = true;
}
}
}
else {
await this._restoreSelection(savedSelection);
reselect = true;
}
}
this._rememberScrollPosition(scrollPosition);
}
this._updateIntroText();
// If we made changes to the selection (including reselecting the same item, which will register as
// a selection when selectEventsSuppressed is set to false), wait for a select event on the tree
// view (e.g., as triggered by itemsView.runListeners('select') in ZoteroPane::itemSelected())
// before returning. This guarantees that changes are reflected in the middle and right-hand panes
// before returning from the save transaction.
//
// If no onselect handler is set on the tree element, as is the case in the Advanced Search window,
// the select listeners never get called, so don't wait.
if (reselect && this.props.onSelectionChange) {
var selectPromise = this.waitForSelect();
this.selection.selectEventsSuppressed = false;
Zotero.debug("Yielding for select promise"); // TEMP
return selectPromise;
}
else {
this.selection.selectEventsSuppressed = false;
}
}
handleActivate = (event, indices) => {
// Ignore double-clicks in duplicates view on everything except attachments
let items = indices.map(index => this.getRow(index).ref);
if (event.button == 0 && this.collectionTreeRow.isDuplicates()) {
if (items.length != 1 || !items[0].isAttachment()) {
return false;
}
}
this.props.onActivate(event, items);
}
/**
* @param event {InputEvent}
* @returns {boolean} false to prevent any handling by the virtualized-table
*/
handleKeyDown = (event) => {
if (Zotero.locked) {
return false;
}
// Handle arrow keys specially on multiple selection, since
// otherwise the tree just applies it to the last-selected row
if (this.selection.count > 1 && ["ArrowLeft", "ArrowRight"].includes(event.key)) {
if (event.key == "ArrowRight") {
this.expandSelectedRows();
}
else {
this.collapseSelectedRows();
}
return false;
}
if (!event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey && COLORED_TAGS_RE.test(event.key)) {
let libraryID = this.collectionTreeRow.ref.libraryID;
let position = parseInt(event.key) - 1;
// When 0 is pressed, remove all colored tags
if (position == -1) {
let items = this.getSelectedItems();
return Zotero.Tags.removeColoredTagsFromItems(items);
}
let colorData = Zotero.Tags.getColorByPosition(libraryID, position);
// If a color isn't assigned to this number or any
// other numbers, allow key navigation
if (!colorData) {
return !Zotero.Tags.getColors(libraryID).size;
}
var items = this.getSelectedItems();
// Async operation and we're not waiting for the promise
// since we need to return false below to prevent virtualized-table from handling the event
const _promise = Zotero.Tags.toggleItemsListTags(items, colorData.name);
return false;
}
else if (event.key == 'a'
&& !event.altKey
&& !event.shiftKey
&& (Zotero.isMac ? (event.metaKey && !event.ctrlKey) : event.ctrlKey)) {
if (!this.collectionTreeRow.isPublications()) {
this.expandMatchParents(this._searchParentIDs);
}
}
else if (event.key == '+' && !(event.ctrlKey || event.altKey || event.metaKey)) {
this.expandAllRows();
return false;
}
else if (event.key == '-' && !(event.shiftKey || event.ctrlKey
|| event.altKey || event.metaKey)) {
this.collapseAllRows();
return false;
}
return true;
}
render() {
const itemsPaneMessageHTML = this._itemsPaneMessage || this.props.emptyMessage;
const showMessage = !this.collectionTreeRow || this._itemsPaneMessage;
const itemsPaneMessage = ( this.props.dragAndDrop && this.onDragOver(e, -1)}
onDrop={e => this.props.dragAndDrop && this.onDrop(e, -1)}
onClick={(e) => {
if (e.target.dataset.href) {
window.ZoteroPane.loadURI(e.target.dataset.href);
}
if (e.target.dataset.action == 'open-sync-prefs') {
Zotero.Utilities.Internal.openPreferences('zotero-prefpane-sync');
}
}}
className={"items-tree-message"}
style={{ display: showMessage ? "flex" : "none" }}
// Due to some collision between React and the XUL environment
// setting innerHTML on a cached React node triggers an XML
// parsing error god knows where. So on every refresh we set a new
// key for the element, forcing it to be recreated. This shouldn't
// be a major performance concern since we're not calling #forceUpdate()
// that often and even if we did it's just a single div here.
key={Date.now()}
dangerouslySetInnerHTML={{ __html: itemsPaneMessageHTML }}>
);
let virtualizedTable = ();
if (this.collectionTreeRow) {
virtualizedTable = React.createElement(VirtualizedTable,
{
getRowCount: () => this._rows.length,
id: this.id,
ref: ref => this.tree = ref,
treeboxRef: ref => this._treebox = ref,
renderItem: this._renderItem.bind(this),
hide: showMessage,
key: "virtualized-table",
label: Zotero.getString('pane.items.title'),
showHeader: true,
columns: this._getColumns(),
onColumnPickerMenu: this._displayColumnPickerMenu,
onColumnSort: this.collectionTreeRow.isFeed() ? null : this._handleColumnSort,
getColumnPrefs: this._getColumnPrefs,
storeColumnPrefs: this._storeColumnPrefs,
containerWidth: this.domEl.clientWidth,
multiSelect: true,
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),
onDragOver: e => this.props.dragAndDrop && this.onDragOver(e, -1),
onDrop: e => this.props.dragAndDrop && this.onDrop(e, -1),
onKeyDown: this.handleKeyDown,
onActivate: this.handleActivate,
onItemContextMenu: e => this.props.onContextMenu(e),
}
);
}
Zotero.debug(`itemTree.render(). Displaying ${showMessage ? "Item Pane Message" : "Item Tree"}`);
return [
itemsPaneMessage,
virtualizedTable
];
}
async changeCollectionTreeRow(collectionTreeRow) {
if (this._locked) return;
if (!collectionTreeRow) {
this.tree = null;
this._treebox = null;
return this.clearItemsPaneMessage();
}
Zotero.debug(`itemTree.changeCollectionTreeRow(): ${collectionTreeRow.id}`);
this._itemTreeLoadingDeferred = Zotero.Promise.defer();
this.setItemsPaneMessage(Zotero.getString('pane.items.loading'));
let newId = "item-tree-" + this.props.id + "-" + collectionTreeRow.visibilityGroup;
if (this.id != newId && this.props.persistColumns) {
await this._writeColumnPrefsToFile(true);
this.id = newId;
await this._loadColumnPrefsFromFile();
}
this.id = newId;
this.collectionTreeRow = collectionTreeRow;
this.selection.selectEventsSuppressed = true;
this.collectionTreeRow.view.itemTreeView = this;
// Ensures that an up to date this._columns is set
this._getColumns();
this.selection.clearSelection();
await this.refresh();
if (Zotero.CollectionTreeCache.error) {
return this.setItemsPaneMessage(Zotero.getString('pane.items.loadError'));
}
else {
this.clearItemsPaneMessage();
}
this.forceUpdate(() => {
this.selection.selectEventsSuppressed = false;
this._updateIntroText();
this._itemTreeLoadingDeferred.resolve();
});
await this._itemTreeLoadingDeferred.promise;
}
async refreshAndMaintainSelection(clearItemsPaneMessage=true) {
if (this.selection) {
this.selection.selectEventsSuppressed = true;
}
const selection = this.getSelectedItems(true);
await this.refresh();
clearItemsPaneMessage && this.clearItemsPaneMessage();
await new Promise((resolve) => {
this.forceUpdate(() => {
if (this.tree) {
this.tree.invalidate();
this._restoreSelection(selection);
if (this.selection) {
this.selection.selectEventsSuppressed = false;
}
}
resolve();
});
});
}
async selectItem(id, noRecurse) {
return this.selectItems([id], noRecurse);
}
async selectItems(ids, noRecurse) {
if (!ids.length) return 0;
// If no row map, we're probably in the process of switching collections,
// so store the items to select on the item group for later
if (!this._rowMap) {
if (this.collectionTreeRow) {
this.collectionTreeRow.itemsToSelect = ids;
Zotero.debug("_rowMap not yet set; not selecting items");
return 0;
}
Zotero.debug('Item group not found and no row map in ItemTree.selectItem() -- discarding select', 2);
return 0;
}
var idsToSelect = [];
for (let id of ids) {
let row = this._rowMap[id];
let item = Zotero.Items.get(id);
// Can't select a deleted item if we're not in the trash
if (item.deleted && !this.collectionTreeRow.isTrash()) {
continue;
}
// Get the row of the parent, if there is one
let parent = item.parentItemID;
let parentRow = parent && this._rowMap[parent];
// If row with id isn't visible, check to see if it's hidden under a parent
if (row == undefined) {
if (!parent || parentRow === undefined) {
// No parent -- it's not here
// Clear the quick search and tag selection and try again (once)
if (!noRecurse && window.ZoteroPane) {
let cleared1 = await window.ZoteroPane.clearQuicksearch();
let cleared2 = window.ZoteroPane.tagSelector
&& window.ZoteroPane.tagSelector.clearTagSelection();
if (cleared1 || cleared2) {
return this.selectItems(ids, true);
}
}
Zotero.debug(`Couldn't find row for item ${id} -- not selecting`);
continue;
}
// If parent is already open and we haven't found the item, the child
// hasn't yet been added to the view, so close parent to allow refresh
await this._closeContainer(parentRow);
// Open the parent
await this.toggleOpenState(parentRow);
}
// Since we're opening containers, we still need to reference by id
idsToSelect.push(id);
}
// Now that all items have been expanded, get associated rows
var rowsToSelect = [];
for (let id of idsToSelect) {
let row = this._rowMap[id];
if (row === undefined) {
Zotero.debug(`Item ${id} not in row map -- skipping`);
continue;
}
rowsToSelect.push(row);
}
if (!rowsToSelect.length) {
return 0;
}
// If items are already selected, just scroll to the top-most one
var selectedRows = this.selection.selected;
if (rowsToSelect.length == selectedRows.size && rowsToSelect.every(row => selectedRows.has(row))) {
this.ensureRowsAreVisible(rowsToSelect);
return rowsToSelect.length;
}
// Single item
if (rowsToSelect.length == 1) {
// this.selection.select() triggers the tree onSelect handler attribute, which calls
// ZoteroPane.itemSelected(), which calls ZoteroItemPane.viewItem(), which refreshes the
// itembox. But since the 'onselect' doesn't handle promises, itemSelected() isn't waited for
// here, which means that 'yield selectItem(itemID)' continues before the itembox has been
// refreshed. To get around this, we wait for a select event that's triggered by
// itemSelected() when it's done.
let promise;
let nothingToSelect = false;
try {
if (!this.selection.selectEventsSuppressed) {
promise = this.waitForSelect();
}
nothingToSelect = !this.selection.select(rowsToSelect[0]);
}
catch (e) {
Zotero.logError(e);
}
if (!nothingToSelect && promise) {
await promise;
}
}
// Multiple items
else {
this.selection.clearSelection();
this.selection.selectEventsSuppressed = true;
var lastStart = 0;
for (let i = 0, len = rowsToSelect.length; i < len; i++) {
if (i == len - 1 || rowsToSelect[i + 1] != rowsToSelect[i] + 1) {
this.selection.rangedSelect(rowsToSelect[lastStart], rowsToSelect[i], true);
lastStart = i + 1;
}
}
this.selection.selectEventsSuppressed = false;
}
this.ensureRowsAreVisible(rowsToSelect);
return rowsToSelect.length;
}
/*
* Sort the items by the currently sorted column.
*/
async sort(itemIDs) {
var t = new Date;
// For child items, just close and reopen parents
if (itemIDs) {
let parentItemIDs = new Set();
let skipped = [];
for (let itemID of itemIDs) {
let row = this._rowMap[itemID];
let item = this.getRow(row).ref;
let parentItemID = item.parentItemID;
if (!parentItemID) {
skipped.push(itemID);
continue;
}
parentItemIDs.add(parentItemID);
}
let parentRows = [...parentItemIDs].map(itemID => this._rowMap[itemID]);
parentRows.sort();
for (let i = parentRows.length - 1; i >= 0; i--) {
let row = parentRows[i];
this._closeContainer(row, true, true);
this.toggleOpenState(row, true, true);
}
this._refreshRowMap();
let numSorted = itemIDs.length - skipped.length;
if (numSorted) {
Zotero.debug(`Sorted ${numSorted} child items by parent toggle`);
}
if (!skipped.length) {
return;
}
itemIDs = skipped;
if (numSorted) {
Zotero.debug(`${itemIDs.length} items left to sort`);
}
}
var primaryField = this.getSortField();
var sortFields = this.getSortFields();
var order = this.getSortDirection(sortFields);
var collation = Zotero.getLocaleCollation();
var sortCreatorAsString = Zotero.Prefs.get('sortCreatorAsString');
Zotero.debug(`Sorting items list by ${sortFields.join(", ")} ${order == 1 ? "ascending" : "descending"} `
+ (itemIDs && itemIDs.length
? `for ${itemIDs.length} ` + Zotero.Utilities.pluralize(itemIDs.length, ['item', 'items'])
: ""));
// Set whether rows with empty values should be displayed last,
// which may be different for primary and secondary sorting.
var emptyFirst = {};
switch (primaryField) {
case 'title':
emptyFirst.title = true;
break;
// When sorting by title we want empty titles at the top, but if not
// sorting by title, empty titles should sort to the bottom so that new
// empty items don't get sorted to the middle of the items list.
default:
emptyFirst.title = false;
}
// Cache primary values while sorting, since base-field-mapped getField()
// calls are relatively expensive
var cache = {};
sortFields.forEach(x => cache[x] = {});
// Get the display field for a row (which might be a placeholder title)
function getField(field, row) {
var item = row.ref;
switch (field) {
case 'title':
return Zotero.Items.getSortTitle(item.getDisplayTitle());
case 'hasAttachment':
if (item.isFileAttachment()) {
var state = item.fileExistsCached() ? 1 : -1;
}
else if (item.isRegularItem()) {
var state = item.getBestAttachmentStateCached();
}
else {
return 0;
}
// Make sort order present, missing, empty when ascending
if (state === 1) {
state = 2;
}
else if (state === -1) {
state = 1;
}
return state;
case 'numNotes':
return row.numNotes(false, true) || 0;
// Use unformatted part of date strings (YYYY-MM-DD) for sorting
case 'date':
var val = row.ref.getField('date', true, true);
if (val) {
val = val.substr(0, 10);
if (val.indexOf('0000') == 0) {
val = "";
}
}
return val;
case 'year':
var val = row.ref.getField('date', true, true);
if (val) {
val = val.substr(0, 4);
if (val == '0000') {
val = "";
}
}
return val;
default:
return row.ref.getField(field, false, true);
}
}
var includeTrashed = this.collectionTreeRow.isTrash();
function fieldCompare(a, b, sortField) {
var aItemID = a.id;
var bItemID = b.id;
var fieldA = cache[sortField][aItemID];
var fieldB = cache[sortField][bItemID];
switch (sortField) {
case 'firstCreator':
return creatorSort(a, b);
case 'itemType':
var typeA = Zotero.ItemTypes.getLocalizedString(a.ref.itemTypeID);
var typeB = Zotero.ItemTypes.getLocalizedString(b.ref.itemTypeID);
return (typeA > typeB) ? 1 : (typeA < typeB) ? -1 : 0;
default:
if (fieldA === undefined) {
cache[sortField][aItemID] = fieldA = getField(sortField, a);
}
if (fieldB === undefined) {
cache[sortField][bItemID] = fieldB = getField(sortField, b);
}
// Display rows with empty values last
if (!emptyFirst[sortField]) {
if(fieldA === '' && fieldB !== '') return 1;
if(fieldA !== '' && fieldB === '') return -1;
}
if (sortField == 'hasAttachment') {
return fieldB - fieldA;
}
return collation.compareString(1, fieldA, fieldB);
}
}
var rowSort = function (a, b) {
for (let i = 0; i < sortFields.length; i++) {
let cmp = fieldCompare(a, b, sortFields[i]);
if (cmp !== 0) {
return cmp;
}
}
return 0;
};
var creatorSortCache = {};
// Regexp to extract the whole string up to an optional "and" or "et al."
var andEtAlRegExp = new RegExp(
// Extract the beginning of the string in non-greedy mode
"^.+?"
// up to either the end of the string, "et al." at the end of string
+ "(?=(?: " + Zotero.getString('general.etAl').replace('.', '\.') + ")?$"
// or ' and '
+ "| " + Zotero.getString('general.and') + " "
+ ")"
);
function creatorSort(a, b) {
var itemA = a.ref;
var itemB = b.ref;
//
// Try sorting by the first name in the firstCreator field, since we already have it
//
// For sortCreatorAsString mode, just use the whole string
//
var aItemID = a.id,
bItemID = b.id,
fieldA = creatorSortCache[aItemID],
fieldB = creatorSortCache[bItemID];
var prop = sortCreatorAsString ? 'firstCreator' : 'sortCreator';
var sortStringA = itemA[prop];
var sortStringB = itemB[prop];
if (fieldA === undefined) {
let firstCreator = Zotero.Items.getSortTitle(sortStringA);
if (sortCreatorAsString) {
var fieldA = firstCreator;
}
else {
var matches = andEtAlRegExp.exec(firstCreator);
fieldA = matches ? matches[0] : '';
}
creatorSortCache[aItemID] = fieldA;
}
if (fieldB === undefined) {
let firstCreator = Zotero.Items.getSortTitle(sortStringB);
if (sortCreatorAsString) {
var fieldB = firstCreator;
}
else {
matches = andEtAlRegExp.exec(firstCreator);
fieldB = matches ? matches[0] : '';
}
creatorSortCache[bItemID] = fieldB;
}
if (fieldA === "" && fieldB === "") {
return 0;
}
// Display rows with empty values last
if (fieldA === '' && fieldB !== '') return 1;
if (fieldA !== '' && fieldB === '') return -1;
return collation.compareString(1, fieldA, fieldB);
}
var savedSelection = this.getSelectedItems(true);
// Save open state and close containers before sorting
var openItemIDs = this._saveOpenState(true);
// Sort specific items
if (itemIDs) {
let idsToSort = new Set(itemIDs);
this._rows.sort((a, b) => {
// Don't re-sort existing items. This assumes a stable sort(), which is the case in Firefox
// but not Chrome/v8.
if (!idsToSort.has(a.ref.id) && !idsToSort.has(b.ref.id)) return 0;
return rowSort(a, b) * order;
});
}
// Full sort
else {
this._rows.sort((a, b) => rowSort(a, b) * order);
}
this._refreshRowMap();
this._rememberOpenState(openItemIDs);
this._restoreSelection(savedSelection);
if (this.tree && !this.selection.selectEventsSuppressed) {
this.tree.invalidate();
}
var numSorted = itemIDs ? itemIDs.length : this._rows.length;
Zotero.debug(`Sorted ${numSorted} ${Zotero.Utilities.pluralize(numSorted, ['item', 'items'])} `
+ `in ${new Date - t} ms`);
}
async setFilter(type, data) {
if (this._locked) return;
switch (type) {
case 'search':
this.collectionTreeRow.setSearch(data);
break;
case 'tags':
this.collectionTreeRow.setTags(data);
break;
default:
throw ('Invalid filter type in setFilter');
}
await this.refreshAndMaintainSelection();
};
ensureRowsAreVisible(indices) {
if (!this._treebox) return;
let itemHeight = this.tree._rowHeight;
const pageLength = Math.floor(this._treebox.getWindowHeight() / itemHeight);
const maxBuffer = 5;
indices = Array.from(indices).filter(index => index < this._rows.length);
indices.sort((a, b) => a - b);
// If all rows are already visible, don't do anything
if (indices.every(x => this.tree.rowIsVisible(x))) {
//Zotero.debug("All indices are already visible");
return;
}
var indicesWithParents = [];
for (let row of indices) {
let parent = this.getParentIndex(row);
indicesWithParents.push(parent != -1 ? parent : row);
}
// If we can fit all parent indices in view, do that
for (let buffer = maxBuffer; buffer >= 0; buffer--) {
if (indicesWithParents[indicesWithParents.length - 1] - indicesWithParents[0] - buffer < pageLength) {
//Zotero.debug(`We can fit all parent indices with buffer ${buffer}`);
this.ensureRowIsVisible(indicesWithParents[0] - buffer);
this.ensureRowIsVisible(indicesWithParents[indicesWithParents.length-1] + buffer);
return;
}
}
// If we can fit all indices in view, do that
for (let buffer = maxBuffer; buffer >= 0; buffer--) {
if (indices[indices.length - 1] - indices[0] - buffer < pageLength) {
//Zotero.debug(`We can fit all indices with buffer ${buffer}`);
this.ensureRowIsVisible(indices[0] - buffer);
this.ensureRowIsVisible(indices[indices.length-1] + buffer);
return;
}
}
// If the first parent row isn't in view and we have enough room, make it visible, trying to
// put it five indices from the top
if (indices[0] != indicesWithParents[0]) {
for (let buffer = maxBuffer; buffer >= 0; buffer--) {
if (indices[0] - indicesWithParents[0] - buffer <= pageLength) {
//Zotero.debug(`Scrolling to first parent minus ${buffer}`);
this.ensureRowIsVisible(indicesWithParents[0] + buffer);
this.ensureRowIsVisible(indicesWithParents[0] - buffer);
return;
}
}
}
// Otherwise just put the first row at the top
//Zotero.debug("Scrolling to first row " + Math.max(indices[0] - maxBuffer, 0));
this.ensureRowIsVisible(indices[0] - maxBuffer);
this.ensureRowIsVisible(indices[0] + maxBuffer);
}
toggleOpenState = async (index, skipRowMapRefresh=false) => {
// Shouldn't happen but does if an item is dragged over a closed
// container until it opens and then released, since the container
// is no longer in the same place when the spring-load closes
if (!this.isContainer(index)) {
return;
}
if (this.isContainerOpen(index)) {
return this._closeContainer(index, skipRowMapRefresh, true);
}
if (!skipRowMapRefresh) {
var savedSelection = this.getSelectedItems(true);
}
var count = 0;
var level = this.getLevel(index);
//
// Open
//
var item = this.getRow(index).ref;
//Get children
var includeTrashed = this.collectionTreeRow.isTrash();
var attachments = item.getAttachments(includeTrashed);
var notes = item.getNotes(includeTrashed);
var newRows;
if (attachments.length && notes.length) {
newRows = notes.concat(attachments);
}
else if (attachments.length) {
newRows = attachments;
}
else if (notes.length) {
newRows = notes;
}
if (newRows) {
newRows = Zotero.Items.get(newRows);
for (let i = 0; i < newRows.length; i++) {
count++;
this._addRow(
new ItemTreeRow(newRows[i], level + 1, false),
index + i + 1,
true
);
}
}
this._rows[index].isOpen = true;
if (count == 0) {
return;
}
if (!skipRowMapRefresh) {
Zotero.debug('Refreshing item row map');
this._refreshRowMap();
await this._refreshPromise;
this._restoreSelection(savedSelection, false, true);
this.tree.invalidate();
}
}
expandMatchParents(searchParentIDs) {
// Expand parents of child matches
if (!this._searchMode) {
return;
}
var savedSelection = this.getSelectedItems(true);
for (var i=0; i b - a);
for (const index of indices) {
if (this.isContainer(index) && !this.isContainerOpen(index)) {
this.toggleOpenState(index, true);
}
}
this._refreshRowMap();
this._restoreSelection(selectedItems, false, indices.length == 1);
this.tree.invalidate();
this.selection.selectEventsSuppressed = false;
}
collapseSelectedRows() {
this.selection.selectEventsSuppressed = true;
const selectedItems = this.getSelectedItems(true);
// Reverse sort and so we don't mess up indices of subsequent
// items when collapsing
const indices = Array.from(this.selection.selected).sort((a, b) => b - a);
for (const index of indices) {
if (this.isContainer(index)) {
this._closeContainer(index, true);
}
}
this._refreshRowMap();
this._restoreSelection(selectedItems, false, true);
this.tree.invalidate();
this.selection.selectEventsSuppressed = false;
}
// //////////////////////////////////////////////////////////////////////////////
//
// Data access methods
//
// //////////////////////////////////////////////////////////////////////////////
getCellText(index, column) {
return this._getRowData(index)[column];
}
getRowString(index) {
return this.getCellText(index, this.getSortField())
}
async deleteSelection(force) {
if (arguments.length > 1) {
throw new Error("ItemTree.deleteSelection() no longer takes two parameters");
}
if (this.selection.count == 0) {
return;
}
try {
this.selection.selectEventsSuppressed = true;
// Collapse open items
for (var i = 0; i < this.rowCount; i++) {
if (this.selection.isSelected(i) && this.isContainer(i)) {
await this._closeContainer(i, false, true);
}
}
this._refreshRowMap();
this.tree.invalidate();
// Create an array of selected items
var ids = Array.from(this.selection.selected).map(index => this.getRow(index).id);
var collectionTreeRow = this.collectionTreeRow;
if (collectionTreeRow.isBucket()) {
collectionTreeRow.ref.deleteItems(ids);
}
if (collectionTreeRow.isTrash()) {
await Zotero.Items.erase(ids);
}
else if (collectionTreeRow.isLibrary(true) || force) {
await Zotero.Items.trashTx(ids);
}
else if (collectionTreeRow.isCollection()) {
await Zotero.DB.executeTransaction(async () => {
await collectionTreeRow.ref.removeItems(ids);
});
}
else if (collectionTreeRow.isPublications()) {
await Zotero.Items.removeFromPublications(ids.map(id => Zotero.Items.get(id)));
}
}
finally {
this.selection.selectEventsSuppressed = false;
}
}
getSelectedItems(asIDs) {
var items = this.selection ? Array.from(this.selection.selected) : [];
items = items.filter(index => index < this._rows.length);
try {
if (asIDs) return items.map(index => this.getRow(index).ref.id);
return items.map(index => this.getRow(index).ref);
} catch (e) {
Zotero.debug(items);
throw e;
}
}
saveSelection() {
Zotero.debug("ItemTree::saveSelection() is deprecated -- use getSelectedItems(true)");
return this.getSelectedItems(true);
}
/**
* Returns an array of items of visible items in current sort order
*
* @param {Boolean} asIDs - Return itemIDs
* @return {Zotero.Item[]|Integer[]} - An array of Zotero.Item objects or itemIDs
*/
getSortedItems(asIDs) {
return this._rows.map(row => asIDs ? row.ref.id : row.ref);
}
getSortDirection(sortFields) {
sortFields = sortFields || this.getSortFields();
if (this.collectionTreeRow.isFeed()) {
return Zotero.Prefs.get('feeds.sortAscending') ? 1 : -1;
}
const columns = this._getColumns();
for (const field of sortFields) {
const col = columns.find(c => c.dataKey == field);
if (col) {
return col.sortDirection || 1;
}
}
return 1;
}
getSortField() {
if (this.collectionTreeRow.isFeed()) {
return 'id';
}
var column = this._sortedColumn;
if (!column) {
column = this._getColumns().find(col => !col.hidden);
}
// zotero-items-column-_________
return column.dataKey;
}
getSortFields() {
var fields = [this.getSortField()];
var secondaryField = this._getSecondarySortField();
if (secondaryField) {
fields.push(secondaryField);
}
try {
var fallbackFields = Zotero.Prefs.get('fallbackSort')
.split(',')
.map((x) => x.trim())
.filter((x) => x !== '');
}
catch (e) {
Zotero.debug(e, 1);
Cu.reportError(e);
// This should match the default value for the fallbackSort pref
var fallbackFields = ['firstCreator', 'date', 'title', 'dateAdded'];
}
fields = Zotero.Utilities.arrayUnique(fields.concat(fallbackFields));
// If date appears after year, remove it, unless it's the explicit secondary sort
var yearPos = fields.indexOf('year');
if (yearPos != -1) {
let datePos = fields.indexOf('date');
if (datePos > yearPos && secondaryField != 'date') {
fields.splice(datePos, 1);
}
}
return fields;
}
/**
* @param index {Integer}
* @param selectAll {Boolean} Whether the selection is part of a select-all event
* @returns {Boolean}
*/
isSelectable = (index, selectAll=false) => {
if (!selectAll || !this._searchMode || this.collectionTreeRow.isPublications()) return true;
let row = this.getRow(index);
return row && this._searchItemIDs.has(row.id);
}
isContainer = (index) => {
return this.getRow(index).ref.isRegularItem();
}
isContainerOpen = (index) => {
return this.getRow(index).isOpen;
}
isContainerEmpty = (index) => {
if (this.regularOnly) {
return true;
}
var item = this.getRow(index).ref;
if (!item.isRegularItem()) {
return true;
}
var includeTrashed = this.collectionTreeRow.isTrash();
return item.numNotes(includeTrashed) === 0 && item.numAttachments(includeTrashed) == 0;
}
////////////////////////////////////////////////////////////////////////////////
///
/// Drag-and-drop methods
///
////////////////////////////////////////////////////////////////////////////////
/**
* Start a drag using HTML 5 Drag and Drop
*/
onDragStart = (event, index) => {
// See note in LibraryTreeView::setDropEffect()
if (Zotero.isWin || Zotero.isLinux) {
event.dataTransfer.effectAllowed = 'copyMove';
}
// Propagate selection before we set the drag image if dragging not one of the selected rows
if (!this.selection.isSelected(index)) {
this.selection.select(index);
}
// Set drag image
const dragElems = this.domEl.querySelectorAll('.selected');
for (let elem of dragElems) {
elem = elem.cloneNode(true);
elem.style.position = "initial";
this._dragImageContainer.appendChild(elem);
}
event.dataTransfer.setDragImage(this._dragImageContainer, 0, 0);
var itemIDs = this.getSelectedItems(true);
event.dataTransfer.setData("zotero/item", itemIDs);
var items = Zotero.Items.get(itemIDs);
Zotero.DragDrop.currentDragSource = this.collectionTreeRow;
// If at least one file is a non-web-link attachment and can be found,
// enable dragging to file system
var files = items
.filter(item => item.isAttachment())
.map(item => item.getFilePath())
.filter(path => path);
if (files.length) {
// Advanced multi-file drag (with unique filenames, which otherwise happen automatically on
// Windows but not Linux) and auxiliary snapshot file copying on macOS
let dataProvider;
if (Zotero.isMac) {
dataProvider = new Zotero.FileDragDataProvider(itemIDs);
}
for (let i = 0; i < files.length; i++) {
let file = Zotero.File.pathToFile(files[i]);
if (dataProvider) {
Zotero.debug("Adding application/x-moz-file-promise");
event.dataTransfer.mozSetDataAt("application/x-moz-file-promise", dataProvider, i);
}
// Allow dragging to filesystem on Linux and Windows
let uri;
if (!Zotero.isMac) {
Zotero.debug("Adding text/x-moz-url " + i);
let fph = Cc["@mozilla.org/network/protocol;1?name=file"]
.createInstance(Ci.nsIFileProtocolHandler);
uri = fph.getURLSpecFromFile(file);
event.dataTransfer.mozSetDataAt("text/x-moz-url", uri + '\n' + file.leafName, i);
}
// Allow dragging to web targets (e.g., Gmail)
Zotero.debug("Adding application/x-moz-file " + i);
event.dataTransfer.mozSetDataAt("application/x-moz-file", file, i);
if (Zotero.isWin) {
event.dataTransfer.mozSetDataAt("application/x-moz-file-promise-url", uri, i);
}
else if (Zotero.isLinux) {
// Don't create a symlink for an unmodified drag
event.dataTransfer.effectAllowed = 'copy';
}
}
}
// Get Quick Copy format for current URL (set via /ping from connector)
var format = Zotero.QuickCopy.getFormatFromURL(Zotero.QuickCopy.lastActiveURL);
Zotero.debug("Dragging with format " + format);
var exportCallback = function(obj, worked) {
if (!worked) {
Zotero.log(Zotero.getString("fileInterface.exportError"), 'warning');
return;
}
var text = obj.string.replace(/\r\n/g, "\n");
event.dataTransfer.setData("text/plain", text);
}
format = Zotero.QuickCopy.unserializeSetting(format);
try {
if (format.mode == 'export') {
Zotero.QuickCopy.getContentFromItems(items, format, exportCallback);
}
else if (format.mode == 'bibliography') {
var content = Zotero.QuickCopy.getContentFromItems(items, format, null, event.shiftKey);
if (content) {
if (content.html) {
event.dataTransfer.setData("text/html", content.html);
}
event.dataTransfer.setData("text/plain", content.text);
}
}
else {
Cu.reportError("Invalid Quick Copy mode");
}
}
catch (e) {
Zotero.debug(e);
Cu.reportError(e + " with '" + format.id + "'");
}
}
/**
* We use this to set the drag action, which is used by view.canDrop(),
* based on the view's canDropCheck() and modifier keys.
*/
onDragOver = (event, row) => {
try {
event.preventDefault();
event.stopPropagation();
var previousOrientation = Zotero.DragDrop.currentOrientation;
Zotero.DragDrop.currentOrientation = getDragTargetOrient(event);
Zotero.debug(`Dragging over item ${row} with ${Zotero.DragDrop.currentOrientation}, drop row: ${this._dropRow}`);
var target = event.target;
if (target.classList.contains('items-tree-message')) {
let doc = target.ownerDocument;
// Consider a drop on the items pane message box (e.g., when showing the welcome text)
// a drop on the items tree
if (target.firstChild.hasAttribute('allowdrop')) {
target = doc.querySelector('#zotero-items-tree treechildren');
}
else {
this.setDropEffect(event, "none");
return false;
}
}
if (!this.canDropCheck(row, Zotero.DragDrop.currentOrientation, event.dataTransfer)) {
this.setDropEffect(event, "none");
return false;
}
if (event.dataTransfer.getData("zotero/item")) {
var sourceCollectionTreeRow = Zotero.DragDrop.getDragSource();
if (sourceCollectionTreeRow) {
var targetCollectionTreeRow = this.collectionTreeRow;
if (!targetCollectionTreeRow) {
this.setDropEffect(event, "none");
return false;
}
if (sourceCollectionTreeRow.id == targetCollectionTreeRow.id) {
// If dragging from the same source, do a move
this.setDropEffect(event, "move");
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.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 = row;
} else {
this._dropRow = null;
}
if (prevDropRow != this._dropRow || previousOrientation != Zotero.DragDrop.currentOrientation) {
typeof prevDropRow == 'number' && this.tree.invalidateRow(prevDropRow);
this.tree.invalidateRow(row);
}
}
}
onDragEnd = () => {
this._dragImageContainer.innerHTML = "";
this._dropRow = null;
this.tree.invalidate();
}
onDragLeave = () => {
let dropRow = this._dropRow;
this._dropRow = null;
this.tree.invalidateRow(dropRow);
}
/**
* Called by treeRow.onDragOver() before setting the dropEffect
*/
canDropCheck = (row, orient, dataTransfer) => {
//Zotero.debug("Row is " + row + "; orient is " + orient);
var dragData = Zotero.DragDrop.getDataFromDataTransfer(dataTransfer);
if (!dragData) {
Zotero.debug("No drag data");
return false;
}
var dataType = dragData.dataType;
var data = dragData.data;
var collectionTreeRow = this.collectionTreeRow;
if (row != -1 && orient == 0) {
var rowItem = this.getRow(row).ref; // the item we are dragging over
// Cannot drop anything on attachments/notes
if (!rowItem.isRegularItem()) {
return false;
}
}
if (dataType == 'zotero/item') {
let items = Zotero.Items.get(data);
// Directly on a row
if (rowItem) {
var canDrop = false;
for (let item of items) {
// If any regular items, disallow drop
if (item.isRegularItem()) {
return false;
}
// Disallow cross-library child drag
if (item.libraryID != collectionTreeRow.ref.libraryID) {
return false;
}
// Only allow dragging of notes and attachments
// that aren't already children of the item
if (item.parentItemID != rowItem.id) {
canDrop = true;
}
}
return canDrop;
}
// In library, allow children to be dragged out of parent
else if (collectionTreeRow.isLibrary(true) || collectionTreeRow.isCollection()) {
for (let item of items) {
// Don't allow drag if any top-level items
if (item.isTopLevelItem()) {
return false;
}
// Don't allow web attachments to be dragged out of parents,
// but do allow PDFs for now so they can be recognized
if (item.isWebAttachment() && item.attachmentContentType != 'application/pdf') {
return false;
}
// Don't allow children to be dragged within their own parents
var parentItemID = item.parentItemID;
var parentIndex = this._rowMap[parentItemID];
if (row != -1 && this.getLevel(row) > 0) {
if (this.getRow(this.getParentIndex(row)).ref.id == parentItemID) {
return false;
}
}
// Including immediately after the parent
if (orient == 1) {
if (row == parentIndex) {
return false;
}
}
// And immediately before the next parent
if (orient == -1) {
var nextParentIndex = null;
for (var i = parentIndex + 1; i < this.rowCount; i++) {
if (this.getLevel(i) == 0) {
nextParentIndex = i;
break;
}
}
if (row === nextParentIndex) {
return false;
}
}
// Disallow cross-library child drag
if (item.libraryID != collectionTreeRow.ref.libraryID) {
return false;
}
}
return true;
}
return false;
}
else if (dataType == "text/x-moz-url" || dataType == 'application/x-moz-file') {
// Disallow direct drop on a non-regular item (e.g. note)
if (rowItem) {
if (!rowItem.isRegularItem()) {
return false;
}
}
// Don't allow drop into searches or publications
else if (collectionTreeRow.isSearch() || collectionTreeRow.isPublications()) {
return false;
}
return true;
}
return false;
};
/*
* Called when something's been dropped on or next to a row
*/
onDrop = async (event, row) => {
const dataTransfer = event.dataTransfer;
var orient = Zotero.DragDrop.currentOrientation;
if (row == -1) {
row = 0;
orient = -1;
}
this._dropRow = null;
Zotero.DragDrop.currentDragSource = null;
if (!dataTransfer.dropEffect || dataTransfer.dropEffect == "none") {
return false;
}
var dragData = Zotero.DragDrop.getDataFromDataTransfer(dataTransfer);
if (!dragData) {
Zotero.debug("No drag data");
return false;
}
var dropEffect = dragData.dropEffect;
var dataType = dragData.dataType;
var data = dragData.data;
var sourceCollectionTreeRow = Zotero.DragDrop.getDragSource(dataTransfer);
var collectionTreeRow = this.collectionTreeRow;
var targetLibraryID = collectionTreeRow.ref.libraryID;
if (dataType == 'zotero/item') {
var ids = data;
var items = Zotero.Items.get(ids);
if (items.length < 1) {
return;
}
// TEMP: This is always false for now, since cross-library drag
// is disallowed in canDropCheck()
//
// TODO: support items coming from different sources?
if (items[0].libraryID == targetLibraryID) {
var sameLibrary = true;
}
else {
var sameLibrary = false;
}
var toMove = [];
// Dropped directly on a row
if (orient == 0) {
// Set drop target as the parent item for dragged items
//
// canDrop() limits this to child items
var rowItem = this.getRow(row).ref; // the item we are dragging over
await Zotero.DB.executeTransaction(async function () {
for (let i=0; i event.stopPropagation());
twisty.addEventListener('mouseup', event => this.handleTwistyMouseUp(event, index),
{ passive: true });
}
const icon = this._getIcon(index);
icon.classList.add('cell-icon');
const item = this.getRow(index).ref;
let retracted = "";
if (Zotero.Retractions.isRetracted(item)) {
retracted = getDOMElement('IconCross');
retracted.classList.add("retracted");
}
let tags = item.getColoredTags().map(x => this._getTagSwatch(x.tag, x.color));
let textSpan = document.createElementNS("http://www.w3.org/1999/xhtml", 'span');
textSpan.className = "cell-text";
textSpan.innerText = data;
span.append(twisty, icon, retracted, ...tags, textSpan);
// Set depth indent
const depth = this.getLevel(index);
let firstChildIndent = 0;
if (column.ordinal == 0) {
firstChildIndent = 6;
}
span.style.paddingInlineStart = ((CHILD_INDENT * depth) + firstChildIndent) + 'px';
return span;
}
_renderHasAttachmentCell(index, data, column) {
let span = document.createElementNS("http://www.w3.org/1999/xhtml", 'span');
span.className = `cell ${column.className}`;
if (this.collectionTreeRow.isTrash()) return span;
const item = this.getRow(index).ref;
if ((!this.isContainer(index) || !this.isContainerOpen(index))
&& Zotero.Sync.Storage.getItemDownloadImageNumber(item)) {
return span;
}
if (this.isContainer(index)) {
if (item.isRegularItem()) {
const state = item.getBestAttachmentStateCached();
let icon = "";
if (state === 1) {
icon = getDOMElement('IconBulletBlue');
icon.classList.add('cell-icon');
}
else if (state === -1) {
icon = getDOMElement('IconBulletBlueEmpty');
icon.classList.add('cell-icon');
}
span.append(icon);
item.getBestAttachmentState()
// TODO: With no cell refreshing this is possibly somewhat inefficient
// Refresh cell when promise is fulfilled
.then(bestState => bestState != state && this.tree.invalidateRow(index));
}
}
if (item.isFileAttachment()) {
const exists = item.fileExistsCached();
let icon = "";
if (exists !== null) {
icon = exists ? getDOMElement('IconBulletBlue') : getDOMElement('IconBulletBlueEmpty');
icon.classList.add('cell-icon');
}
span.append(icon);
item.fileExists()
// TODO: With no cell refreshing this is possibly somewhat inefficient
// Refresh cell when promise is fulfilled
.then(realExists => realExists != exists && this.tree.invalidateRow(index));
}
return span;
}
_renderCell() {
return renderCell.apply(this, arguments);
}
_renderItem(index, selection, oldDiv=null, columns) {
let div;
if (oldDiv) {
div = oldDiv;
div.innerHTML = "";
}
else {
div = document.createElementNS("http://www.w3.org/1999/xhtml", 'div');
div.className = "row";
}
div.classList.toggle('selected', selection.isSelected(index));
div.classList.remove('drop', 'drop-before', 'drop-after');
const rowData = this._getRowData(index);
div.classList.toggle('context-row', !!rowData.contextRow);
div.classList.toggle('unread', !!rowData.unread);
if (this._dropRow == index) {
let span;
if (Zotero.DragDrop.currentOrientation != 0) {
span = document.createElementNS("http://www.w3.org/1999/xhtml", 'span');
span.className = Zotero.DragDrop.currentOrientation < 0 ? "drop-before" : "drop-after";
div.appendChild(span);
} else {
div.classList.add('drop');
}
}
for (let column of columns) {
if (column.hidden) continue;
if (column.primary) {
div.appendChild(this._renderPrimaryCell(index, rowData[column.dataKey], column));
}
else if (column.dataKey === 'hasAttachment') {
div.appendChild(this._renderHasAttachmentCell(index, rowData[column.dataKey], column));
}
else {
div.appendChild(this._renderCell(index, rowData[column.dataKey], column));
}
}
if (!oldDiv) {
if (this.props.dragAndDrop) {
div.setAttribute('draggable', true);
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 });
}
}
return div;
};
_handleSelectionChange = (selection, shouldDebounce) => {
// Update aria-activedescendant on the tree
if (this.collectionTreeRow.isDuplicates() && selection.count == 1) {
var itemID = this.getRow(selection.focused).ref.id;
var setItemIDs = this.collectionTreeRow.ref.getSetItemsByItemID(itemID);
// We are modifying the selection object directly here
// which won't trigger item updates
for (let id of setItemIDs) {
selection.selected.add(this._rowMap[id]);
this.tree.invalidateRow(this._rowMap[id]);
}
}
if (shouldDebounce) {
this._onSelectionChangeDebounced();
}
else {
this._onSelectionChange();
}
}
async _closeContainer(index, skipRowMapRefresh, dontEnsureRowsVisible=false) {
// isContainer == false shouldn't happen but does if an item is dragged over a closed
// container until it opens and then released, since the container is no longer in the same
// place when the spring-load closes
if (!this.isContainer(index)) return;
if (!this.isContainerOpen(index)) return;
if (!skipRowMapRefresh) {
var savedSelection = this.getSelectedItems(true);
}
var count = 0;
var level = this.getLevel(index);
// Remove child rows
while ((index + 1 < this._rows.length) && (this.getLevel(index + 1) > level)) {
// Skip the map update here and just refresh the whole map below,
// since we might be removing multiple rows
this._removeRow(index + 1, true);
count++;
}
this._rows[index].isOpen = false;
if (count == 0) {
return;
}
if (!skipRowMapRefresh) {
Zotero.debug('Refreshing item row map');
this._refreshRowMap();
await this._refreshPromise;
this._restoreSelection(savedSelection, false, dontEnsureRowsVisible);
this.tree.invalidate();
}
}
/**
* Returns an object describing the row data for each column.
* The keys are column dataKey properties and the entries are the corresponding data.
* @param index {Integer} the row index
* @returns {Object}
*/
_getRowData = (index) => {
var treeRow = this.getRow(index);
if (!treeRow) {
throw new Error(`Attempting to get row data for a non-existant tree row ${index}`);
}
var itemID = treeRow.id;
// If value is available, retrieve synchronously
if (this._rowCache[itemID]) {
return this._rowCache[itemID];
}
let row = {};
// Mark items not matching search as context rows, displayed in gray
if (this._searchMode && !this._searchItemIDs.has(itemID)) {
row.contextRow = true;
}
row.hasAttachment = "";
// Don't show pie for open parent items, since we show it for the
// child item
if (!this.isContainer(index) || !this.isContainerOpen(index)) {
var num = Zotero.Sync.Storage.getItemDownloadImageNumber(treeRow.ref);
row.hasAttachment = num === false ? "pie" : "pie" + num;
}
// Style unread items in feeds
if (treeRow.ref.isFeedItem && !treeRow.ref.isRead) {
row.unread = true;
}
row.itemType = Zotero.ItemTypes.getLocalizedString(treeRow.ref.itemTypeID);
// Year column is just date field truncated
row.year = treeRow.getField('date', true).substr(0, 4);
if (row.year) {
// Don't show anything for unparsed year
if (row.year === "0000") {
row.year = "";
}
// Show pre-1000 year without leading zeros
else if (row.year < 1000) {
row.year = parseInt(row.year);
}
}
row.numNotes = treeRow.numNotes() || "";
row.title = treeRow.ref.getDisplayTitle();
const columns = this.getColumns();
for (let col of columns) {
let key = col.dataKey;
let val = row[key];
if (val === undefined) {
val = treeRow.getField(key);
}
switch (key) {
// Format dates as short dates in proper locale order and locale time
// (e.g. "4/4/07 14:27:23")
case 'dateAdded':
case 'dateModified':
case 'accessDate':
case 'date':
if (key == 'date' && !this.collectionTreeRow.isFeed()) {
break;
}
if (val) {
let date = Zotero.Date.sqlToDate(val, true);
if (date) {
// If no time, interpret as local, not UTC
if (Zotero.Date.isSQLDate(val)) {
date = Zotero.Date.sqlToDate(val);
val = date.toLocaleDateString();
}
else {
val = date.toLocaleString();
}
}
else {
val = '';
}
}
}
row[key] = val;
}
return this._rowCache[itemID] = row;
}
_getColumnPrefs = () => {
if (!this.props.persistColumns) return {};
return this._columnPrefs || {};
}
_storeColumnPrefs = (prefs) => {
if (!this.props.persistColumns) return;
Zotero.debug(`Storing itemTree ${this.id} column prefs`, 2);
this._columnPrefs = prefs;
if (!this._columns) {
Zotero.debug(new Error(), 1);
}
this._columns = this._columns.map(column => Object.assign(column, prefs[column.dataKey]));
this._writeColumnPrefsToFile();
}
_loadColumnPrefsFromFile = async () => {
if (!this.props.persistColumns) return;
try {
let columnPrefs = await Zotero.File.getContentsAsync(COLUMN_PREFS_FILEPATH);
let persistSettings = JSON.parse(columnPrefs);
this._columnPrefs = persistSettings[this.id] || {};
}
catch (e) {
this._columnPrefs = {};
}
}
/**
* Writes column prefs to file, but is throttled to not do it more often than
* every 60s. Can use the force param to force write to file immediately.
* @param force {Boolean} force an immediate write to file without throttling
* @returns {Promise}
*/
_writeColumnPrefsToFile = async (force=false) => {
if (!this.props.persistColumns) return;
var writeToFile = async () => {
try {
let persistSettingsString = await Zotero.File.getContentsAsync(COLUMN_PREFS_FILEPATH);
var persistSettings = JSON.parse(persistSettingsString);
}
catch {
persistSettings = {};
}
persistSettings[this.id] = this._columnPrefs;
let prefString = JSON.stringify(persistSettings);
Zotero.debug(`Writing column prefs of length ${prefString.length} to file ${COLUMN_PREFS_FILEPATH}`);
return Zotero.File.putContentsAsync(COLUMN_PREFS_FILEPATH, prefString);
};
if (this._writeColumnsTimeout) {
clearTimeout(this._writeColumnsTimeout);
}
if (force) {
return writeToFile();
}
else {
this._writeColumnsTimeout = setTimeout(writeToFile, 60000);
}
};
_setLegacyColumnSettings(column) {
let persistSettings = JSON.parse(Zotero.Prefs.get('pane.persist') || "{}");
const legacyDataKey = "zotero-items-column-" + column.dataKey;
const legacyPersistSetting = persistSettings[legacyDataKey];
if (legacyPersistSetting) {
// Remove legacy pref
// TODO: uncomment once xul item tree fully phased out
// delete persistSettings[legacyDataKey];
for (const key in legacyPersistSetting) {
if (typeof legacyPersistSetting[key] == "string") {
if (key == 'sortDirection') {
legacyPersistSetting[key] = legacyPersistSetting[key] == 'ascending' ? 1 : -1;
}
else {
try {
legacyPersistSetting[key] = JSON.parse(legacyPersistSetting[key]);
} catch (e) {}
}
}
if (key == 'ordinal') {
legacyPersistSetting[key] /= 2;
}
}
Zotero.Prefs.set('pane.persist', JSON.stringify(persistSettings));
}
return Object.assign({}, column, legacyPersistSetting || {});
}
_getColumns() {
if (!this.collectionTreeRow) {
return [];
}
const visibilityGroup = this.collectionTreeRow.visibilityGroup;
const prefKey = this.id;
if (this._columnsId == prefKey) {
return this._columns;
}
this._columnsId = prefKey;
this._columns = [];
let columnsSettings = this._getColumnPrefs();
const columns = this.getColumns();
let hasDefaultIn = columns.some(column => 'defaultIn' in column);
for (let column of columns) {
if (this.props.persistColumns) {
if (column.disabledIn && column.disabledIn.includes(visibilityGroup)) continue;;
const columnSettings = columnsSettings[column.dataKey];
if (!columnSettings) {
column = this._setLegacyColumnSettings(column);
}
// Also includes a `hidden` pref and overrides the above if available
column = Object.assign({}, column, columnSettings || {});
if (column.sortDirection) {
this._sortedColumn = column;
}
// If column does not have an "ordinal" field it means it
// is newly added
if (!("ordinal" in column)) {
column.ordinal = columns.findIndex(c => c.dataKey == column.dataKey);
}
}
else {
column = Object.assign({}, column);
}
// Initial hidden value
if (!("hidden" in column)) {
if (hasDefaultIn) {
column.hidden = !(column.defaultIn && column.defaultIn.has(visibilityGroup));
}
else {
column.hidden = false;
}
}
this._columns.push(column);
}
return this._columns;
}
_getColumn(index) {
return this._getColumns()[index];
}
_updateIntroText() {
if (!window.ZoteroPane) {
return;
}
if (this.collectionTreeRow && !this.rowCount) {
let doc = this._ownerDocument;
let ns = 'http://www.w3.org/1999/xhtml';
let div;
// My Library and no groups
if (this.collectionTreeRow.isLibrary() && !Zotero.Groups.getAll().length) {
div = doc.createElementNS(ns, 'div');
let p = doc.createElementNS(ns, 'p');
let html = Zotero.getString(
'pane.items.intro.text1',
[
Zotero.clientName
]
);
// Encode special chars, which shouldn't exist
html = Zotero.Utilities.htmlSpecialChars(html);
html = `${html}`;
p.innerHTML = html;
div.appendChild(p);
p = doc.createElementNS(ns, 'p');
html = Zotero.getString(
'pane.items.intro.text2',
[
Zotero.getString('connector.name', Zotero.clientName),
Zotero.clientName
]
);
// Encode special chars, which shouldn't exist
html = Zotero.Utilities.htmlSpecialChars(html);
html = html.replace(
/\[([^\]]+)](.+)\[([^\]]+)]/,
`$1`
+ '$2'
+ `$3`
);
p.innerHTML = html;
div.appendChild(p);
p = doc.createElementNS(ns, 'p');
html = Zotero.getString('pane.items.intro.text3', [Zotero.clientName]);
// Encode special chars, which shouldn't exist
html = Zotero.Utilities.htmlSpecialChars(html);
html = html.replace(
/\[([^\]]+)]/,
'$1'
);
p.innerHTML = html;
div.appendChild(p);
// Activate text links
for (let span of div.getElementsByTagName('span')) {
if (span.classList.contains('text-link')) {
if (span.hasAttribute('data-href')) {
span.onclick = function () {
doc.defaultView.ZoteroPane.loadURI(this.getAttribute('data-href'));
};
}
else if (span.hasAttribute('data-action')) {
if (span.getAttribute('data-action') == 'open-sync-prefs') {
span.onclick = () => {
Zotero.Utilities.Internal.openPreferences('zotero-prefpane-sync');
};
}
}
}
}
div.setAttribute('allowdrop', true);
}
// My Publications
else if (this.collectionTreeRow.isPublications()) {
div = doc.createElementNS(ns, 'div');
div.className = 'publications';
let p = doc.createElementNS(ns, 'p');
p.textContent = Zotero.getString('publications.intro.text1', window.ZOTERO_CONFIG.DOMAIN_NAME);
div.appendChild(p);
p = doc.createElementNS(ns, 'p');
p.textContent = Zotero.getString('publications.intro.text2');
div.appendChild(p);
p = doc.createElementNS(ns, 'p');
let html = Zotero.getString('publications.intro.text3');
// Convert tags to placeholders
html = html.replace('', ':b:').replace('', ':/b:');
// Encode any other special chars, which shouldn't exist
html = Zotero.Utilities.htmlSpecialChars(html);
// Restore bold text
html = html.replace(':b:', '').replace(':/b:', '');
p.innerHTML = html; // AMO note: markup from hard-coded strings and filtered above
div.appendChild(p);
}
if (div) {
this._introText = true;
doc.defaultView.ZoteroPane_Local.setItemsPaneMessage(div);
return;
}
this._introText = null;
}
if (this._introText || this._introText === null) {
window.ZoteroPane.clearItemsPaneMessage();
this._introText = false;
}
}
/**
* Restore a scroll position returned from _saveScrollPosition()
*/
_rememberScrollPosition(scrollPosition) {
if (!scrollPosition || !scrollPosition.id || !this._treebox) {
return;
}
var row = this.getRowIndexByID(scrollPosition.id);
if (row === false) {
return;
}
this._treebox.scrollToRow(Math.max(row - scrollPosition.offset, 0));
}
/**
* Return an object describing the current scroll position to restore after changes
*
* @return {Object|Boolean} - Object with .id (a treeViewID) and .offset, or false if no rows
*/
_saveScrollPosition() {
if (!this._treebox) return false;
var treebox = this._treebox;
var first = treebox.getFirstVisibleRow();
if (!first) {
return false;
}
var last = treebox.getLastVisibleRow();
var firstSelected = null;
for (let i = first; i <= last; i++) {
// If an object is selected, keep the first selected one in position
if (this.selection.isSelected(i)) {
let row = this.getRow(i);
if (!row) return false;
return {
id: row.ref.treeViewID,
offset: i - first
};
}
}
// Otherwise keep the first visible row in position
let row = this.getRow(first);
if (!row) return false;
return {
id: row.ref.treeViewID,
offset: 0
};
}
_saveOpenState(close) {
if (!this.tree) return [];
var itemIDs = [];
if (close) {
if (!this.selection.selectEventsSuppressed) {
var unsuppress = this.selection.selectEventsSuppressed = true;
}
}
for (var i=0; i=0; i--) {
this.toggleOpenState(rowsToOpen[i], true);
}
this._refreshRowMap();
if (unsuppress) {
this.selection.selectEventsSuppressed = false;
}
}
/**
*
* @param selection
* @param {Boolean} expandCollapsedParents - if an item to select is in a collapsed parent
* will expand the parent, otherwise the item is ignored
* @param {Boolean} dontEnsureRowsVisible - do not scroll the item tree after restoring selection
* to ensure restored selection is visible
* @private
*/
async _restoreSelection(selection, expandCollapsedParents=true, dontEnsureRowsVisible=false) {
if (!selection.length || !this._treebox) {
return;
}
if (!this.selection.selectEventsSuppressed) {
var unsuppress = this.selection.selectEventsSuppressed = true;
}
this.selection.clearSelection();
let focusedSet = false;
var toggleSelect = (function (itemID) {
if (!focusedSet) {
this.selection.select(this._rowMap[itemID]);
focusedSet = true;
}
else {
this.selection.toggleSelect(this._rowMap[itemID]);
}
}).bind(this);
try {
for (let i = 0; i < selection.length; i++) {
if (this._rowMap[selection[i]] != null) {
toggleSelect(selection[i]);
}
// Try the parent
else {
var item = Zotero.Items.get(selection[i]);
if (!item) {
continue;
}
var parent = item.parentItemID;
if (!parent) {
continue;
}
if (this._rowMap[parent] != null) {
if (expandCollapsedParents) {
await this._closeContainer(this._rowMap[parent]);
await this.toggleOpenState(this._rowMap[parent]);
toggleSelect(selection[i]);
}
else {
!this.selection.isSelected(this._rowMap[parent]) &&
toggleSelect(parent);
}
}
}
}
}
// Ignore NS_ERROR_UNEXPECTED from nsITreeSelection::toggleSelect(), apparently when the tree
// disappears before it's called (though I can't reproduce it):
//
// https://forums.zotero.org/discussion/69226/papers-become-invisible-in-the-middle-pane
catch (e) {
Zotero.logError(e);
}
if (!dontEnsureRowsVisible) {
this.ensureRowsAreVisible(Array.from(this.selection.selected));
}
if (unsuppress) {
this.selection.selectEventsSuppressed = false;
}
}
_handleColumnSort = async (index, sortDirection) => {
let columnSettings = this._getColumnPrefs();
// TEMP: Sort the columns by their ordinals, which can get out of sync after column
// dragging. There's probably a better way to do this.
this._getColumns().sort((a, b) => a.ordinal - b.ordinal);
let column = this._getColumn(index);
if (column.dataKey == 'hasAttachment') {
Zotero.debug("Caching best attachment states");
if (!this._cachedBestAttachmentStates) {
let t = new Date();
for (let i = 0; i < this._rows.length; i++) {
let item = this.getRow(i).ref;
if (item.isRegularItem()) {
await item.getBestAttachmentState();
}
}
Zotero.debug("Cached best attachment states in " + (new Date - t) + " ms");
this._cachedBestAttachmentStates = true;
}
}
if (this._sortedColumn && this._sortedColumn.dataKey == column.dataKey) {
this._sortedColumn.sortDirection = sortDirection;
if (columnSettings[column.dataKey]) {
columnSettings[column.dataKey].sortDirection = this._sortedColumn.sortDirection;
}
}
else {
if (this._sortedColumn) {
delete this._sortedColumn.sortDirection;
if (columnSettings[column.dataKey]) {
delete columnSettings[this._sortedColumn.dataKey].sortDirection;
}
}
this._sortedColumn = column;
this._sortedColumn.sortDirection = sortDirection;
if (columnSettings[column.dataKey]) {
columnSettings[column.dataKey].sortDirection = this._sortedColumn.sortDirection;
}
}
await this._refreshPromise;
this.selection.selectEventsSuppressed = true;
await this.sort();
this.forceUpdate(() => {
this.selection.selectEventsSuppressed = false;
// Store column prefs as a final action because it freezes the UI momentarily
// and makes the column sorting look laggy
this._storeColumnPrefs(columnSettings);
});
}
_displayColumnPickerMenu = (event) => {
if (!this.props.columnPicker) return;
const ns = 'http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul';
const prefix = 'zotero-column-picker-';
const doc = document;
const menupopup = doc.createElementNS(ns, 'menupopup');
menupopup.id = 'zotero-column-picker';
menupopup.addEventListener('popuphiding', (event) => {
if (event.target.id == menupopup.id) {
document.children[0].removeChild(menupopup);
}
});
const columns = this._getColumns()
.sort((a, b) => a.ordinal - b.ordinal);
for (let i = 0; i < columns.length; i++) {
const column = columns[i];
if (column.ignoreInColumnPicker === true) continue;
let label = Zotero.Intl.strings[column.label] || column.label;
let menuitem = doc.createElementNS(ns, 'menuitem');
menuitem.setAttribute('type', 'checkbox');
menuitem.setAttribute('label', label);
menuitem.setAttribute('colindex', i);
menuitem.addEventListener('command', () => this.tree._columns.toggleHidden(i));
if (!column.hidden) {
menuitem.setAttribute('checked', true);
}
if (column.disabledIn && column.disabledIn.includes(this.collectionTreeRow.visibilityGroup)) {
menuitem.setAttribute('disabled', true);
}
menupopup.appendChild(menuitem);
}
try {
// More Columns menu
let id = prefix + 'more-menu';
let moreMenu = doc.createElementNS(ns, 'menu');
moreMenu.setAttribute('label', Zotero.getString('pane.items.columnChooser.moreColumns'));
moreMenu.setAttribute('anonid', id);
let moreMenuPopup = doc.createElementNS(ns, 'menupopup');
moreMenuPopup.setAttribute('anonid', id + '-popup');
let moreItems = [];
for (let i = 0; i < columns.length; i++) {
const column = columns[i];
if (column.submenu) {
moreItems.push(menupopup.children[i]);
}
}
// Sort fields and move to submenu
var collation = Zotero.getLocaleCollation();
moreItems.sort(function (a, b) {
return collation.compareString(1, a.getAttribute('label'), b.getAttribute('label'));
});
moreItems.forEach(function (elem) {
moreMenuPopup.appendChild(menupopup.removeChild(elem));
});
let sep = doc.createElementNS(ns, 'menuseparator');
menupopup.appendChild(sep);
moreMenu.appendChild(moreMenuPopup);
menupopup.appendChild(moreMenu);
}
catch (e) {
Cu.reportError(e);
Zotero.debug(e, 1);
}
//
// Secondary Sort menu
//
if (!this.collectionTreeRow.isFeed()) {
try {
const id = prefix + 'sort-menu';
const primaryField = this.getSortField();
const sortFields = this.getSortFields();
let secondaryField = false;
if (sortFields[1]) {
secondaryField = sortFields[1];
}
const primaryFieldLabel = Zotero.Intl.strings[columns.find(c => c.dataKey == primaryField).label];
const sortMenu = doc.createElementNS(ns, 'menu');
sortMenu.setAttribute('label',
Zotero.getString('pane.items.columnChooser.secondarySort', primaryFieldLabel));
sortMenu.setAttribute('anonid', id);
const sortMenuPopup = doc.createElementNS(ns, 'menupopup');
sortMenuPopup.setAttribute('anonid', id + '-popup');
// Generate menuitems
const sortOptions = [
'title',
'firstCreator',
'itemType',
'date',
'year',
'publisher',
'publicationTitle',
'dateAdded',
'dateModified'
];
for (let field of sortOptions) {
// Hide current primary field, and don't show Year for Date, since it would be a no-op
if (field == primaryField || (primaryField == 'date' && field == 'year')) {
continue;
}
let column = columns.find(c => c.dataKey == field);
let label = Zotero.Intl.strings[column.label] || column.label;
let sortMenuItem = doc.createElementNS(ns, 'menuitem');
sortMenuItem.setAttribute('fieldName', field);
sortMenuItem.setAttribute('label', label);
sortMenuItem.setAttribute('type', 'checkbox');
if (field == secondaryField) {
sortMenuItem.setAttribute('checked', 'true');
}
sortMenuItem.addEventListener('command', async () => {
if (this._setSecondarySortField(field)) {
await this.sort();
}
})
sortMenuPopup.appendChild(sortMenuItem);
}
sortMenu.appendChild(sortMenuPopup);
menupopup.appendChild(sortMenu);
}
catch (e) {
Cu.reportError(e);
Zotero.debug(e, 1);
}
}
let sep = doc.createElementNS(ns, 'menuseparator');
// sep.setAttribute('anonid', prefix + 'sep');
menupopup.appendChild(sep);
//
// Restore Default Column Order
//
let menuitem = doc.createElementNS(ns, 'menuitem');
menuitem.setAttribute('label', Zotero.Intl.strings['zotero.items.restoreColumnOrder.label']);
menuitem.setAttribute('anonid', prefix + 'restore-order');
menuitem.addEventListener('command', () => this.tree._columns.restoreDefaultOrder());
menupopup.appendChild(menuitem);
document.children[0].appendChild(menupopup);
menupopup.openPopup(null, null, event.clientX + 2, event.clientY + 2);
}
_getSecondarySortField() {
var primaryField = this.getSortField();
var secondaryField = Zotero.Prefs.get('secondarySort.' + primaryField);
if (!secondaryField || secondaryField == primaryField) {
return false;
}
return secondaryField;
}
_setSecondarySortField(secondaryField) {
var primaryField = this.getSortField();
var currentSecondaryField = this._getSecondarySortField();
var sortFields = this.getSortFields();
if (primaryField == secondaryField) {
return false;
}
if (currentSecondaryField) {
// If same as the current explicit secondary sort, ignore
if (currentSecondaryField == secondaryField) {
return false;
}
// If not, but same as first implicit sort, remove current explicit sort
if (sortFields[2] && sortFields[2] == secondaryField) {
Zotero.Prefs.clear('secondarySort.' + primaryField);
return true;
}
}
// If same as current implicit secondary sort, ignore
else if (sortFields[1] && sortFields[1] == secondaryField) {
return false;
}
Zotero.Prefs.set('secondarySort.' + primaryField, secondaryField);
return true;
}
_getIcon(index) {
var item = this.getRow(index).ref;
var itemType = Zotero.ItemTypes.getName(item.itemTypeID);
if (itemType == 'attachment') {
var linkMode = item.attachmentLinkMode;
if (item.attachmentContentType == 'application/pdf' && item.isFileAttachment()) {
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
itemType += 'PdfLink';
}
else {
itemType += 'Pdf';
}
}
else if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE) {
itemType += "File";
}
else if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
itemType += "Link";
}
else if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL) {
itemType += "Snapshot";
}
else if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
itemType += "WebLink";
}
}
let iconClsName = "IconTreeitem" + Zotero.Utilities.capitalize(itemType);
if (!Icons[iconClsName]) {
iconClsName = "IconTreeitem";
}
var icon = getDOMElement(iconClsName);
if (!icon) {
Zotero.debug('Could not find tree icon for "' + itemType + '"');
return document.createElementNS("http://www.w3.org/1999/xhtml", 'span');
}
return icon;
}
_isOnlyEmoji(str) {
// Remove emoji and zero-width joiner and see if anything's left
return !str.replace(EMOJI_RE, '').replace(/\u200D/g,'');
}
_getTagSwatch(tag, color) {
let span = document.createElementNS("http://www.w3.org/1999/xhtml", 'span');
span.className = 'tag-swatch';
// If only emoji, display directly
//
// TODO: Check for a maximum number of graphemes, which is hard to do
// https://stackoverflow.com/a/54369605
if (this._isOnlyEmoji(tag)) {
span.textContent = tag;
}
// Otherwise display color
else {
span.style.backgroundColor = color;
}
return span;
}
};
var ItemTreeRow = function(ref, level, isOpen)
{
this.ref = ref; //the item associated with this
this.level = level;
this.isOpen = isOpen;
this.id = ref.id;
}
ItemTreeRow.prototype.getField = function(field, unformatted)
{
return this.ref.getField(field, unformatted, true);
}
ItemTreeRow.prototype.numNotes = function() {
if (this.ref.isNote()) {
return 0;
}
if (this.ref.isAttachment()) {
return this.ref.note !== '' ? 1 : 0;
}
return this.ref.numNotes(false, true) || 0;
}
Zotero.Utilities.Internal.makeClassEventDispatcher(ItemTree);
module.exports = ItemTree;